[feat] 视频清晰度切换

AltaTV 播放器清晰度切换的数据结构、播放链路和边界处理总结

company melot altatv

背景

播放器原来只需要拿到一个播放地址并交给底层播放器初始化。清晰度切换上线后,播放地址不再是一个简单字符串,而是变成了“同一集、多个格式、多个清晰度”的集合。

这个功能的目标不是只切换当前集,而是让用户选择的清晰度在当前剧的播放过程中持续生效:用户在第一集选择 720p 后,后续跳到第四集也应优先使用 720p;用户再切回 1080p 后,之后重新进入其他集也应优先使用 1080p。

播放链路概览

播放页的核心链路由 AltaVideoPlayerAltaVideoPlayerControllerAltaVideoItemAltaVideoItemControllerAltaVideoManagerGoogleVideoController 串起来。

AltaVideoPlayerController 负责请求剧集详情,并把每一集组装成 VideoData。这些 VideoData 会追加到 AltaVideoManager.videoList 中。页面主体是竖向 PageView,每一页由 AltaVideoItem 展示。

真正的播放加载入口在 AltaVideoManager

  1. AltaVideoItem.onPageShown() 通知当前页显示。
  2. AltaVideoManager.onPageShown() 调用 _checkAndPrepare()
  3. _checkAndPrepare() 判断当前 item 是否在缓存范围内、是否正在解锁、是否已经初始化过。
  4. prepareItem() 判断当前清晰度下是否已有可用 URL。
  5. 如果没有 URL,则调用 _unlockVideoInfo() 或自动/手动解锁相关逻辑重新拿播放信息。
  6. 如果已有 URL,则进入 _loadVideo()
  7. _loadVideo() 选择当前清晰度对应的 VideoUrlItem,创建或复用 GoogleVideoController,并调用 GoogleVideoController.initialing()
  8. GoogleVideoController 基于 video_player 初始化底层播放器,并把 prepared、playing、progress、end、error 等事件广播回 AltaVideoManager
  9. AltaVideoItemController 监听 manager 事件,收到 prepared 后恢复进度、设置倍速,并在 canPlay() 通过后播放。

这套链路的关键点是:页面层不直接关心 URL 和播放器实例的创建,统一由 AltaVideoManager 管理。

数据结构改造

清晰度切换的基础是 VideoData 中的播放地址结构。

现在播放地址由 VideoUrl 管理,VideoUrl 内部区分:

  • MP4 1080p
  • MP4 720p
  • M3U8 1080p
  • M3U8 720p
  • 旧接口兼容的单 URL

每一个具体地址是一个 VideoUrlItem,包含清晰度、播放地址、是否 M3U8、过期时间和可用状态。

清晰度枚举由 VideoResolutionType 表示,目前主要支持:

  • p1080
  • p720
  • unknown

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()

切换清晰度的核心动作

AltaVideoManagerspecifiedResolution 保存用户当前选择的清晰度。这个字段是 manager 级别的,因此它天然作用于当前剧,而不是只作用于当前集。

onVideoResolutionChanged() 做了几件事:

  1. 更新 specifiedResolution
  2. 清空当前集的 urlItem
  3. 清理当前集的初始化时间。
  4. 暂停并重置当前集的 controller。
  5. 广播 onVideoController,让 UI 暂时解除旧 controller 引用。
  6. 调用 prepareItem() 重新加载当前集。
  7. 同步重置前一集和后一集的 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()

  1. 请求原始 m3u8 内容。
  2. 找到 #EXT-X-KEY 行。
  3. URI 中提取 keyId
  4. 调用后端解密接口,并带上当前集 ID 和清晰度。
  5. 把后端返回结果转换为播放器可用的 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 残留问题

清晰度切换还有一个容易忽略的跳集场景:

  1. 第一集从 1080p 切到 720p。
  2. 通过 VideoListDialog 跳到第四集,此时全局清晰度仍然是 720p。
  3. 第四集切回 1080p。
  4. 再通过 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 表示用户可选清晰度。
  • VideoUrlVideoUrlItem 承载多格式、多清晰度 URL。
  • specifiedResolution 让清晰度选择在当前剧范围内持续生效。
  • needUnlock() 按清晰度判断是否需要重新获取播放信息。
  • onVideoResolutionChanged() 重置当前集和相邻集,保证后续播放使用新清晰度。
  • _loadVideo() 保留异步竞争保护,避免过期清晰度请求继续初始化播放器。
  • HlsKeyProxy 解决加密 M3U8 的本地播放问题。
  • onDispose() 清理 urlItem,避免跳集后旧清晰度地址残留。

整个方案尽量把改动限制在播放器管理层和播放地址模型中,页面层只负责展示选项和触发切换,播放状态仍然由 AltaVideoManager 统一收口。