[feat] 视频清晰度切换
AltaTV 播放器清晰度切换的数据结构、播放链路和边界处理总结
背景
播放器原来只需要拿到一个播放地址并交给底层播放器初始化。清晰度切换上线后,播放地址不再是一个简单字符串,而是变成了“同一集、多个格式、多个清晰度”的集合。
这个功能的目标不是只切换当前集,而是让用户选择的清晰度在当前剧的播放过程中持续生效:用户在第一集选择 720p 后,后续跳到第四集也应优先使用 720p;用户再切回 1080p 后,之后重新进入其他集也应优先使用 1080p。
播放链路概览
播放页的核心链路由 AltaVideoPlayer、AltaVideoPlayerController、AltaVideoItem、AltaVideoItemController、AltaVideoManager 和 GoogleVideoController 串起来。
AltaVideoPlayerController 负责请求剧集详情,并把每一集组装成 VideoData。这些 VideoData 会追加到 AltaVideoManager.videoList 中。页面主体是竖向 PageView,每一页由 AltaVideoItem 展示。
真正的播放加载入口在 AltaVideoManager:
AltaVideoItem.onPageShown()通知当前页显示。AltaVideoManager.onPageShown()调用_checkAndPrepare()。_checkAndPrepare()判断当前 item 是否在缓存范围内、是否正在解锁、是否已经初始化过。prepareItem()判断当前清晰度下是否已有可用 URL。- 如果没有 URL,则调用
_unlockVideoInfo()或自动/手动解锁相关逻辑重新拿播放信息。 - 如果已有 URL,则进入
_loadVideo()。 _loadVideo()选择当前清晰度对应的VideoUrlItem,创建或复用GoogleVideoController,并调用GoogleVideoController.initialing()。GoogleVideoController基于video_player初始化底层播放器,并把 prepared、playing、progress、end、error 等事件广播回AltaVideoManager。AltaVideoItemController监听 manager 事件,收到prepared后恢复进度、设置倍速,并在canPlay()通过后播放。
这套链路的关键点是:页面层不直接关心 URL 和播放器实例的创建,统一由 AltaVideoManager 管理。
数据结构改造
清晰度切换的基础是 VideoData 中的播放地址结构。
现在播放地址由 VideoUrl 管理,VideoUrl 内部区分:
- MP4 1080p
- MP4 720p
- M3U8 1080p
- M3U8 720p
- 旧接口兼容的单 URL
每一个具体地址是一个 VideoUrlItem,包含清晰度、播放地址、是否 M3U8、过期时间和可用状态。
清晰度枚举由 VideoResolutionType 表示,目前主要支持:
p1080p720unknown
VideoUrl.defaultOrderedUrlItems() 负责返回某个清晰度下可用的 URL 列表。如果没有指定清晰度,则默认优先级是 M3U8 1080p、MP4 1080p、M3U8 720p、MP4 720p、单 URL。
VideoUrl.setLoadUrlItem() 会按照指定清晰度选择真正要加载的 urlItem。后续播放链路只认 urlItem,因此它表示“当前这集实际准备加载或正在播放的地址”。
VideoData.needUnlock() 也从“是否没有 URL”变成了“当前清晰度下是否没有可用 URL”。这点很重要:同一集可能已经有 1080p URL,但用户切到 720p 时,如果 720p 地址还没有,就需要重新调用播放信息接口获取。
清晰度选择入口
清晰度入口在 VideoQualityDialog。
VideoQualityDialogController 初始化时,会根据当前 VideoData.videoUrl.urlItem 识别当前选中的清晰度。弹窗展示选项时,会检查 VideoUrl.defaultOrderedUrlItems(),只展示当前数据中已有可用地址的清晰度。
用户点击 720p 或 1080p 后,弹窗调用 AltaVideoManager.onVideoResolutionChanged()。
切换清晰度的核心动作
AltaVideoManager 用 specifiedResolution 保存用户当前选择的清晰度。这个字段是 manager 级别的,因此它天然作用于当前剧,而不是只作用于当前集。
onVideoResolutionChanged() 做了几件事:
- 更新
specifiedResolution。 - 清空当前集的
urlItem。 - 清理当前集的初始化时间。
- 暂停并重置当前集的 controller。
- 广播
onVideoController,让 UI 暂时解除旧 controller 引用。 - 调用
prepareItem()重新加载当前集。 - 同步重置前一集和后一集的 controller 与
urlItem。
重置前后集的目的是让当前剧后续播放也使用新清晰度。因为 PageView 会缓存相邻页,如果只重置当前集,那么下一集可能仍然持有旧清晰度的 controller。
当前剧生效而不是当前集生效
这个功能不是把清晰度写进某一个 VideoData,而是写进 AltaVideoManager.specifiedResolution。
后续任何集进入 _checkAndPrepare() 和 prepareItem() 时,都会使用这个全局选择:
- 如果该集在当前清晰度下已有 URL,则直接加载。
- 如果没有 URL,则重新调用播放信息接口获取。
- 如果 controller 已经被 reset,则会重新初始化。
因此用户在第一集切到 720p 后,第四集加载时也会优先走 720p。用户在第四集再切回 1080p 后,后续重新加载其他集时也会优先走 1080p。
没有当前清晰度 URL 时怎么处理
如果 VideoData.needUnlock(specifiedResolution) 返回 true,说明当前清晰度下没有可用 URL。这里不代表一定是未解锁,也可能只是当前清晰度的地址没有随列表一起返回。
prepareItem() 会根据业务状态处理:
- 已解锁、订阅免费、免费剧、广告免费场景:调用
_unlockVideoInfo()重新拉取播放信息。 - 自动解锁开启且当前集锁定:调用
_unlockAuto()。 - 观影券可用:调用
passUnlock()。 - 其他需要用户确认的场景:展示对应的解锁或充值 UI。
因此“没有当前清晰度 URL”时,正常会重新请求一次播放信息。请求回来后,_loadVideo() 再根据 specifiedResolution 选择真正要加载的 urlItem。
异步竞争如何处理
切换清晰度时有一个竞争问题:
用户从 1080p 切到 720p,如果本地没有 720p URL,会进入 _unlockVideoInfo() 异步请求。请求还没回来时,用户又切回 1080p。此时 1080p 可能能直接播放;等之前的 720p 请求回来后,不能再继续按 720p 初始化播放器。
当前实现中,_loadVideo() 在正式选择新 URL 前,会先检查当前 urlItem 的清晰度是否和 specifiedResolution 一致。如果不一致,会直接返回,避免旧的异步结果继续污染当前播放状态。
这也是为什么不能简单把 setLoadUrlItem() 提前到所有判断之前。提前选择 URL 会让过期的异步请求重新按最新清晰度选择地址,从而绕过这层保护。
这里的设计原则是:异步请求完成后,只有当它仍然符合当前 manager 的 specifiedResolution,才允许继续初始化播放器。
加密 M3U8 解析
M3U8 播放还有额外处理。
服务端返回的 M3U8 分片链接是加密的,原始 m3u8 中包含 key 地址,但客户端不能直接使用这个 key 地址,而是需要从 key 地址中提取 keyId,再调用后端解密接口获取真正的 key。
处理流程在 AltaVideoManager._fetchM3U8Key():
- 请求原始 m3u8 内容。
- 找到
#EXT-X-KEY行。 - 从
URI中提取keyId。 - 调用后端解密接口,并带上当前集 ID 和清晰度。
- 把后端返回结果转换为播放器可用的 key bytes。
GoogleVideoController.initialing() 收到 encryptionKey 后,不会直接把原始 m3u8 交给播放器,而是注册到 HlsKeyProxy。
HlsKeyProxy 会启动本地 HTTP 服务,为每个播放器创建一个 session,并返回代理后的播放地址。播放器请求代理 m3u8 时,代理会改写:
#EXT-X-KEY,让 key URI 指向本地代理的 key 地址。- ts 分片地址,把相对路径补成绝对地址并转成代理地址。
播放器销毁或 reset 时,GoogleVideoController.resetPlayer() 会注销当前 proxy session。最后一个 session 注销后,本地代理服务会自动关闭。
跳集后的旧 URL 残留问题
清晰度切换还有一个容易忽略的跳集场景:
- 第一集从 1080p 切到 720p。
- 通过
VideoListDialog跳到第四集,此时全局清晰度仍然是 720p。 - 第四集切回 1080p。
- 再通过
VideoListDialog跳回第一集。
这时第一集的 urlItem 可能仍然是之前加载过的 720p。如果第一集 controller 还存在,跳回来可能直接继续播放旧 controller;如果第一集 controller 已经 dispose,但 urlItem 还残留 720p,重新进入 _loadVideo() 时会发现 urlItem 和当前 specifiedResolution 不一致,从而直接返回,导致无法重新初始化。
为了解决这个问题,采用了一个很小的修复策略:在 AltaVideoManager.onDispose() 中释放某个 video item 的 controller 时,同时把这个 item 的 urlItem 置空。
这样当用户跳回已释放的第一集时,它不会再携带旧的 720p urlItem。后续重新 prepare 时,会按当前全局清晰度重新选择 URL;如果当前是 1080p,就会重新加载 1080p。
这个修复没有扩大异步链路,也没有改变 _loadVideo() 中的竞争保护,只是在 controller 生命周期结束时清理掉对应的临时播放地址。
初始化时间缓存的 key
切换清晰度时还需要清理 videoInitializedTime。这个 Map 的写入 key 是 videoItemId,因此清理时也必须使用 videoItemId。
如果误用 index 清理,会导致旧初始化时间残留。结果是某些预加载判断会认为当前集或相邻集已经初始化过,从而影响下一集预加载节奏。
因此在 onVideoResolutionChanged() 和 onDispose() 中,都按 videoItemId 清理初始化时间。
总结
这次清晰度切换的核心不是 UI 弹窗,而是播放链路中“期望清晰度”和“当前集实际 URL”的关系处理。
最终实现里:
VideoResolutionType表示用户可选清晰度。VideoUrl和VideoUrlItem承载多格式、多清晰度 URL。specifiedResolution让清晰度选择在当前剧范围内持续生效。needUnlock()按清晰度判断是否需要重新获取播放信息。onVideoResolutionChanged()重置当前集和相邻集,保证后续播放使用新清晰度。_loadVideo()保留异步竞争保护,避免过期清晰度请求继续初始化播放器。HlsKeyProxy解决加密 M3U8 的本地播放问题。onDispose()清理urlItem,避免跳集后旧清晰度地址残留。
整个方案尽量把改动限制在播放器管理层和播放地址模型中,页面层只负责展示选项和触发切换,播放状态仍然由 AltaVideoManager 统一收口。