
IPTVnator 影片手动标记已观看功能解析Xtream 与 Stalker 门户的 Watched 状态实现【免费下载链接】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/iptvnatorIPTVnator 是一个跨平台 IPTV 播放器支持 m3u/m3u8 播放列表、收藏、电视指南、回看catchup等功能。本文围绕仓库 changelog .changes/portal-mark-movie-watched.md 记录的影片手动标记已观看/未观看特性深入剖析其从共享状态模型、持久化写入到 Xtream / Stalker 两个门户集成的完整实现。读完本文你将掌握IPTVnator 中已观看判定阈值与三分态模型、手动标记背后的满进度播放记录写入原理、详情页按钮与目录徽标如何即时联动以及播放中/并发点击等边界情况如何被防御。功能总览一次 changelog 记录背后的完整链路原始 changelog 记录了这样一个特性Movies can now be marked as watched (or unwatched) by hand from their detail page on Xtream and Stalker sources, including the Favorites and Recently Viewed views — the same way seasons and episodes already could. The catalog shows the green check right away, and a watched movie offers Play instead of a Resume near the end.翻译并展开就是四条核心行为承诺手动标记在 Xtream 与 Stalker 两种门户的电影详情页用户可手动把影片标记为已观看或未观看与已有的剧集/季标记能力对齐视图全覆盖该能力同样出现在收藏Favorites与最近观看Recently Viewed视图中目录即时反馈标记后目录卡片立刻显示绿色对勾徽标主按钮语义切换已观看的影片在详情页主按钮处显示Play从头播放而非Resume从断点继续。这四项承诺在源码中均有对应实现下文逐一拆解。仓库以 Nx 工作区组织与门户portal相关的核心代码集中在 libs/portal 目录下。核心状态模型三分态与 90% 阈值一切已观看判定都建立在一个统一的状态模型上定义于 libs/portal/shared/util/src/lib/portal-watch-state.tsexport type PortalWatchState unwatched | in-progress | watched; /** Progress at or above which a movie or episode counts as watched. */ export const PORTAL_WATCHED_PROGRESS_PERCENT 90;每个目录卡片、每部电影或剧集都有一个PortalWatchStatewatched播放位置记录达到完成阈值≥90%无论这个进度是播放器自然走到还是用户手动标记写入的in-progress有开始但未完成。对剧集而言只要任意一集有记录即视为开始了——因为目录列表载荷不包含剧集总数剧集永远不会在目录层面达到watchedunwatched没有任何播放记录。阈值由watchStateFromProgressPercent统一折算测试 libs/portal/shared/util/src/lib/vod-watched-toggle.spec.ts 明确断言了边界expect(watchStateFromProgressPercent(0)).toBe(unwatched); expect(watchStateFromProgressPercent(1)).toBe(in-progress); expect(watchStateFromProgressPercent(89)).toBe(in-progress); expect(watchStateFromProgressPercent(90)).toBe(watched); expect(watchStateFromProgressPercent(100)).toBe(watched);值得一提的是无时长即永不判已观看的保守策略resolvePortalWatchState依赖getPortalPlaybackProgressPercent计算百分比若记录中durationSeconds为 0无法计算百分比测试断言其结果也是unwatched避免误判已观看。手动标记的本质写入一条满进度播放记录设计上最有意思的一点手动标记已观看并没有引入新的看过表而是写入一条 position duration 的完整播放进度记录。buildWatchedVodPosition是构造这条记录的工厂函数export function buildWatchedVodPosition(options: { playlistId: string; contentXtreamId: number; durationSeconds?: number | null; // 详情页已知的运行时如 Xtream 的 duration_secs currentPosition?: PlaybackPositionData | null; // 已存储的行其真实时长优先于元数据猜测 }): PlaybackPositionData { const duration positiveOrNull(options.currentPosition?.durationSeconds) ?? positiveOrNull(options.durationSeconds) ?? 1; return { contentXtreamId: options.contentXtreamId, contentType: vod, positionSeconds: duration, // position duration即看完 durationSeconds: duration, playlistId: options.playlistId, updatedAt: new Date().toISOString(), }; }时长的取值优先级是已存储记录的真实时长 详情页元数据duration_secs 回退到 1 秒。1 秒回退与剧集标记使用的兜底一致且因为position duration1 秒的满进度行同样满足 90% 阈值测试中resolvePortalWatchState(unknown)断言为watched。反过来取消标记就是删除这条行记录——同时也会遗忘断点位置这与剧集标记的取舍完全相同见 vod-watched-toggle.ts 顶部注释。由于记录形态与播放自然产生的进度记录完全一致目录徽标、Resume 规则等既有消费方无需任何改动即可正确响应。共享切换器createVodWatchedToggle两个门户共用一个可复用工厂createVodWatchedTogglelibs/portal/shared/util/src/lib/vod-watched-toggle.ts它暴露三个响应式信号与一个动作方法export interface VodWatchedToggle { isWatched: Signalboolean; // 当前影片是否已观看 busy: Signalboolean; // 写入进行中 enabled: Signalboolean; // 是否可点击 toggle(target: VodWatchedToggleTarget): Promiseboolean; // 翻转状态 }toggle的核心流程关键逻辑如下先判定方向const markWatched !isWatched()标记构造满进度行并调用savePlaybackPositionOrThrow(playlistId, next)取消调用clearPlaybackPositionOrThrow(playlistId, contentXtreamId, vod)严格持久化边界*OrThrow意味着只有写入被确认后applyPosition才会替换屏幕上的行——绝不先渲染后落库写入成功后再刷新onPersisted?.(playlistId)用于刷新目录徽标其失败只记日志不阻断写入已确认不能因此让切换器报错或产生未处理异常失败回滚catch 分支保持已渲染状态不变仅通过notify(failed)提示串行化busy信号保证并发点击被拒绝测试断言了重叠切换只写一次。enabled由三个条件共同决定这也是几个关键防御点const enabled computed( () !busy() !(config.playingNow?.() ?? false) (config.positionReady?.() ?? true) );busy()写入进行中不可再点playingNow若内联播放器已挂载、外部会话进行中或正在启动下一次播放进度 tick 可能覆盖刚写入的行因此此时按钮保持禁用而不是静默翻转回去positionReady直到宿主读取到真实存储行之前按钮可能给出错误方向把已观看的重新标记、或清除从未见过的行因此读未落地时禁用。另外VodWatchedToggleTarget.stillCurrent回调解决了详情页宿主复用问题详情页组件会被跨导航复用、不同播放列表的 id 会冲突所以迟到的写入完成既不能把下一条电影的行打补丁也不能在它上面展示反馈——写入本身携带自己的 id 是安全的但页面渲染必须通过stillCurrent()校验。反馈文案统一映射在VOD_WATCHED_FEEDBACK_KEYSexport const VOD_WATCHED_FEEDBACK_KEYS: RecordVodWatchedToggleFeedback, string { marked: XTREAM.MOVIE_MARKED_WATCHED, unmarked: XTREAM.MOVIE_MARKED_UNWATCHED, failed: XTREAM.MOVIE_WATCH_UPDATE_FAILED, };Xtream 门户集成详情路由的 Watched 服务与按钮在 Xtream 侧封装层是VodDetailsWatchedServicelibs/portal/xtream/feature/src/lib/vod-details/vod-details-watched.service.ts它把共享工厂与路由副本的行、播放所有权和反馈接线private readonly toggle createVodWatchedToggle({ playbackPositions: this.playbackPositions, // 注入 PORTAL_PLAYBACK_POSITIONS position: this.playback.routePlaybackPosition, applyPosition: (position) { this.playback.discardPendingPositionLoads(); // 丢弃在途读取防止回写旧行 this.playback.routePlaybackPosition.set(position); this.playback.vodPlaybackPosition.set(position); }, playingNow: computed(() this.playback.inlinePlayback() ! null || this.playback.matchedExternalPlayback() ! null || this.playback.isExternalLaunchPending() || this.playback.playbackStartPending() ), positionReady: this.playback.positionLoaded, notify: (feedback) this.snackBar.open(this.translate.instant(VOD_WATCHED_FEEDBACK_KEYS[feedback]), undefined, { duration: 5000 }), onPersisted: (playlistId) this.xtreamStore.currentPlaylist()?.id playlistId ? this.xtreamStore.loadAllPositions(playlistId) : undefined, logger: this.logger, });几个值得注意的细节作用于路由副本播放位置按 (playlist, stream) 键控onPersisted里loadAllPositions会重新载入整个位置映射以刷新目录徽标但前提是当前播放列表仍是写入的那个——位置映射是全局最新加载胜出的迟到的写入绝不能把用户已切换走的旧播放列表重新加载覆盖它多源引脚不受影响被 pin 到另一个播放列表的替代副本拥有自己的状态就像播放自然留下的一样时长来源toggleWatched从getXtreamVodInfo(vodItem)取出duration_secs作为元数据时长写入目标 id 取自路由当前显示的电影 id。详情页模板libs/portal/xtream/feature/src/lib/vod-details/vod-details-route.component.html中这个按钮与收藏按钮并列图标随状态切换check_circle/check_circle_outline文案随状态切换XTREAM.MARK_WATCHED/XTREAM.MARK_UNWATCHED并提供data-testidvod-watched-toggle与aria-pressed供 E2E 与无障碍使用let watchedLabel (isWatched() ? XTREAM.MARK_UNWATCHED : XTREAM.MARK_WATCHED) | translate; button typebutton classicon-action-btn icon-action-btn--watched [class.is-active]isWatched() [disabled]!canToggleWatched() (click)toggleWatched(playableItem) [matTooltip]watchedLabel [attr.aria-label]watchedLabel [attr.aria-pressed]isWatched() >if ( item.type ! stalker || !owner || item.playlistId ! owner.playlistId || Number(item.data.id) ! owner.vodId ) { return Promise.resolve(false); }这个校验解决的是宿主切换竞态宿主输入会立刻更新 owner但旧条目要等到其详情准备完成后才消失这个窗口内的点击绝不能把新播放列表与旧 id 配成一对。其stillCurrent回调同样以当前 owner 与写入时 owner 一致为准防止迟到完成影响下一条电影。目录绿色勾选即时徽标刷新链路目录立刻显示绿色对勾由两部分构成。徽标渲染目录网格组件 libs/portal/shared/ui/src/lib/components/grid-list/grid-list.component.ts 接收每个条目的watchState模板中if (i.watchState watched) { app-watched-badge [isWatched]true iconcheck_circle / } else if (i.watchState in-progress !i.progress) { !-- 有开始但无百分比可画如剧集眼睛图标表示碰过 -- app-watched-badge [isWatched]true iconremove_red_eye / }即watched显示check_circle绿色对勾剧集类条目由于无百分比、无法画进度胶囊用remove_red_eye眼睛图标表达已开始。而电影类条目若在in-progress且有progress数值则由ProgressCapsuleComponent绘制进度胶囊。即时刷新标记写入确认后onPersisted触发loadAllPositions(playlistId)Xtream或对应 store 的位置重载目录卡片从位置映射重新推导watchState于是绿色对勾立刻出现。共享工厂的applyPosition同时把路由副本的行原地替换详情页按钮图标也随即切换。主按钮语义Play 还是 Resume已观看的影片提供 Play 而非 Resume由主按钮位置选择逻辑保证实现在 libs/portal/xtream/feature/src/lib/vod-details/vod-primary-action-position.tsexport function isResumablePosition(position: PlaybackPositionData | null): boolean { const progress getPortalPlaybackProgressPercent(position); return progress 0 progress 90; // 与 90% 已观看阈值对齐 }可续播区间的上限 90 与已观看阈值PORTAL_WATCHED_PROGRESS_PERCENT严格一致注释明确指出标签与起点不能互相矛盾——一部看到 95% 的电影应显示 Play那么从它开始播放就必须真的从头开始而不是 seek 回它留下的 95% 位置。该文件同时处理了多源引脚场景pin 到另一个播放列表的副本有自己独立的行主按钮必须读取被 pin 副本的行未读到前宁可显示空、不显示错误的时间码已观看副本显示 Play、无行副本也显示 Play而路由副本的Resume 42:18绝不能贴在一个根本不会播放的流上。模板侧vod-details-route.component.html按hasPlaybackPosition()分叉可续播时显示XTREAM.RESUME 格式化时间并附带replay重启按钮否则显示XTREAM.PLAY。由于手动标记写入的是满进度行其进度 ≥90%isResumablePosition返回 false按钮自然呈现 Play 语义。收藏与最近观看视图收藏Favorites与最近观看Recently Viewed视图共享同一套目录网格与详情路由因此手动标记能力自动覆盖这两个视图。最近观看侧有一个值得一提的联动libs/portal/xtream/data-access/src/lib/with-recent-items.ts 中addRecentItem在写入后立即重载 recent items// Reload after add/update so re-watched items // immediately move to the top in recently-viewed. const items await dataSource.getRecentItems(playlistId); patchState(store, { recentItems: items.map((item) mapDbRecentItem(item, playlistId)) });也就是说重新观看或手动标记触发的状态变更会让条目立刻置顶于最近观看列表。影片详情页上的 Watched 切换按钮通过data-testidvod-watched-toggle在 web-e2e 等测试套件中可被稳定定位与断言。边界情况与测试验证共享切换器的行为由 vod-watched-toggle.spec.ts 完整覆盖是理解其防御语义的最佳入口场景预期行为标记未观看影片写入positionSeconds durationSeconds 5400的满进度行反馈marked取消已观看影片调用clearPlaybackPositionOrThrow(playlistId, 42, vod)行变 null反馈unmarked写入被拒绝如磁盘满保持已渲染行不变反馈failed记录日志busy复位写入后刷新失败只记日志切换本身仍成功写入已确认播放进行中enabled为 falsetoggle 直接返回 false不写库存储行读取未落地enabled为 false落地后恢复可点并发/重叠点击串行化savePlaybackPositionOrThrow只被调用一次切换后电影已换页行照常写入并刷新徽标但不再补丁当前页面、不展示反馈此外90% 阈值、无时长不判已观看、满进度行构建优先级存储时长 元数据时长 1s 兜底等都有对应测试断言同文件与 portal-watch-state 测试 中的describe(portal watch state)块。i18n 文案按钮与反馈文案集中在 apps/web/src/assets/i18n/en.json 的 XTREAM 段MARK_WATCHED: Mark as Watched, MARK_UNWATCHED: Mark as Unwatched, MOVIE_MARKED_WATCHED: Marked as watched, MOVIE_MARKED_UNWATCHED: Marked as unwatched, MOVIE_WATCH_UPDATE_FAILED: Updating the watched state failed仓库为每种语言维护一份对应的 i18n 文件如ar.json、de.json、fr.json、zh.json等位于同一目录并有 tools/i18n 下的脚本check-drift.mjs、fill-missing.mjs用于检查各语言间文案漂移与补全缺失项。小结IPTVnator 的影片手动标记已观看看似是一个小功能实则是共享状态模型、严格持久化边界与多门户复用的典范以portal-watch-state.ts的 90% 三分态为判定基石以buildWatchedVodPosition把手动标记归约为满进度播放记录这一既有形态再由createVodWatchedToggle提供带并发、竞态、播放中防护的原子切换Xtream 与 Stalker 各自用薄封装接入最终由目录网格徽标与 Play/Resume 主按钮语义统一呈现。理解这条链路即可触类旁通地看懂剧集/季标记、播放进度恢复乃至收藏与最近观看等相邻功能的实现哲学。【免费下载链接】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),仅供参考