
iptvnator 直播频道列表隐藏状态修复按面隔离的状态设计、恢复交互与可访问性实践【免费下载链接】iptvnator:tv: Cross-platform IPTV player application with multiple features, such as support of m3u and m3u8 playlists, favorites, TV guide, TV archive/catchup and more.项目地址: https://gitcode.com/GitHub_Trending/ip/iptvnator导读本篇文章围绕 iptvnator 仓库中 live-tv-hidden-channel-list 变更记录 所记录的修复展开深入剖析一个典型的状态泄漏 Bugissue #1458一次折叠操作曾通过一个共享的 localStorage 键让 M3U 播放器、Xtream/Stalker 门户与收藏/最近观看中的所有频道列表同时消失且能跨重启、跨删除全部播放列表与重新导入存活。读完本文你将掌握 iptvnator 如何通过按面surface隔离的状态键、三级折叠模型、占位空状态组件与工作区头部常驻开关从数据、交互与可访问性三个层面根治该问题并了解对应的单元测试与 Electron E2E 测试如何锁定回归。一、问题背景一个共享存储键引发的全站频道消失该变更记录的原始描述如下A hidden channel list no longer looks like a playlist that lost its channels: the player now says the list is hidden and offers a Show channels list button, a toggle in the workspace header stays in place in both states, and hiding the list in one place (M3U player, portal Live TV, favorites/recent) no longer hides it everywhere else.从变更记录可以看出修复前存在三个并发的体验缺陷列表消失而非隐藏频道列表被折叠后界面看起来像播放列表丢掉了所有频道用户无从判断是自己折叠了列表还是数据出了问题缺少显眼的恢复入口用户只能靠一条 32px 的窄恢复条chevron把列表拉回来状态全局共享在一个位置M3U 播放器、门户 Live TV、收藏/最近观看隐藏列表会连带隐藏其他所有位置的列表且持久化后能跨重启存活。Electron E2E 测试 live-sidebar-collapse.e2e.ts 的开头注释完整还原了第二个用户报告的现场Issue #1458, second report: all channels disappear after clearing the playback history; a reset does not bring them back. The history write never touched the channels — the reporters screenshot shows a collapsed channel rail, a state that used to be persisted under one key shared by every live surface and that survived restart, Remove all playlists and re-import.也就是说用户清空播放历史后怀疑数据被删但实际只是频道栏处于折叠态——而这个折叠态存储在一个所有直播面共用的键live-sidebar-state下。E2E 测试用LEGACY_STATE_KEY live-sidebar-state显式钉住了这个历史键确保它不再被读取。二、修复总览数据、交互、可访问性三层并进对照变更记录的三句话与源码可以梳理出修复的完整落点变更记录要点源码实现落点目标player now says the list is hiddenapp-channel-list-hidden-state占位组件channel-list-hidden-state.component.ts让隐藏状态可被明确识别offers a Show channels list button占位组件内全尺寸actionLabel按钮(restore)事件接回切换逻辑提供比 32px chevron 更显眼的恢复入口a toggle in the workspace header stays in place in both statesWorkspaceShellHeaderServiceresolveRouteLiveSidebarSurfacelive-sidebar-state.ts折叠/展开两态下头部开关始终存在no longer hides it everywhere else按m3u/portal/collection三面拆分存储键状态作用域隔离三、核心状态模型三级折叠与三个面3.1 三级状态LiveSidebarState在 live-sidebar-state.ts 中直播 TV 面板的可见性被建模为三个从外到内嵌套的层级export type LiveSidebarState expanded | categories-hidden | collapsed;expanded分类栏 频道栏 播放器三者齐全categories-hidden频道栏 播放器。分类栏被折叠但通过频道栏头部的分类下拉仍可一键唤回对于没有分类栏的m3u、collection面此状态与expanded等价collapsed仅播放器剧场模式。源码注释特别指出一个设计约束刻意不存在频道隐藏、分类可见的状态因为点击分类必然要带回频道栏。3.2 三个面LiveSidebarSurfaceexport type LiveSidebarSurface m3u | portal | collection;每个面各自记住自己的折叠状态这正是在一个地方隐藏列表不会连带隐藏其他所有位置的关键export const LIVE_SIDEBAR_SURFACES: readonly LiveSidebarSurface[] [ m3u, portal, collection, ];m3uM3U 播放器页面portalXtream/Stalker 直播布局含工作区 shell 的分类栏collection统一的收藏/最近观看直播页签。3.3 存储键设计从共享键到按面键修复前后存储键的对比在代码注释中说得非常直白/** * Key every live surface shared before the per-surface split. It is no longer * read: a stored collapsed left every playlist and portal without a channel * list and only a 32px chevron to recover it (issue #1458). The state service * removes it once, so the update itself restores the list for everyone. */ export const LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY live-sidebar-state;新键为live-sidebar-state:surfaceexport function liveSidebarStateStorageKey( surface: LiveSidebarSurface ): string { return ${LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY}:${surface}; }即实际写入 localStorage 的键为live-sidebar-state:m3u、live-sidebar-state:portal、live-sidebar-state:collection。Electron E2E 测试中同样用M3U_STATE_KEY live-sidebar-state:m3u与PORTAL_STATE_KEY live-sidebar-state:portal验证了按面隔离。读写的边界条件也做了防御export function restoreLiveSidebarState( storageKey: string, fallback: LiveSidebarState DEFAULT_LIVE_SIDEBAR_STATE ): LiveSidebarState { const storedValue localStorage.getItem(storageKey); return isLiveSidebarState(storedValue) ? storedValue : fallback; }默认值DEFAULT_LIVE_SIDEBAR_STATE expanded任何非法/缺失的存储值都会回落到展开态。3.4 旧键的退役清理服务构造函数的第一件事就是清除旧共享键让升级本身即为所有用户恢复列表constructor() { forgetLegacyLiveSidebarState(); ... }export function forgetLegacyLiveSidebarState(): void { try { localStorage.removeItem(LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY); } catch { // Storage unavailable (private mode, blocked site data): nothing to forget. } }该函数对 localStorage 不可用隐私模式、站点数据被拦截的场景做了兜底避免抛出异常。四、状态服务LiveLayoutSidebarStateServicelive-layout-sidebar-state.service.ts 是折叠状态的唯一所有者声明为Injectable({ providedIn: root })。所有渲染可折叠频道栏的表面——M3U 播放器、Xtream/Stalker 直播布局、统一收藏/最近页签、工作区头部开关——都通过该服务读写状态从而保证同一面内在会话中的一致反映。4.1 内部结构按面的 Signal 映射private readonly states: RecordLiveSidebarSurface, WritableSignalLiveSidebarState; private readonly collapsed: RecordLiveSidebarSurface, Signalboolean; private readonly categoriesHidden: RecordLiveSidebarSurface, Signalboolean;三个面各自拥有一组states/collapsed/categoriesHidden通过bySurface工厂统一创建function bySurfaceT( create: (surface: LiveSidebarSurface) T ): RecordLiveSidebarSurface, T { return { m3u: create(m3u), portal: create(portal), collection: create(collection), }; }派生信号的含义值得注意collapsed[surface]state collapsed频道栏是否折叠categoriesHidden[surface]state ! expanded分类栏是否收起categories-hidden或collapsed均视为收起。4.2 会话级恢复目标记忆private readonly restoreTargets: Record LiveSidebarSurface, ExcludeLiveSidebarState, collapsed ;restoreTargets记住用户从哪个层级进入collapsed退出折叠时回到该层级——这个记忆是会话级的重启后恢复的是存储的原始层级本身this.restoreTargets bySurface((surface) { const restored this.states[surface](); return restored collapsed ? expanded : restored; });4.3 对外 API方法行为stateOf(surface)返回面的只读状态 SignalisCollapsedFor(surface)频道栏是否折叠稳定 Signal可赋给组件字段areCategoriesHiddenFor(surface)分类栏是否收起稳定 Signaltoggle(surface)折叠中则展开回到进入前的层级否则全部折叠Cmd/CtrlB、头部开关与浮动恢复条共用collapse(surface)仅播放器调用记住当前层级供expand()使用expand(surface)从进入collapsed前的层级恢复hideCategories(surface)/showCategories(surface)仅折叠/展开分类栏setState(surface, state)写入并持久化非collapsed状态会同步更新恢复目标持久化在每次setState时同步进行setState(surface: LiveSidebarSurface, state: LiveSidebarState): void { if (state ! collapsed) { this.restoreTargets[surface] state; } this.states[surface].set(state); persistLiveSidebarState(state, liveSidebarStateStorageKey(surface)); }五、列表已隐藏占位组件app-channel-list-hidden-statechannel-list-hidden-state.component.ts 是本次修复最直观的交互成果。它取代了原先的select a channel空状态——因为列表本身不可见时提示用户去选择一个看不到的频道是荒谬的。Component({ selector: app-channel-list-hidden-state, imports: [PortalEmptyStateComponent, TranslatePipe], template: app-portal-empty-state iconview_sidebar [message]LAYOUT.CHANNELS_LIST_HIDDEN | translate [hint]LAYOUT.CHANNELS_LIST_HIDDEN_HINT | translate [actionLabel]LAYOUT.SHOW_CHANNELS_LIST | translate actionIconchevron_right (action)restore.emit() / , ... }) export class ChannelListHiddenStateComponent { readonly restore outputvoid(); }要点组件只输出一个restore事件具体恢复逻辑由宿主注入M3U 播放器接toggleSidebar()video-player.component.htmlXtream/Stalker 布局接livePanels.toggleSidebar()live-stream-layout.component.html、stalker-live-stream-layout.component.html统一收藏直播页签同样渲染该组件unified-live-tab.component.html文案通过TranslatePipe走 i18n在 en.json 中可找到LAYOUT.SHOW_CHANNELS_LIST等键多语言环境下文案保持一致它复用了通用app-portal-empty-state组件后者为此新增了可选的hint、actionLabel、actionIcon输入与action输出。UI 契约文档 iptvnator-ui-guidelines.md 对该组件提出了更细的视觉要求标题声明列表被隐藏、一行提示注明快捷键、全尺寸描边按钮接入同一切换逻辑按钮保持全不透明度而图标与文案保持弱化——因为按钮是走出该状态的唯一出路。六、工作区头部常驻开关两种状态都存在的控制点折叠栏的收起 chevron 与浮动恢复条都位于被隐藏的栏本身内部因此折叠时用户会失去控制入口。修复方案是在工作区头部渲染一个view_sidebar切换按钮且只在渲染自己频道栏的路由上出现。路由到面的解析函数resolveRouteLiveSidebarSurface位于 live-sidebar-state.tsexport function resolveRouteLiveSidebarSurface( provider: PortalProvider | null | undefined, section: PortalRailSection | null | undefined ): LiveSidebarSurface | null { if (!provider || !section) { return null; } switch (provider) { case playlists: return section all || section groups ? m3u : null; case xtreams: return section live ? portal : null; case stalker: return section itv || section radio ? portal : null; default: return null; } }即头部开关覆盖 M3U 的all/groups、Xtream 的live、Stalker 的itv/radio。契约文档补充了三条行为规则开关在折叠/展开两种状态下都保留在头部使用aria-pressed表达状态按下 栏可见并且仅在列表隐藏时将开关着色为主色——隐藏态才是需要提示的例外情况收藏/最近路由被刻意排除返回null只有收藏页自己知道直播页签进而频道栏是否在屏因此由页面自身的头部开关内容切换旁负责而不是全局头部在手机断点≤640px下头部开关被隐藏此时频道栏是底部抽屉自带开关头部也没有多余宽度。七、可访问性与焦点管理折叠栏并未真正卸载而是 0px 宽继续渲染因此必须退出 Tab 顺序与无障碍树。契约文档iptvnator-ui-guidelines.md明确折叠的栏携带inert属性shell 栏用isContextPanelInertXtream/Stalker 频道栏用isSidebarCollapsedshell 栏在手机抽屉形态下跳过inert每次层级变化都会移除或置inert用户刚激活的按钮焦点因此落回body获得替代控件的一侧在下次渲染后通过focusIfFocusLost()iptvnator/portal/shared/util接管焦点LivePanelsController基于有效层级安装handoffFocusOnLiveSidebarChange()在直播根上首次选择分类会无状态变化地折叠频道栏在仅播放器时聚焦浮动恢复条在分类栏折叠时聚焦显示分类按钮shell 侧边栏基于实际折叠状态在直播根上第二层级仍显示频道栏因此只有仅播放器 ↔ 可见才是转换聚焦上下文面板点名的控件。八、回归防线单元测试与 Electron E2E8.1 单元测试live-layout-sidebar-state.service.spec.ts 用七个用例锁定了状态机的关键不变量默认展开每个面初始都是expanded面间独立折叠m3u不影响portal/collection且只有live-sidebar-state:m3u被写入信号实例稳定同一面的isCollapsedFor()返回同一 Signal 实例可安全赋给组件字段按面恢复portal面预置collapsed后新建服务只有该面折叠分类栏独立折叠hideCategories只影响categories-hidden派生信号不影响isCollapsedFor三级往返折叠后expand回到进入前的层级categories-hidden旧键清理预置共享键live-sidebar-state后所有面保持展开且旧键被移除。8.2 Electron E2Elive-sidebar-collapse.e2e.ts 从真实用户场景出发覆盖了数据路径完好 折叠栏可发现、可作用域、可恢复两半通过 Playwright 启动 Electron 应用、从原生对话框导入 M3U、添加 Xtream 门户addXtreamPortal、清空播放历史后重启应用验证频道数据dbGetAppPlaylist返回的items数量毫发无损断言折叠栏处于.sidebar-collapsed且宽度 ≤1px、浮动恢复条可见、头部开关aria-pressedfalse用app-channel-list-hidden-state button.empty-state-action选择器点击全尺寸恢复按钮验证一键恢复路径跨重启后验证live-sidebar-state:m3u等按面键的恢复行为并确认旧共享键不再生效。E2E 测试还记录了一个实现细节折叠栏的 DOM 子节点仍保留在零宽容器内被 overflow 裁剪因此 Playwright 仍会把它们报告为可见断言必须针对容器本身及其恢复控件而不是子节点。九、总结与工程启示iptvnator 对 issue #1458 的修复是一个小而完整的工程范本值得提炼的点包括状态作用域建模先定义面surface再分配存储键避免单一全局键放大状态泄漏的影响半径无状态升级路径构造函数内一次性清除旧键使修复本身即为存量用户恢复现场空状态语义化app-channel-list-hidden-state让隐藏与数据丢失在视觉上可区分并给出全尺寸恢复入口控制点不消失头部常驻开关保证任何状态下都有可发现的控制三层回归防线状态机单元测试钉住不变量E2E 复现真实用户报告场景契约文档固化 UI 与可访问性要求。对于需要实现可折叠侧栏/抽屉这类功能的开发者本文涉及的 live-sidebar-state.ts、live-layout-sidebar-state.service.ts 与对应的 单元测试、E2E 测试是一套可以直接借鉴的状态隔离 恢复交互 可访问性完整实现。【免费下载链接】iptvnator:tv: Cross-platform IPTV player application with multiple features, such as support of m3u and m3u8 playlists, favorites, TV guide, TV archive/catchup and more.项目地址: https://gitcode.com/GitHub_Trending/ip/iptvnator创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考