From bc4434c93d5b352a8f6d845be4bcde504f43b89a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=B2=A9=E5=B2=A9?= Date: Mon, 9 Feb 2026 00:27:46 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=B8=9A=E5=8A=A1?= =?UTF-8?q?=E9=80=BB=E8=BE=91=20composables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useFavorites: 收藏管理 - useHistory: 播放历史 - useSettings: 用户设置 - useChannels: 频道数据获取和解析 - useGroups: 分组管理 - useChannelFilter: 频道过滤 - useDates: 日期列表 - usePrograms: 节目单管理 - useEvent: 键盘事件 --- ui/src/composables/useChannelFilter.js | 94 +++++++++++++++ ui/src/composables/useChannels.js | 151 +++++++++++++++++++++++++ ui/src/composables/useDates.js | 47 ++++++++ ui/src/composables/useEvent.js | 19 ++++ ui/src/composables/useFavorites.js | 76 +++++++++++++ ui/src/composables/useGroups.js | 82 ++++++++++++++ ui/src/composables/useHistory.js | 84 ++++++++++++++ ui/src/composables/usePrograms.js | 42 +++++++ ui/src/composables/useSettings.js | 85 ++++++++++++++ ui/src/composables/useUI.js | 56 +++------ ui/src/utils/common.js | 8 ++ 11 files changed, 702 insertions(+), 42 deletions(-) create mode 100644 ui/src/composables/useChannelFilter.js create mode 100644 ui/src/composables/useChannels.js create mode 100644 ui/src/composables/useDates.js create mode 100644 ui/src/composables/useEvent.js create mode 100644 ui/src/composables/useFavorites.js create mode 100644 ui/src/composables/useGroups.js create mode 100644 ui/src/composables/useHistory.js create mode 100644 ui/src/composables/usePrograms.js create mode 100644 ui/src/composables/useSettings.js create mode 100644 ui/src/utils/common.js diff --git a/ui/src/composables/useChannelFilter.js b/ui/src/composables/useChannelFilter.js new file mode 100644 index 0000000..0b6a138 --- /dev/null +++ b/ui/src/composables/useChannelFilter.js @@ -0,0 +1,94 @@ +import { ref, computed } from "vue"; + +export function useChannelFilter() { + const selectedChannel = ref(null); + const allChannels = ref([]); + const selectedGroup = ref(""); + const favorites = ref(new Set()); + const validityMap = ref(new Map()); + + // 过滤后的频道 + const filteredChannels = computed(() => { + if (!selectedGroup.value) return allChannels.value; + + // 置顶分组特殊处理 + if (selectedGroup.value === "recent") { + // TODO: 返回最近播放 + return []; + } + if (selectedGroup.value === "favorite") { + return allChannels.value.filter((c) => favorites.value.has(c.id)); + } + + return allChannels.value.filter((c) => c.group === selectedGroup.value); + }); + + // 获取频道 LOGO(取前两个字符) + function getChannelLogo(name) { + return name?.slice(0, 2) || "--"; + } + + // 检查是否收藏 + function isFavorite(channelId) { + return favorites.value.has(channelId); + } + + // 获取有效线路数 + function getValidCount(channel) { + if (!channel?.urls) return 0; + return channel.urls.filter((url) => { + const validity = validityMap.value.get(url); + return validity?.status === "online"; + }).length; + } + + // 选择频道 + function selectChannel(channel) { + selectedChannel.value = channel; + return channel; + } + + // 设置数据源 + function setChannels(channels) { + allChannels.value = channels; + } + + function setSelectedGroup(group) { + selectedGroup.value = group; + } + + function setFavorites(favSet) { + favorites.value = favSet; + } + + function setValidityMap(map) { + validityMap.value = map; + } + + // 根据当前频道初始化 + function initSelectedChannel(channel) { + if (channel) { + selectedChannel.value = channel; + } + } + + // 获取第一个频道(用于分组切换时自动选中) + function getFirstChannel() { + return filteredChannels.value[0] || null; + } + + return { + selectedChannel, + filteredChannels, + selectChannel, + setChannels, + setSelectedGroup, + setFavorites, + setValidityMap, + initSelectedChannel, + getFirstChannel, + getChannelLogo, + isFavorite, + getValidCount, + }; +} diff --git a/ui/src/composables/useChannels.js b/ui/src/composables/useChannels.js new file mode 100644 index 0000000..6d76745 --- /dev/null +++ b/ui/src/composables/useChannels.js @@ -0,0 +1,151 @@ +import { ref, computed, onMounted } from "vue"; +import { useStorage } from "./useStorage.js"; + +// 全局状态 +const channels = ref([]); +const loading = ref(false); +let initialized = false; + +export function useChannels() { + const storage = useStorage(); + + // 分组列表(基于频道计算) + const groups = computed(() => { + return [...new Set(channels.value.map((c) => c.group))]; + }); + + // 加载频道数据 + const loadChannels = async (force = false) => { + if (loading.value) return; + loading.value = true; + + try { + // 检查缓存 + if (!force) { + const isValid = await storage.isListCacheValid(); + if (isValid) { + const cached = await storage.getChannels(); + if (cached && cached.length > 0) { + channels.value = cached; + loading.value = false; + // 后台刷新 + refreshChannelsInBackground(); + return; + } + } + } + + // 从网络加载 + const text = await fetchChannelData(); + if (text && !text.startsWith("ERROR")) { + const parsed = parseChannelData(text); + channels.value = parsed; + await storage.setChannels(parsed); + } + } finally { + loading.value = false; + } + }; + + // 后台刷新频道 + const refreshChannelsInBackground = async () => { + try { + const text = await fetchChannelData(); + if (text && !text.startsWith("ERROR")) { + const parsed = parseChannelData(text); + if (parsed.length > 0) { + channels.value = parsed; + await storage.setChannels(parsed); + } + } + } catch (e) { + console.error("后台刷新失败:", e); + } + }; + + // 获取频道数据(网络) + const fetchChannelData = async () => { + const platform = import.meta.env.VITE_PLATFORM || "web"; + + // Android/TV 使用原生接口 + if ((platform === "android" || platform === "tv") && window.AndroidAsset) { + try { + return window.AndroidAsset.readChannelData(); + } catch (e) { + console.error("读取本地数据失败:", e); + } + } + + // Web/Desktop 从网络加载 + try { + const response = await fetch( + "https://cdn.jsdelivr.net/gh/Guovin/iptv-api@gd/output/result.txt" + ); + if (response.ok) { + return await response.text(); + } + } catch (e) { + console.error("网络加载失败:", e); + } + + return null; + }; + + // 解析频道数据 + const parseChannelData = (text) => { + const result = []; + const lines = text.split("\n"); + let currentGroup = ""; + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + if (trimmed.includes("#genre#")) { + currentGroup = trimmed.split(",")[0]; + } else if (trimmed.includes(",")) { + const [name, url] = trimmed.split(",").map((s) => s.trim()); + if (name && url) { + const existing = result.find( + (c) => c.name === name && c.group === currentGroup + ); + if (existing) { + existing.urls.push(url); + } else { + result.push({ + id: `${currentGroup}_${name}`, + name, + group: currentGroup, + urls: [url], + logo: "", + epgId: "", + }); + } + } + } + } + + return result; + }; + + // 根据索引获取频道 + const getChannelByIndex = (index) => { + return channels.value[index] || null; + }; + + // 初始化加载 + onMounted(() => { + if (!initialized) { + loadChannels(); + initialized = true; + } + }); + + return { + channels, + groups, + loading, + loadChannels, + getChannelByIndex, + }; +} diff --git a/ui/src/composables/useDates.js b/ui/src/composables/useDates.js new file mode 100644 index 0000000..816dc5d --- /dev/null +++ b/ui/src/composables/useDates.js @@ -0,0 +1,47 @@ +import { ref, computed } from "vue"; + +// 日期标签 +const DAY_LABELS = ["今天", "明天", "后天"]; + +export function useDates() { + const selectedDate = ref(""); + + // 生成日期列表(今天、明天、后天...) + const dates = computed(() => { + const list = []; + const today = new Date(); + + for (let i = 0; i < 7; i++) { + const date = new Date(today); + date.setDate(today.getDate() + i); + + const value = date.toISOString().split("T")[0]; + const day = `${date.getMonth() + 1}/${date.getDate()}`; + const label = + i < 3 + ? DAY_LABELS[i] + : `${date.getMonth() + 1}月${date.getDate()}日`; + + list.push({ value, day, label }); + } + + return list; + }); + + // 选择日期 + function selectDate(dateValue) { + selectedDate.value = dateValue; + } + + // 初始化为今天 + function initToday() { + selectedDate.value = new Date().toISOString().split("T")[0]; + } + + return { + selectedDate, + dates, + selectDate, + initToday, + }; +} diff --git a/ui/src/composables/useEvent.js b/ui/src/composables/useEvent.js new file mode 100644 index 0000000..9acadde --- /dev/null +++ b/ui/src/composables/useEvent.js @@ -0,0 +1,19 @@ +import { onBeforeUnmount } from "vue"; + +export const useEvent = (eventName, callback) => { + window.addEventListener(eventName, callback); + + onBeforeUnmount(() => { + window.removeEventListener(eventName, callback); + }); +}; + +export const useKeyEvent = (key, callback) => { + const handler = (e) => { + console.log("🚀 ~ handler ~ e:", e.key); + if (e.key === key) { + callback(e); + } + }; + useEvent("keyup", handler); +}; diff --git a/ui/src/composables/useFavorites.js b/ui/src/composables/useFavorites.js new file mode 100644 index 0000000..43ba9f0 --- /dev/null +++ b/ui/src/composables/useFavorites.js @@ -0,0 +1,76 @@ +import { ref, onMounted } from "vue"; +import { useStorage } from "./useStorage.js"; + +const STORAGE_KEY = "favorites_data"; + +// 全局状态 +const favorites = ref(new Set()); +let initialized = false; +let loadPromise = null; + +export function useFavorites() { + const storage = useStorage(); + + // 加载收藏(从 useStorage) + const loadFavorites = async () => { + if (loadPromise) return loadPromise; + + loadPromise = (async () => { + try { + const saved = await storage.get(STORAGE_KEY); + if (saved) { + favorites.value = new Set(saved); + } + } catch (e) { + console.error("加载收藏失败:", e); + } + initialized = true; + })(); + + return loadPromise; + }; + + // 保存收藏(到 useStorage) + const saveFavorites = async () => { + try { + await storage.set(STORAGE_KEY, [...favorites.value]); + } catch (e) { + console.error("保存收藏失败:", e); + } + }; + + // 切换收藏状态 + const toggleFavorite = async (channelId) => { + if (!channelId) return; + if (favorites.value.has(channelId)) { + favorites.value.delete(channelId); + } else { + favorites.value.add(channelId); + } + await saveFavorites(); + }; + + // 检查是否已收藏 + const isFavorite = (channelId) => { + return favorites.value.has(channelId); + }; + + // 获取所有收藏 + const getFavorites = () => { + return [...favorites.value]; + }; + + // 初始化加载 + onMounted(() => { + if (!initialized) { + loadFavorites(); + } + }); + + return { + favorites, + toggleFavorite, + isFavorite, + getFavorites, + }; +} diff --git a/ui/src/composables/useGroups.js b/ui/src/composables/useGroups.js new file mode 100644 index 0000000..e233591 --- /dev/null +++ b/ui/src/composables/useGroups.js @@ -0,0 +1,82 @@ +import { ref, computed } from "vue"; + +// 置顶分组配置 +const PINNED_GROUPS = [ + { id: "recent", name: "最近播放", icon: "⏱" }, + { id: "favorite", name: "收藏", icon: "❤️" }, +]; + +// 分组图标映射 +const GROUP_ICONS = { + 央视: "📺", + 卫视: "📡", + 体育: "⚽", + 电影: "🎬", + 少儿: "👶", +}; + +export function useGroups() { + const selectedGroup = ref(""); + const rawGroups = ref([]); + + // 计算置顶分组(带数量) + const pinnedGroups = computed(() => { + return PINNED_GROUPS.map((g) => ({ + ...g, + count: 0, // TODO: 实际数量从外部传入 + })); + }); + + // 计算普通分组(带图标和数量) + const normalGroups = computed(() => { + return rawGroups.value.map((group) => ({ + id: group, + name: group, + icon: getGroupIcon(group), + count: 0, // TODO: 实际数量从外部传入 + })); + }); + + // 所有分组 + const allGroups = computed(() => [...pinnedGroups.value, ...normalGroups.value]); + + // 获取分组图标 + function getGroupIcon(groupName) { + for (const [key, icon] of Object.entries(GROUP_ICONS)) { + if (groupName.includes(key)) return icon; + } + return "📺"; + } + + // 选择分组 + function selectGroup(groupId) { + selectedGroup.value = groupId; + } + + // 设置原始分组数据 + function setGroups(groups) { + rawGroups.value = groups; + } + + // 根据当前频道初始化选中分组 + function initSelectedGroup(channel, defaultGroup = "") { + if (channel?.group) { + selectedGroup.value = channel.group; + } else if (defaultGroup) { + selectedGroup.value = defaultGroup; + } else if (rawGroups.value.length > 0) { + selectedGroup.value = rawGroups.value[0]; + } + } + + return { + selectedGroup, + pinnedGroups, + normalGroups, + allGroups, + selectGroup, + setGroups, + initSelectedGroup, + getGroupIcon, + }; +} diff --git a/ui/src/composables/useHistory.js b/ui/src/composables/useHistory.js new file mode 100644 index 0000000..bad3da6 --- /dev/null +++ b/ui/src/composables/useHistory.js @@ -0,0 +1,84 @@ +import { ref, onMounted } from "vue"; +import { useStorage } from "./useStorage.js"; + +const STORAGE_KEY = "play_history"; +const MAX_HISTORY = 20; + +// 全局状态 +const history = ref([]); +let initialized = false; +let loadPromise = null; + +export function useHistory() { + const storage = useStorage(); + + // 加载历史(从 useStorage) + const loadHistory = async () => { + if (loadPromise) return loadPromise; + + loadPromise = (async () => { + try { + const saved = await storage.get(STORAGE_KEY); + if (saved) { + history.value = saved; + } + } catch (e) { + console.error("加载历史失败:", e); + } + initialized = true; + })(); + + return loadPromise; + }; + + // 保存历史(到 useStorage) + const saveHistory = async () => { + try { + await storage.set(STORAGE_KEY, [...history.value]); + } catch (e) { + console.error("保存历史失败:", e); + } + }; + + // 添加到历史 + const addToHistory = async (channel) => { + if (!channel) return; + // 移除重复项 + history.value = history.value.filter((h) => h.id !== channel.id); + // 添加到开头 + history.value.unshift({ + ...channel, + playedAt: Date.now(), + }); + // 限制数量 + if (history.value.length > MAX_HISTORY) { + history.value = history.value.slice(0, MAX_HISTORY); + } + await saveHistory(); + }; + + // 清空历史 + const clearHistory = async () => { + history.value = []; + await saveHistory(); + }; + + // 获取历史列表 + const getHistory = () => { + return [...history.value]; + }; + + // 初始化加载 + onMounted(() => { + if (!initialized) { + loadHistory(); + } + }); + + return { + history, + addToHistory, + clearHistory, + getHistory, + }; +} diff --git a/ui/src/composables/usePrograms.js b/ui/src/composables/usePrograms.js new file mode 100644 index 0000000..05e6878 --- /dev/null +++ b/ui/src/composables/usePrograms.js @@ -0,0 +1,42 @@ +import { ref, computed } from "vue"; + +// 模拟节目单数据(TODO: 实际从 EPG 获取) +const MOCK_PROGRAMS = [ + { id: 1, time: "08:00", title: "朝闻天下", isCurrent: false }, + { id: 2, time: "09:00", title: "今日说法", isCurrent: false }, + { id: 3, time: "10:00", title: "电视剧:繁花", isCurrent: true }, + { id: 4, time: "12:00", title: "新闻30分", isCurrent: false }, + { id: 5, time: "13:00", title: "电视剧:西游记", isCurrent: false }, + { id: 6, time: "15:00", title: "动画剧场", isCurrent: false }, + { id: 7, time: "19:00", title: "新闻联播", isCurrent: false }, +]; + +export function usePrograms() { + const selectedProgram = ref(null); + const programs = ref([...MOCK_PROGRAMS]); + + // 选择节目 + function selectProgram(program) { + selectedProgram.value = program; + return program; + } + + // 加载指定日期的节目(TODO: 实际从 EPG 获取) + function loadProgramsByDate(dateValue) { + // TODO: 根据日期加载节目单 + console.log("加载节目单:", dateValue); + } + + // 更新节目单 + function setPrograms(newPrograms) { + programs.value = newPrograms; + } + + return { + selectedProgram, + programs, + selectProgram, + loadProgramsByDate, + setPrograms, + }; +} diff --git a/ui/src/composables/useSettings.js b/ui/src/composables/useSettings.js new file mode 100644 index 0000000..fcefc94 --- /dev/null +++ b/ui/src/composables/useSettings.js @@ -0,0 +1,85 @@ +import { ref, onMounted } from "vue"; +import { useStorage } from "./useStorage.js"; + +const STORAGE_KEY = "user_settings"; + +// 默认设置 +const defaultSettings = { + autoPlay: true, + defaultVolume: 0.8, + showEpg: true, + theme: "dark", + checkTimeout: 2000, // 检测超时时间(ms) + checkConcurrency: 5, // 并发数 +}; + +// 全局状态 +const settings = ref({ ...defaultSettings }); +let initialized = false; +let loadPromise = null; + +export function useSettings() { + const storage = useStorage(); + + // 加载设置(从 useStorage) + const loadSettings = async () => { + if (loadPromise) return loadPromise; + + loadPromise = (async () => { + try { + const saved = await storage.get(STORAGE_KEY); + if (saved) { + settings.value = { ...defaultSettings, ...saved }; + } + } catch (e) { + console.error("加载设置失败:", e); + } + initialized = true; + })(); + + return loadPromise; + }; + + // 保存设置(到 useStorage) + const saveSettings = async () => { + try { + await storage.set(STORAGE_KEY, settings.value); + } catch (e) { + console.error("保存设置失败:", e); + } + }; + + // 更新单个设置 + const updateSetting = async (key, value) => { + if (key in settings.value) { + settings.value[key] = value; + await saveSettings(); + } + }; + + // 更新多个设置 + const updateSettings = async (newSettings) => { + settings.value = { ...settings.value, ...newSettings }; + await saveSettings(); + }; + + // 重置设置为默认值 + const resetSettings = async () => { + settings.value = { ...defaultSettings }; + await saveSettings(); + }; + + // 初始化加载 + onMounted(() => { + if (!initialized) { + loadSettings(); + } + }); + + return { + settings, + updateSetting, + updateSettings, + resetSettings, + }; +} diff --git a/ui/src/composables/useUI.js b/ui/src/composables/useUI.js index 5dbe6d0..b37257b 100644 --- a/ui/src/composables/useUI.js +++ b/ui/src/composables/useUI.js @@ -1,33 +1,20 @@ -import { ref, computed } from 'vue'; - -// 防抖工具函数 -function debounce(fn, delay) { - let timer = null; - return function (...args) { - clearTimeout(timer); - timer = setTimeout(() => fn.apply(this, args), delay); - }; -} +import { ref, computed } from "vue"; +import { debounce } from "../utils/common.js"; // 当前激活的栏索引(用于TV导航) const activeColumnIndex = ref(0); const leftPanelVisible = ref(false); const bottomPanelVisible = ref(false); -// 防抖隐藏底部栏 -const debouncedHideBottomPanel = debounce(() => { - bottomPanelVisible.value = false; -}, 3000); - // 当前选中的分组/频道/日期/节目 -const selectedGroup = ref(''); +const selectedGroup = ref(""); const selectedChannel = ref(null); -const selectedDate = ref(''); +const selectedDate = ref(""); const selectedProgram = ref(null); export function useUI() { - const platform = import.meta.env.VITE_PLATFORM || 'web'; - const isTV = platform === 'tv'; + const platform = import.meta.env.VITE_PLATFORM || "web"; + const isTV = platform === "tv"; // 显示左侧面板 const showLeftPanel = () => { @@ -54,18 +41,13 @@ export function useUI() { // 显示底部栏(启动防抖隐藏) const showBottomPanel = () => { bottomPanelVisible.value = true; - debouncedHideBottomPanel(); + hideBottomPanel(); }; // 隐藏底部栏 - const hideBottomPanel = () => { + const hideBottomPanel = debounce(() => { bottomPanelVisible.value = false; - }; - - // 底部栏交互(重置防抖) - const onBottomInteraction = () => { - debouncedHideBottomPanel(); - }; + }, 3000); // 设置选中项 const setSelectedGroup = (group) => { @@ -87,11 +69,11 @@ export function useUI() { // TV 导航:切换栏 const moveColumn = (direction) => { if (!leftPanelVisible.value) return; - + const maxIndex = 3; // 0-3 四栏 - if (direction === 'right') { + if (direction === "right") { activeColumnIndex.value = Math.min(activeColumnIndex.value + 1, maxIndex); - } else if (direction === 'left') { + } else if (direction === "left") { activeColumnIndex.value = Math.max(activeColumnIndex.value - 1, 0); } }; @@ -107,18 +89,17 @@ export function useUI() { selectedChannel, selectedDate, selectedProgram, - + // 计算属性 isTV, currentActiveColumn, - + // 方法 showLeftPanel, hideLeftPanel, toggleLeftPanel, showBottomPanel, hideBottomPanel, - onBottomInteraction, setSelectedGroup, setSelectedChannel, setSelectedDate, @@ -126,12 +107,3 @@ export function useUI() { moveColumn, }; } - -// 创建单例 -let uiInstance = null; -export function useUISingleton() { - if (!uiInstance) { - uiInstance = useUI(); - } - return uiInstance; -} diff --git a/ui/src/utils/common.js b/ui/src/utils/common.js new file mode 100644 index 0000000..f4d57c1 --- /dev/null +++ b/ui/src/utils/common.js @@ -0,0 +1,8 @@ +// 防抖工具函数 +export const debounce = (fn, delay) => { + let timer = null; + return function (...args) { + clearTimeout(timer); + timer = setTimeout(() => fn.apply(this, args), delay); + }; +};