欢迎光临
我们一直在努力

用 Claude Code 开发安卓视频播放器:从 ExoPlayer、WebDAV 到上架 Google Play 的完整流程与原理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */
#content_views .toc,
/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */
#content_views.markdown_views > p:empty:has(+ .toc),
#content_views.markdown_views > .toc + p:empty,
/* 富文本旧版目录标记 */
#content_views.htmledit_views #main-toc,
#content_views.htmledit_views #hr-toc,
#content_views.htmledit_views p[id*=\”-toc\”] {
display: none !important;
}
/* 目录去掉后,紧跟的首个标题不再多出一块上边距 */
#content_views.markdown_views > .toc + h1,
#content_views.markdown_views > .toc + h2,
#content_views.markdown_views > .toc + h3,
#content_views.markdown_views > .toc + h4,
#content_views.markdown_views > .toc + p:empty + h1,
#content_views.markdown_views > .toc + p:empty + h2,
#content_views.markdown_views > .toc + p:empty + h3,
#content_views.markdown_views > .toc + p:empty + h4 {
margin-top: 0 !important;
}

说明:这个项目是和 Claude Code 协作开发的,本文也由 Claude 协助整理成文。文中代码摘自项目源码(注释改成了中文),时间和数字来自 git 记录与源码。

前言

最近给自己的视频播放器 UnitPlayer 做了安卓版,并提交到了 Google Play。整个过程是和 Claude Code 协作完成的:我负责提需求、在真机上测试、做决定;Claude 负责写代码、编译安装、出商店素材,还在浏览器里帮我填 Play Console 的各种表单。

这篇文章按开发顺序把流程走一遍,重点放在每个功能背后的原理:ExoPlayer 的播放管线、手势的计算方式、Android 存储权限的演变、WebDAV 协议、局域网服务发现(mDNS/DNS-SD 和子网扫描)、Keystore 加密、画中画,最后是签名和上架 Google Play 的流程与踩坑。

先看最终做成了什么:

模块功能
媒体库 本机视频(MediaStore)、添加任意文件夹(SAF)、继续观看、搜索与排序、按文件夹分组
播放器 横滑调进度、左侧竖滑调亮度、右侧竖滑调音量、双击快退快进(可累加)、长按 2 倍速、锁屏、倍速、睡眠定时、音轨和字幕轨切换
网络 WebDAV 浏览和串流、局域网自动发现、登录信息加密保存、直接播放链接(含 HLS)
系统集成 画中画、“用 UnitPlayer 打开”、音频焦点、耳机和蓝牙按键、应用内切换语言(7 种)

技术栈:

项选型
语言 / UI Kotlin + Jetpack Compose,单 Activity
播放内核 AndroidX Media3 1.11.1(ExoPlayer、HLS、OkHttp 数据源、MediaSession)
网络 OkHttp 5.5
构建 AGP 9.4.1 + Gradle 9.6;compileSdk 37、targetSdk 36、minSdk 29(Android 10)
发布 R8 代码压缩 + 资源压缩,AAB 上传 Google Play

Kotlin 代码一共 26 个文件,约 5000 行。


一、开发流程:一个晚上写功能,一个白天做上架

下面是 git 记录里的提交时间线(北京时间):

时间提交内容
10-06 23:28 第一次提交:媒体库 + 手势播放器
10-07 00:53 WebDAV 网络共享、设置页和语言切换、7 种语言、安卓专属功能
00:54 各模块忽略编译产物
01:28 外挂字幕、长按 2 倍速、填充屏幕、睡眠定时、媒体按键、播放设置
02:09 视频信息、按文件夹分组、播放链接(HLS)、主题、设备名、新图标
02:13 网络视频进"继续观看"、解码器回退、按应用语言优先选音轨
02:21 画中画窗口比例限制、链接标题去掉 .m3u8
02:48 "继续观看"打开最近看的那个视频

10 月 7 日白天做的是上架:7 种语言的商店文案、42 张截图、7 张宣传图,然后在 Play Console 里填完各项声明,提交封闭测试审核。

1.1 和 Claude Code 怎么分工

  • 写代码:我描述要什么功能、交互参照什么,Claude 写 Kotlin、用 Gradle 编译,再通过 adb 装到手机上。
  • 测试:真机是一台 Pixel XL(Android 10,正好是 minSdk 的下限),模拟器跑 Android 16。示例视频由脚本生成(程序画的风景动画,没有版权问题)。WebDAV 用 Python 的 wsgidav 在电脑上起一个测试服务器,模拟器通过 10.0.2.2 访问宿主机。
  • 素材:截图在模拟器里按 7 种语言逐个截,宣传图用脚本绘制。
  • 上架:Claude 在浏览器里操作 Play Console 填表,“提交审核”"发布"这类撤不回的操作都是我确认后才点。

1.2 几点体会

  • 把进度写进项目文档。项目里放一份交接文档,记录做到哪一步、下一步做什么、遇到过哪些报错。换一个新会话接手时先读它,不用从头解释。
  • 撤不回的操作由人来点。代码写错了可以回滚,提交审核、对外发布不行。
  • 最低版本要专门测。minSdk 是 29(Android 10),画中画的自动进入、NSD 服务解析的并发、媒体权限这些在不同版本上行为都不一样,手上那台 Android 10 的旧手机正好用来兜底。
  • 第一件事是检查 .gitignore。第一次提交时把 app/build 编译产物也提交了进去,.git 一下子涨到 89 MB。后来补了 .gitignore,再用 git filter-branch 把历史里的编译产物清掉,.git 回到 244 KB。新建 Android 项目时,先确认每个模块的 build/ 都被忽略了。

  • 二、整体架构

    #mermaid-svg-Oqxwaa47JYhKAptT{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Oqxwaa47JYhKAptT .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Oqxwaa47JYhKAptT .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Oqxwaa47JYhKAptT .error-icon{fill:#552222;}#mermaid-svg-Oqxwaa47JYhKAptT .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Oqxwaa47JYhKAptT .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Oqxwaa47JYhKAptT .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Oqxwaa47JYhKAptT .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Oqxwaa47JYhKAptT .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Oqxwaa47JYhKAptT .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Oqxwaa47JYhKAptT .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Oqxwaa47JYhKAptT .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Oqxwaa47JYhKAptT .marker.cross{stroke:#333333;}#mermaid-svg-Oqxwaa47JYhKAptT svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Oqxwaa47JYhKAptT p{margin:0;}#mermaid-svg-Oqxwaa47JYhKAptT .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-Oqxwaa47JYhKAptT .cluster-label text{fill:#333;}#mermaid-svg-Oqxwaa47JYhKAptT .cluster-label span{color:#333;}#mermaid-svg-Oqxwaa47JYhKAptT .cluster-label span p{background-color:transparent;}#mermaid-svg-Oqxwaa47JYhKAptT .label text,#mermaid-svg-Oqxwaa47JYhKAptT span{fill:#333;color:#333;}#mermaid-svg-Oqxwaa47JYhKAptT .node rect,#mermaid-svg-Oqxwaa47JYhKAptT .node circle,#mermaid-svg-Oqxwaa47JYhKAptT .node ellipse,#mermaid-svg-Oqxwaa47JYhKAptT .node polygon,#mermaid-svg-Oqxwaa47JYhKAptT .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Oqxwaa47JYhKAptT .rough-node .label text,#mermaid-svg-Oqxwaa47JYhKAptT .node .label text,#mermaid-svg-Oqxwaa47JYhKAptT .image-shape .label,#mermaid-svg-Oqxwaa47JYhKAptT .icon-shape .label{text-anchor:middle;}#mermaid-svg-Oqxwaa47JYhKAptT .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Oqxwaa47JYhKAptT .rough-node .label,#mermaid-svg-Oqxwaa47JYhKAptT .node .label,#mermaid-svg-Oqxwaa47JYhKAptT .image-shape .label,#mermaid-svg-Oqxwaa47JYhKAptT .icon-shape .label{text-align:center;}#mermaid-svg-Oqxwaa47JYhKAptT .node.clickable{cursor:pointer;}#mermaid-svg-Oqxwaa47JYhKAptT .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Oqxwaa47JYhKAptT .arrowheadPath{fill:#333333;}#mermaid-svg-Oqxwaa47JYhKAptT .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Oqxwaa47JYhKAptT .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Oqxwaa47JYhKAptT .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Oqxwaa47JYhKAptT .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Oqxwaa47JYhKAptT .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Oqxwaa47JYhKAptT .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Oqxwaa47JYhKAptT .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Oqxwaa47JYhKAptT .cluster text{fill:#333;}#mermaid-svg-Oqxwaa47JYhKAptT .cluster span{color:#333;}#mermaid-svg-Oqxwaa47JYhKAptT div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Oqxwaa47JYhKAptT .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Oqxwaa47JYhKAptT rect.text{fill:none;stroke-width:0;}#mermaid-svg-Oqxwaa47JYhKAptT .icon-shape,#mermaid-svg-Oqxwaa47JYhKAptT .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Oqxwaa47JYhKAptT .icon-shape p,#mermaid-svg-Oqxwaa47JYhKAptT .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Oqxwaa47JYhKAptT .icon-shape .label rect,#mermaid-svg-Oqxwaa47JYhKAptT .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Oqxwaa47JYhKAptT .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Oqxwaa47JYhKAptT .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Oqxwaa47JYhKAptT :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    Compose 界面

    媒体库 LibraryScreen

    网络 NetworkScreen

    设置 SettingsScreen

    播放器 PlayerScreen

    PlayerViewModel持有 ExoPlayer

    MediaStore / SAF

    WebDavClientPROPFIND

    Bonjour 发现 + 子网扫描

    LoginStoreKeystore AES-GCM

    共享 OkHttpClient

    几个设计点:

    • 单 Activity + Compose。MainActivity 在清单里声明了 configChanges="orientation|screenSize|…",横竖屏切换时不重建 Activity,视频不会因为旋转而中断。
    • ExoPlayer 放在 ViewModel 里。ViewModel 的生命周期比界面长,界面重组、旋转、进出画中画都不会把播放器重建掉。
    • 一个 OkHttpClient 用到底。浏览 WebDAV 目录和播放网络视频共用同一个客户端,连接池可以复用,对同一台 NAS 不用反复握手。
    • 持久化全部用 SharedPreferences。播放进度、文件夹、服务器列表、加密后的登录信息,数据量都很小,没必要上数据库。

    播放器的创建代码:

    /** 播放器归 ViewModel 所有,旋转和离开界面都不会中断播放 */
    class PlayerViewModel(application: Application) : AndroidViewModel(application) {
    // 手机的硬件解码器解不了某个文件时,换下一个解码器试,而不是直接报错
    val player: ExoPlayer = ExoPlayer.Builder(application, DefaultRenderersFactory(application).setEnableDecoderFallback(true))
    // 其他应用出声(包括来电)时暂停;拔掉耳机也暂停
    .setAudioAttributes(
    AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(),
    true,
    )
    .setHandleAudioBecomingNoisy(true)
    .build()
    // …
    }

    下面按模块逐个讲原理。


    三、播放内核:ExoPlayer 怎么把一个文件变成画面

    3.1 播放管线

    #mermaid-svg-w44cUNpqNGvAFJaU{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-w44cUNpqNGvAFJaU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-w44cUNpqNGvAFJaU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-w44cUNpqNGvAFJaU .error-icon{fill:#552222;}#mermaid-svg-w44cUNpqNGvAFJaU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-w44cUNpqNGvAFJaU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-w44cUNpqNGvAFJaU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-w44cUNpqNGvAFJaU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-w44cUNpqNGvAFJaU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-w44cUNpqNGvAFJaU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-w44cUNpqNGvAFJaU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-w44cUNpqNGvAFJaU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-w44cUNpqNGvAFJaU .marker.cross{stroke:#333333;}#mermaid-svg-w44cUNpqNGvAFJaU svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-w44cUNpqNGvAFJaU p{margin:0;}#mermaid-svg-w44cUNpqNGvAFJaU .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-w44cUNpqNGvAFJaU .cluster-label text{fill:#333;}#mermaid-svg-w44cUNpqNGvAFJaU .cluster-label span{color:#333;}#mermaid-svg-w44cUNpqNGvAFJaU .cluster-label span p{background-color:transparent;}#mermaid-svg-w44cUNpqNGvAFJaU .label text,#mermaid-svg-w44cUNpqNGvAFJaU span{fill:#333;color:#333;}#mermaid-svg-w44cUNpqNGvAFJaU .node rect,#mermaid-svg-w44cUNpqNGvAFJaU .node circle,#mermaid-svg-w44cUNpqNGvAFJaU .node ellipse,#mermaid-svg-w44cUNpqNGvAFJaU .node polygon,#mermaid-svg-w44cUNpqNGvAFJaU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-w44cUNpqNGvAFJaU .rough-node .label text,#mermaid-svg-w44cUNpqNGvAFJaU .node .label text,#mermaid-svg-w44cUNpqNGvAFJaU .image-shape .label,#mermaid-svg-w44cUNpqNGvAFJaU .icon-shape .label{text-anchor:middle;}#mermaid-svg-w44cUNpqNGvAFJaU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-w44cUNpqNGvAFJaU .rough-node .label,#mermaid-svg-w44cUNpqNGvAFJaU .node .label,#mermaid-svg-w44cUNpqNGvAFJaU .image-shape .label,#mermaid-svg-w44cUNpqNGvAFJaU .icon-shape .label{text-align:center;}#mermaid-svg-w44cUNpqNGvAFJaU .node.clickable{cursor:pointer;}#mermaid-svg-w44cUNpqNGvAFJaU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-w44cUNpqNGvAFJaU .arrowheadPath{fill:#333333;}#mermaid-svg-w44cUNpqNGvAFJaU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-w44cUNpqNGvAFJaU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-w44cUNpqNGvAFJaU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-w44cUNpqNGvAFJaU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-w44cUNpqNGvAFJaU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-w44cUNpqNGvAFJaU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-w44cUNpqNGvAFJaU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-w44cUNpqNGvAFJaU .cluster text{fill:#333;}#mermaid-svg-w44cUNpqNGvAFJaU .cluster span{color:#333;}#mermaid-svg-w44cUNpqNGvAFJaU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-w44cUNpqNGvAFJaU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-w44cUNpqNGvAFJaU rect.text{fill:none;stroke-width:0;}#mermaid-svg-w44cUNpqNGvAFJaU .icon-shape,#mermaid-svg-w44cUNpqNGvAFJaU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-w44cUNpqNGvAFJaU .icon-shape p,#mermaid-svg-w44cUNpqNGvAFJaU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-w44cUNpqNGvAFJaU .icon-shape .label rect,#mermaid-svg-w44cUNpqNGvAFJaU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-w44cUNpqNGvAFJaU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-w44cUNpqNGvAFJaU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-w44cUNpqNGvAFJaU :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    DataSource读字节:文件 / content / HTTP

    Extractor解封装:MP4 / MKV / TS

    SampleQueue音频、视频、字幕分轨缓存

    视频 Renderer

    MediaCodec硬解 / 软解

    Surface

    音频 Renderer

    MediaCodec

    AudioTrack

    字幕 Renderer

    SubtitleView

  • DataSource 负责读字节。本地文件是 content:// 或 file://,网络视频是 HTTP。DefaultDataSource 按 URI 的 scheme 分发;网络视频这里换成了 OkHttpDataSource,并带上认证头(第六节细讲)。
  • Extractor 负责解封装。它识别容器格式(MP4、MKV/WebM、MPEG-TS 等),把交织在一起的音频、视频、字幕样本拆开,分别放进各自的 SampleQueue。同时它会建立一张 SeekMap,也就是"时间 → 字节偏移"的映射:MP4 来自 moov 里的样本表,MKV 来自 Cues 索引。拖动进度时就靠这张表算出该从文件的哪个字节开始读。
  • Renderer 负责消费样本。视频交给 MediaCodecVideoRenderer,音频交给 MediaCodecAudioRenderer,字幕交给文本渲染器。
  • MediaCodec 负责解码。它是 Android 的编解码接口,背后是芯片厂商的硬件解码器,或者系统自带的软件解码器。视频解码后直接渲染到 Surface,音频解码成 PCM 后写进 AudioTrack。
  • 音视频同步以音频为准。有音轨时,音频渲染器充当播放时钟,视频渲染器拿每一帧的时间戳和音频当前位置比较,决定马上显示、等一等,还是丢掉这一帧。
  • DefaultMediaSourceFactory 会根据 URI 推断媒体类型:以 .m3u8 结尾的走 HlsMediaSource(需要额外引入 media3-exoplayer-hls 模块),普通文件走 ProgressiveMediaSource。HLS 的原理是先下载 m3u8 播放列表,列表里是一段段几秒长的 TS 或 fMP4 分片;如果有多个码率的版本,播放器会根据测到的带宽自动切换。

    3.2 解码器回退,以及为什么没接 FFmpeg

    有读者问过底层是 ExoPlayer 还是 FFmpeg。答案是 ExoPlayer,没有接 FFmpeg,解码全部用系统的 MediaCodec。

    对于同一种格式,系统通常有多个解码器可选,比如 H.264 可能有一个芯片厂商的硬件解码器,再加一个系统自带的软件解码器(名字一般是 c2.android.*)。ExoPlayer 默认只试排在第一位的那个,硬件解码器初始化失败(比如分辨率或 profile 超出了它的能力)就直接报错。打开 setEnableDecoderFallback(true) 之后,第一个初始化失败会自动换下一个,很多"这个视频放不了"就这样救回来了。

    为什么不接 FFmpeg?因为系统解码器已经覆盖了绝大多数文件。按照 Android 官方的媒体格式支持列表,系统本身就带有 H.264、H.265、VP8、VP9 等视频解码器,以及 AAC、MP3、Opus、Vorbis、FLAC 等音频解码器(AOSP 里都有对应的软件实现),Android 10 起还自带 AV1 软件解码器。真正的短板在 AC-3/E-AC-3、DTS、TrueHD 这几种音频格式,它们不在系统必带的范围里,有没有取决于手机厂商,没有的话就是"有画面没声音"。

    Media3 官方有 FFmpeg 扩展,但它只能解音频、不能解视频,而且不提供现成的包,要自己用 NDK 编译,每个 CPU 架构都会让安装包变大。第一版先不做,商店说明里写明"支持的格式取决于手机的解码能力",播放器的"视频信息"里也能看到当前用的编码格式。

    3.3 音频焦点和"拔耳机暂停"

    setAudioAttributes(…, true) 的第二个参数表示让 ExoPlayer 自己管理音频焦点:开始播放时申请焦点,来电或其他应用开始播放音乐时失去焦点,播放器自动暂停;短暂失去焦点(比如导航播报)时,按情况降低音量或暂停,结束后恢复。

    setHandleAudioBecomingNoisy(true) 监听的是系统的 ACTION_AUDIO_BECOMING_NOISY 广播:拔掉有线耳机或者蓝牙耳机断开时,声音会突然切到外放,播放器在这一刻自动暂停。

    3.4 播放列表、续播和音轨语言

    从文件夹里点开一个视频时,会把整个文件夹的视频一起交给播放器:

    player.setMediaSources(queue.map(::mediaSource), queue.indexOfFirst { it.id == item.id }, history.resumePosition(item.id))

    这样看完一集会自动播放下一集,"上一个/下一个"按钮也是现成的。切换到下一个视频时,在 onMediaItemTransition 里恢复它上次的进度。

    续播的规则在 PlaybackHistory 里,两个阈值:

    /** 从哪里续播,没有就是 0。看了不到 5 秒的不值得续播 */
    fun resumePosition(id: String): Long {
    val position = prefs.getLong("p:$id", 0)
    return if (position >= MIN_RESUME_MS) position else 0
    }

    fun save(id: String, positionMs: Long, durationMs: Long) {
    // 看完了(或者只剩最后 10 秒):下次从头开始
    if (durationMs > 0 && positionMs >= durationMs – FINISHED_MARGIN_MS) {
    clear(id)
    return
    }
    // …
    }

    private companion object {
    const val MIN_RESUME_MS = 5_000L
    const val FINISHED_MARGIN_MS = 10_000L
    }

    多音轨的 MKV 很常见(比如同时有国语和英语),打开视频时把应用当前语言设为首选音频语言:

    player.trackSelectionParameters = player.trackSelectionParameters.buildUpon()
    .clearOverrides()
    .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
    .setPreferredAudioLanguage(appLanguage())
    .build()

    睡眠定时分两种:按分钟的用协程 delay 到点暂停;"播完这一集"直接用 ExoPlayer 的 pauseAtEndOfMediaItems = true。


    四、手势系统

    播放器上盖了一层透明的 Box,用 Compose 的 pointerInput 同时挂两个检测器:detectTapGestures 负责单击、双击、长按,detectDragGestures 负责滑动。

    4.1 方向锁定

    一次滑动到底是调进度、调亮度还是调音量,只在第一次移动时判断一次,之后整个手势都锁定在这个模式里,避免手指稍微歪一点就在几种模式之间来回跳:

    onDrag = { change, amount ->
    change.consume()
    total += amount
    if (mode == null) {
    mode = when {
    abs(total.x) > abs(total.y) -> DragMode.SEEK // 横向为主:调进度
    start.x < size.width / 2 -> DragMode.BRIGHTNESS // 左半屏竖滑:亮度
    else -> DragMode.VOLUME // 右半屏竖滑:音量
    }
    startLevel = when (mode) {
    DragMode.BRIGHTNESS -> brightness()
    DragMode.VOLUME -> volume()
    else -> 0f
    }
    }
    // …
    }

    4.2 进度:滑动距离怎么换算成时间

    /** 横滑:划满一屏宽度移动视频时长的 15%,长视频最多翻倍 */
    internal fun seekDelta(dragFraction: Float, durationMs: Long): Long {
    val factor = 1.0 + min(1.0, durationMs / 3_600_000.0)
    return (dragFraction * durationMs * 0.15 * factor).toLong()
    }

    写成公式就是:

    Δt = (Δx / 屏宽) × 时长 × 0.15 × (1 + min(1, 时长 / 1 小时))

    划满一整屏能移动多少:

    视频时长系数划满一屏移动
    10 分钟 1.17 约 1 分 45 秒(17.5%)
    30 分钟 1.5 约 6 分 45 秒(22.5%)
    1 小时 2 18 分钟(30%)
    2 小时 2(封顶) 36 分钟(30%)

    短视频需要精细控制,长视频需要更大的跨度,所以比例随时长增大,到 1 小时封顶,免得电影一划就跳过大半部。

    还有一个细节:拖动过程中只更新界面上的预览时间,松手时才真正 seekTo 一次:

    fun finish() {
    if (mode == DragMode.SEEK) player.seekTo(target)
    // …
    }

    原因是每次 seek 都会让 ExoPlayer 清空缓冲重新加载,播放网络视频时还意味着发一个新的 HTTP 请求(第六节讲)。手指滑动一下就会产生几十个事件,如果每个都 seek,网络请求会一个接一个地发出去又被取消。

    4.3 亮度和音量

    fun brightness(): Float {
    val override = activity.window.attributes.screenBrightness
    if (override >= 0) return override
    return Settings.System.getInt(context.contentResolver, Settings.System.SCREEN_BRIGHTNESS, 128) / 255f
    }
    fun setBrightness(level: Float) {
    activity.window.attributes = activity.window.attributes.apply { screenBrightness = level.coerceIn(0.01f, 1f) }
    }
    fun volume() = audio.getStreamVolume(AudioManager.STREAM_MUSIC).toFloat() / maxVolume
    fun setVolume(level: Float) = audio.setStreamVolume(AudioManager.STREAM_MUSIC, (level * maxVolume).roundToInt(), 0)

    • 亮度改的是窗口属性,不是系统设置。WindowManager.LayoutParams.screenBrightness 只影响当前窗口,不需要 WRITE_SETTINGS 权限,离开播放器时设回 BRIGHTNESS_OVERRIDE_NONE,系统亮度原样恢复。还没调过时,初始值从系统设置里读(0–255 换算成 0–1)。下限设成 0.01 而不是 0,避免屏幕暗到完全看不见。
    • 音量改的是媒体音量流(STREAM_MUSIC),最后一个参数 flags = 0 表示不弹出系统音量条,由播放器自己在屏幕中间显示进度。

    4.4 双击和长按

    detectTapGestures(
    onPress = {
    tryAwaitRelease()
    endBoost() // 松手:恢复原来的速度
    },
    onLongPress = {
    if (player.isPlaying) {
    val fast = if (model.speed < 2f) 2f else 3f
    boost = fast
    player.playbackParameters = PlaybackParameters(fast)
    }
    },
    onTap = { if (controlsVisible) controlsVisible = false else showControls() },
    onDoubleTap = { offset ->
    val zone = offset.x / size.width
    when {
    zone < 0.33f -> doubleTapSeek(forward = false) // 左三分之一:快退
    zone > 0.67f -> doubleTapSeek(forward = true) // 右三分之一:快进
    else -> togglePlay() // 中间:播放/暂停
    }
    },
    )

    • 双击可以累加:同一个方向连续双击,每次再跳 10 秒(秒数可以在设置里改),屏幕上显示累计值,比如"30s"。换方向或者停顿一会儿就清零。
    • 长按临时加速:按住 2 倍速,松手恢复。如果本来就已经是 2 倍速,长按就到 3 倍速。onPress 里的 tryAwaitRelease() 会一直挂起到手指抬起,正好用来做"松手恢复"。

    这里有个 Compose 的取舍:一旦设置了 onDoubleTap,单击就要等过了双击的判定时间(约 300 毫秒)才会触发,因为系统得先确认你是不是还要点第二下。所以点一下呼出控制栏会有一点点延迟,这是支持双击的代价。


    五、本地视频:MediaStore 和 SAF

    5.1 权限的演变

    Android 10 引入了分区存储(Scoped Storage),应用不能再随意按文件路径读写共享存储,只能通过两条正规途径:MediaStore(系统的媒体索引)和 SAF(存储访问框架,由用户在系统选择器里授权)。读取视频需要的权限也随版本变了好几次:

    系统版本需要的权限
    Android 10–12(API 29–32) READ_EXTERNAL_STORAGE
    Android 13(API 33) READ_MEDIA_VIDEO(图片、视频、音频拆成了三个权限)
    Android 14+(API 34+) READ_MEDIA_VIDEO + READ_MEDIA_VISUAL_USER_SELECTED(用户可以只授权一部分视频)

    /** Android 13+ 单独申请视频权限;14+ 允许用户只授权部分视频 */
    val permissions: Array<String>
    get() = when {
    Build.VERSION.SDK_INT >= 34 -> arrayOf(Manifest.permission.READ_MEDIA_VIDEO, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED)
    Build.VERSION.SDK_INT >= 33 -> arrayOf(Manifest.permission.READ_MEDIA_VIDEO)
    else -> arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE)
    }

    清单里 READ_EXTERNAL_STORAGE 加了 android:maxSdkVersion="32",在 Android 13 以上的设备上就不会再申请这个旧权限。

    5.2 MediaStore:查询系统的媒体索引

    系统的 MediaProvider 会扫描存储,把媒体文件的元数据存进一个 SQLite 数据库。应用通过 ContentResolver 查询,拿到的是 content:// URI,而不是文件路径:

    val collection = MediaStore.Video.Media.getContentUri(MediaStore.VOLUME_EXTERNAL)
    val projection = arrayOf(
    MediaStore.Video.Media._ID,
    MediaStore.Video.Media.DISPLAY_NAME,
    MediaStore.Video.Media.DURATION,
    MediaStore.Video.Media.SIZE,
    MediaStore.Video.Media.RELATIVE_PATH,
    MediaStore.Video.Media.MIME_TYPE,
    MediaStore.Video.Media.DATE_MODIFIED,
    )
    context.contentResolver.query(collection, projection, null, null, "${MediaStore.Video.Media.DATE_ADDED} DESC")?.use { cursor ->
    // 每一行:ContentUris.withAppendedId(collection, id) 就是这个视频的 URI
    }

    时长、大小这些直接从索引里读,不用打开文件,所以几千个视频的列表也能很快出来。RELATIVE_PATH(比如 Movies/、DCIM/Camera/)用来做"按文件夹分组"。

    5.3 SAF:用户指定的任意文件夹

    想按自己的目录管理视频时(比如某个下载目录、U 盘里的文件夹),通过 SAF 添加:调起系统的文件夹选择器(OpenDocumentTree),用户选好后得到一个 tree URI。关键是要持久化这个授权,否则应用重启后就没有权限了:

    fun add(uri: Uri) {
    // 同时要写权限,这样能在应用里删除视频;如果系统只给了读权限,就只拿读
    runCatching {
    context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
    }.onFailure {
    context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
    }
    // …保存 URI
    }

    读取已保存的文件夹时,要和 persistedUriPermissions 做一次比对,因为用户可能已经在系统设置里撤销了授权。

    遍历文件夹用的是广度优先:通过 DocumentsContract.buildChildDocumentsUriUsingTree 逐层查询子项,遇到子文件夹就排进队列。注意,SAF 的每次查询都是一次跨进程调用(对面是 DocumentsProvider),比直接读文件系统慢得多,所以设了 5000 个视频的上限,并跳过以 . 开头的隐藏文件。同一个文件夹里的字幕文件也是在这一步顺便收集的。

    5.4 缩略图

    val bitmap = runCatching { context.contentResolver.loadThumbnail(item.uri, Size(320, 180), null) }.getOrNull()
    ?: runCatching { frame(context, item) }.getOrNull()

    /** 兜底:没有现成缩略图的文件,取第 1 秒附近的一帧 */
    private fun frame(context: Context, item: VideoItem): Bitmap? {
    val retriever = MediaMetadataRetriever()
    return try {
    if (item.source == VideoItem.Source.NETWORK) retriever.setDataSource(item.uri.toString(), item.headers)
    else retriever.setDataSource(context, item.uri)
    retriever.getScaledFrameAtTime(1_000_000, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, 320, 180)
    } finally {
    retriever.release()
    }
    }

    优先用 loadThumbnail(Android 10 新增),它直接用 Provider 已有的缩略图缓存,几乎零成本。拿不到时再用 MediaMetadataRetriever 抽帧。OPTION_CLOSEST_SYNC 表示取离指定时间最近的关键帧:关键帧可以独立解码,不依赖前后的帧,所以只解一帧就够了;如果要求精确到某一帧,就得从前一个关键帧开始一路解码过来。缩略图放在一个容量 120 的 LruCache 里。网络视频只给"继续观看"里的那几张卡片抽帧,因为每抽一帧都要走网络读数据。


    六、WebDAV 串流

    6.1 WebDAV 是什么

    WebDAV(RFC 4918)是 HTTP 的扩展,在 GET、PUT 之外增加了 PROPFIND、MKCOL、COPY、MOVE、LOCK 等方法,让 HTTP 服务器可以当网络文件系统用。大多数 NAS、路由器和不少网盘都支持。播放器只需要两个能力:用 PROPFIND 列目录,用 GET 读文件。

    列目录的请求长这样:

    PROPFIND /movies/ HTTP/1.1
    Host: 192.168.1.10:5005
    Depth: 1
    Authorization: Basic dXNlcjpwYXNzd29yZA==
    Content-Type: application/xml; charset=utf-8

    <?xml version="1.0" encoding="utf-8"?>
    <D:propfind xmlns:D="DAV:"><D:prop><D:resourcetype/><D:getcontentlength/><D:displayname/></D:prop></D:propfind>

    • Depth: 1 表示返回这个目录本身加上它的直接子项。0 只返回自己,infinity 是递归整棵树,后者很多服务器出于性能考虑直接禁用了。
    • 请求体只要三个属性:是不是文件夹(resourcetype)、大小(getcontentlength)、显示名(displayname)。少要属性,服务器就少做事、少传数据。

    服务器返回 207 Multi-Status,每个条目一个 <response>:

    <D:multistatus xmlns:D="DAV:">
    <D:response>
    <D:href>/movies/</D:href>
    <D:propstat><D:prop><D:resourcetype><D:collection/></D:resourcetype></D:prop></D:propstat>
    </D:response>
    <D:response>
    <D:href>/movies/%E7%AC%AC01%E9%9B%86.mkv</D:href>
    <D:propstat><D:prop>
    <D:resourcetype/>
    <D:getcontentlength>1468006400</D:getcontentlength>
    </D:prop></D:propstat>
    </D:response>
    </D:multistatus>

    6.2 解析时的几个坑

    "response" -> {
    val url = href?.let { base.resolve(it) } ?: continue
    val segments = url.pathSegments.filter { it.isNotEmpty() }
    // 目录自己也会出现在结果里,跳过
    if (segments == self) continue
    val name = displayName ?: segments.lastOrNull() ?: continue
    entries += RemoteEntry(name, url, collection, size)
    }

  • 命名空间前缀不固定。有的服务器用 D:,有的用 d:,有的用 lp1:。解析器打开 FEATURE_PROCESS_NAMESPACES,按本地名(response、href)匹配,不管前缀。
  • 目录自己也在结果里。Depth: 1 的结果包含当前目录本身,要比较路径段把它排除,否则列表里会多出一个"自己"。
  • href 的形式五花八门。可能是绝对路径、完整 URL,也可能经过百分号编码。统一用 HttpUrl.resolve() 基于最终请求的 URL解析。注意是最终 URL:如果发生了重定向(比如 /movies 被重定向到 /movies/),相对路径要以重定向之后的地址为基准。
  • 文件夹是 <collection/>,而且只在 <resourcetype> 里面出现才算数。
  • 排序用自然排序,文件夹在前,"第2集"排在"第10集"前面,而不是按字符逐个比较。
  • 状态码要翻译成用户能看懂的提示:

    状态含义
    207 正常,解析目录
    401 / 403 需要登录、密码不对,或者没有权限
    404 路径不存在
    200 / 405 / 501 地址能访问,但不是 WebDAV(普通网页,或服务器不认识 PROPFIND)
    SSLException HTTPS 证书有问题
    连接异常 地址或端口连不上

    6.3 认证和串流

    /** 每个请求都带上:抢先发送 Basic 认证 */
    val headers: Map<String, String> =
    if (login == null || login.username.isEmpty()) emptyMap()
    else mapOf("Authorization" to Credentials.basic(login.username, login.password, Charsets.UTF_8))

    标准的 HTTP 认证是"先不带凭据请求一次,收到 401 再带上凭据重发"。这里采用抢先认证,第一个请求就带上 Authorization,省掉一次往返。更重要的是,同一份 headers 可以直接交给播放器的数据源:

    private fun mediaSource(item: VideoItem): MediaSource {
    // 网络视频可能需要 Authorization 头;本地视频用默认数据源
    val dataSource = if (item.source == VideoItem.Source.NETWORK) {
    DefaultDataSource.Factory(getApplication(), OkHttpDataSource.Factory(Http.client).setDefaultRequestProperties(item.headers))
    } else {
    DefaultDataSource.Factory(getApplication())
    }
    // …
    }

    要提醒的是,Basic 认证只是把"用户名:密码"做了 Base64 编码,不是加密。走明文 HTTP 时,同一个局域网里抓包就能看到。NAS 支持 HTTPS 的话尽量用 HTTPS。

    家用 NAS 大多是明文 HTTP,或者用的是自签证书,所以 network_security_config 里放开了明文流量,并信任用户自己安装的证书:

    <network-security-config>
    <base-config cleartextTrafficPermitted="true">
    <trust-anchors>
    <certificates src="system" />
    <certificates src="user" />
    </trust-anchors>
    </base-config>
    </network-security-config>

    6.4 拖动网络视频的进度时发生了什么

    网络视频能拖动,靠的是 HTTP 的 Range 请求:

  • ExoPlayer 在 SeekMap 里查到目标时间对应的字节偏移,比如第 734003200 字节;
  • 发起新请求 Range: bytes=734003200-;
  • 服务器返回 206 Partial Content,从那个位置开始传数据。
  • 所以服务器必须支持 Range(响应头里有 Accept-Ranges: bytes),主流 WebDAV 服务器都支持。另外两点会影响体验:

    • MP4 的 moov 放在文件末尾时,播放器得先跳到文件尾读索引,再回到开头读数据,起播会慢一些。用 -movflags faststart 转过的文件把 moov 放在开头,起播更快。
    • 没有 Cues 索引的 MKV 建不出 SeekMap,只能从头顺序播放,拖不了进度。

    这也是第四节里"松手才 seek"的原因:每次 seek 都是一次新的 Range 请求。


    七、局域网自动发现:mDNS/DNS-SD 和子网扫描

    让用户手动输入 NAS 的 IP 和端口体验很差,所以"添加服务器"页面会自动列出局域网里的服务器。实现上是两条路并行:

    NAS / 电脑

    组播 224.0.0.251:5353

    播放器

    NAS / 电脑

    组播 224.0.0.251:5353

    播放器

    #mermaid-svg-UowCKGWNCHSxHpWG{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-UowCKGWNCHSxHpWG .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-UowCKGWNCHSxHpWG .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-UowCKGWNCHSxHpWG .error-icon{fill:#552222;}#mermaid-svg-UowCKGWNCHSxHpWG .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-UowCKGWNCHSxHpWG .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-UowCKGWNCHSxHpWG .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-UowCKGWNCHSxHpWG .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-UowCKGWNCHSxHpWG .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-UowCKGWNCHSxHpWG .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-UowCKGWNCHSxHpWG .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-UowCKGWNCHSxHpWG .marker{fill:#333333;stroke:#333333;}#mermaid-svg-UowCKGWNCHSxHpWG .marker.cross{stroke:#333333;}#mermaid-svg-UowCKGWNCHSxHpWG svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-UowCKGWNCHSxHpWG p{margin:0;}#mermaid-svg-UowCKGWNCHSxHpWG .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-UowCKGWNCHSxHpWG text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-UowCKGWNCHSxHpWG .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-UowCKGWNCHSxHpWG .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-UowCKGWNCHSxHpWG .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-UowCKGWNCHSxHpWG .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-UowCKGWNCHSxHpWG #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-UowCKGWNCHSxHpWG .sequenceNumber{fill:white;}#mermaid-svg-UowCKGWNCHSxHpWG #sequencenumber{fill:#333;}#mermaid-svg-UowCKGWNCHSxHpWG #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-UowCKGWNCHSxHpWG .messageText{fill:#333;stroke:none;}#mermaid-svg-UowCKGWNCHSxHpWG .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-UowCKGWNCHSxHpWG .labelText,#mermaid-svg-UowCKGWNCHSxHpWG .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-UowCKGWNCHSxHpWG .loopText,#mermaid-svg-UowCKGWNCHSxHpWG .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-UowCKGWNCHSxHpWG .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-UowCKGWNCHSxHpWG .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-UowCKGWNCHSxHpWG .noteText,#mermaid-svg-UowCKGWNCHSxHpWG .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-UowCKGWNCHSxHpWG .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-UowCKGWNCHSxHpWG .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-UowCKGWNCHSxHpWG .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-UowCKGWNCHSxHpWG .actorPopupMenu{position:absolute;}#mermaid-svg-UowCKGWNCHSxHpWG .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-UowCKGWNCHSxHpWG .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-UowCKGWNCHSxHpWG .actor-man circle,#mermaid-svg-UowCKGWNCHSxHpWG line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-UowCKGWNCHSxHpWG :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    第二条路:逐个探测本网段 253 个地址

    mDNS 查询 _webdav._tcp.local(PTR)

    实例名 + SRV(主机名、端口)+ TXT(path=/)+ A(IP)

    TCP 连接 80 / 443 / 5005 / 5006 / 8080 / 8443

    SYN-ACK(端口开放)

    OPTIONS /

    响应头 DAV: 1,2(确认是 WebDAV)

    反向 DNS 查设备名

    7.1 第一条路:Bonjour(mDNS + DNS-SD)

    mDNS(RFC 6762)把 DNS 查询搬到了局域网组播上:查询发到 224.0.0.251 的 UDP 5353 端口,局域网里每台设备自己回答关于自己的问题,不需要 DNS 服务器。DNS-SD(RFC 6763)在它之上定义了怎么用 DNS 记录描述"服务":

    记录作用例子
    PTR 某类服务有哪些实例 _webdav._tcp.local → 客厅NAS._webdav._tcp.local
    SRV 实例在哪台主机、哪个端口 → nas.local:5005
    TXT 附加的键值对 path=/dav
    A / AAAA 主机名对应的 IP nas.local → 192.168.1.10

    WebDAV 约定的服务类型是 _webdav._tcp(HTTPS 是 _webdavs._tcp),TXT 里的 path 指明 WebDAV 的根目录。Android 用 NsdManager 封装了这一套:

    for ((type, https) in listOf("_webdav._tcp" to false, "_webdavs._tcp" to true)) {
    val listener = object : NsdManager.DiscoveryListener {
    override fun onServiceFound(info: NsdServiceInfo) {
    scope.launchResolve(info, https)
    }
    // …
    }
    runCatching { nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, listener) }.onSuccess { listeners += listener }
    }

    两个兼容性的坑:

  • 老版本 Android 上,resolveService 同一时间只能解析一个服务,同时发起第二个会失败(FAILURE_ALREADY_ACTIVE)。所以用一个 Mutex 把解析串行化:resolving.withLock { resolve(info) }。
  • 拿 IP 的 API 换过。Android 14 起用 hostAddresses 拿到地址列表,之前只有一个 host。列表里优先取 IPv4,IPv6 的链路本地地址拼 URL 很麻烦。
  • 在 Mac 上可以用一行命令模拟一台广播 WebDAV 的设备,测试很方便:

    dns-sd -R "测试服务器" _webdav._tcp local 8080 path=/

    7.2 第二条路:子网扫描

    不是所有设备都会广播 WebDAV 服务,所以还有一个兜底方案:把本网段的地址挨个试一遍。

    /** 常见的 WebDAV 端口;5005/5006 是常见 NAS 的默认端口 */
    private val PORTS = listOf(80, 443, 5005, 5006, 8080, 8443)

    suspend fun scan(own: Inet4Address, onProgress: (Float) -> Unit, onFound: (FoundServer) -> Unit) = withContext(Dispatchers.IO) {
    val bytes = own.address
    val prefix = "${bytes[0].toInt() and 255}.${bytes[1].toInt() and 255}.${bytes[2].toInt() and 255}"
    val self = bytes[3].toInt() and 255
    val gate = Semaphore(48) // 最多同时 48 个连接
    // …
    coroutineScope {
    (1..254).filter { it != self }.map { last ->
    async {
    val host = "$prefix.$last"
    val open = PORTS.filter { port -> gate.withPermit { isOpen(host, port) } }
    if (open.isNotEmpty()) {
    val webDav = open.filter { looksLikeWebDav(host, it) }
    // …
    }
    }
    }.awaitAll()
    }
    }

    private fun isOpen(host: String, port: Int): Boolean = runCatching {
    Socket().use { it.connect(InetSocketAddress(host, port), 700) }
    true
    }.getOrDefault(false)

    原理是 TCP 连接探测。对一个地址的某个端口发起连接,会有三种结果:

    • 收到 SYN-ACK:端口开放,连接成功;
    • 收到 RST:主机在,但端口没开,立刻失败;
    • 什么都没收到:这个地址上没有设备(局域网里表现为 ARP 解析不到),只能等到超时。

    所以扫描耗时主要花在空地址上。估算一下最坏情况:253 个地址 × 6 个端口 = 1518 次探测,每次最多 0.7 秒,48 路并发,约 1518 × 0.7 ÷ 48 ≈ 22 秒。实际上大部分空地址会更快失败,而且发现一个就显示一个,用户不用等扫描结束。

    端口开着不代表就是 WebDAV,所以再用一个 OPTIONS 请求确认:

    /** WebDAV 服务器会在 OPTIONS 的响应里带上 "DAV" 头 */
    private fun looksLikeWebDav(host: String, port: Int): Boolean = runCatching {
    val client = Http.client.newBuilder().connectTimeout(2, TimeUnit.SECONDS).readTimeout(2, TimeUnit.SECONDS).build()
    val scheme = if (port in HTTPS_PORTS) "https" else "http"
    val request = Request.Builder().url("$scheme://$host:$port/").method("OPTIONS", null).build()
    client.newCall(request).execute().use { it.header("DAV") != null }
    }.getOrDefault(false)

    RFC 4918 规定,支持 WebDAV 的服务器要在响应头里用 DAV: 1, 2 声明自己支持的等级。没有这个头的主机也会列出来,只是不标"已确认",因为有些设备的 WebDAV 挂在子路径下,根路径不响应。

    最后,设备名通过反向 DNS(PTR 查询)向路由器要:InetAddress.getByName(ip).canonicalHostName,比如得到 NAS.lan 就显示 NAS。家用路由器一般会把 DHCP 分配的主机名登记进自己的 DNS。这一步设了 2 秒超时,查不到就显示 IP。

    项目的待办里还记着一条:targetSdk 升到 37 时,要适配 Android 17 的局域网访问权限,到时候扫描和 WebDAV 访问都会受影响。


    八、登录信息加密:Android Keystore + AES-GCM

    WebDAV 的用户名和密码要存在手机上,明文存进 SharedPreferences 显然不行。做法是用 Android Keystore 里的密钥加密:

    private fun key(): SecretKey {
    val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
    (keyStore.getKey(ALIAS, null) as? SecretKey)?.let { return it }
    val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
    generator.init(
    KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
    .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
    .setKeySize(256)
    .build(),
    )
    return generator.generateKey()
    }

    fun set(serverId: String, login: Login?) {
    // …
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    cipher.init(Cipher.ENCRYPT_MODE, key())
    val sealed = cipher.iv + cipher.doFinal(JSONObject().put("u", login.username).put("p", login.password).toString().toByteArray())
    prefs.edit().putString(serverId, Base64.encodeToString(sealed, Base64.NO_WRAP)).apply()
    }

    原理上有三点:

  • 密钥不出安全硬件。Keystore 的密钥在可信执行环境(TEE,部分机型是独立的 StrongBox 安全芯片)里生成和使用,应用拿到的只是一个句柄,密钥本身从不进入应用进程的内存。就算有人拷走了应用的数据文件,在别的设备上也解不开。
  • AES-GCM 是认证加密。它同时提供保密性和完整性:密文后面附带 128 位的认证标签,密文被改动过一个比特,解密就会直接失败,而不是解出一堆乱码。
  • IV 由 Keystore 生成。Keystore 默认强制随机化加密,加密时不允许自己指定 IV(防止 IV 重用,GCM 下重用 IV 是灾难性的),所以要在 init 之后读 cipher.iv,把它和密文存在一起。最终存储格式是:
  • [ IV 12 字节 ][ 密文 ][ 认证标签 16 字节 ] → Base64 → SharedPreferences

    解密时先取前 12 字节作为 IV:

    cipher.init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes, 0, IV_SIZE))
    val json = JSONObject(String(cipher.doFinal(bytes, IV_SIZE, bytes.size – IV_SIZE)))

    还有一个容易忽略的点:备份。既然密钥不能离开这台设备,那么把密文备份到云端、恢复到新手机上也解不开,反而会让应用读到一份无效数据。所以在备份规则里把它排除掉:

    <data-extraction-rules>
    <cloud-backup>
    <exclude domain="sharedpref" path="logins.xml" />
    </cloud-backup>
    <device-transfer>
    <exclude domain="sharedpref" path="logins.xml" />
    </device-transfer>
    </data-extraction-rules>

    服务器的地址、端口、路径这些不敏感的信息单独以 JSON 明文保存。


    九、外挂字幕

    9.1 怎么找到字幕

    视频旁边的同名字幕自动加载,匹配规则支持带语言后缀的写法:

    /** "Movie.mp4" 对应 "Movie.srt"、"Movie.en.srt"、"Movie.chs.ass"… */
    fun subtitleBelongsTo(subtitleName: String, videoName: String): Boolean {
    val video = videoName.substringBeforeLast('.').lowercase()
    val subtitle = subtitleName.substringBeforeLast('.').lowercase()
    return subtitle == video || subtitle.startsWith("$video.")
    }

    扩展名映射到 MIME 类型,ExoPlayer 据此选择解析器:

    扩展名MIME
    .srt application/x-subrip
    .vtt text/vtt
    .ass / .ssa text/x-ssa
    .ttml / .dfxp application/ttml+xml

    本地文件夹和 WebDAV 目录里的字幕都是列目录时顺便匹配上的,不需要额外请求。

    9.2 怎么加载到播放器

    字幕是 MediaItem 的一部分,通过 SubtitleConfiguration 声明:

    // 手动选的字幕排第一并默认显示;否则用视频旁边找到的第一个
    val subtitles = (listOfNotNull(pickedSubtitles.get(item.id)) + item.subtitles).distinctBy { it.uri }
    val mediaItem = MediaItem.Builder()
    .setMediaId(item.id)
    .setUri(item.uri)
    .setSubtitleConfigurations(
    subtitles.mapIndexed { index, subtitle ->
    MediaItem.SubtitleConfiguration.Builder(subtitle.uri)
    .setMimeType(subtitle.mimeType)
    .setLabel(subtitle.name)
    .setSelectionFlags(if (index == 0) C.SELECTION_FLAG_DEFAULT else 0)
    .build()
    },
    )
    .build()

    DefaultMediaSourceFactory 会为每个字幕文件单独建一个媒体源,再和视频本体合并成一个 MergingMediaSource。对播放器来说,外挂字幕和视频里内封的字幕轨没有区别,都出现在"音轨/字幕"菜单里。

    播放中途手动选一个字幕文件时有个限制:字幕配置是 MediaItem 的一部分,已经 prepare 过的媒体源加不进去。所以做法是记住当前位置,用新的配置重建媒体源,再从原位置继续:

    fun addSubtitle(subtitle: Subtitle) {
    val item = current ?: return
    pickedSubtitles.set(item.id, subtitle) // 按视频记住,下次打开还在
    val index = player.currentMediaItemIndex
    val position = player.currentPosition
    val playing = player.playWhenReady
    // …
    player.setMediaSources(queue.map(::mediaSource), index, position)
    player.prepare()
    player.playWhenReady = playing
    }

    ASS 字幕要说明一下:ExoPlayer 自带的 SSA 解析器支持基本样式和定位,但动画、卡拉 OK 这类特效不支持,因为它没有集成 libass。


    十、画中画和系统集成

    10.1 画中画

    窗口比例有限制。Android 要求画中画窗口的宽高比在 1:2.39 到 2.39:1 之间,超出范围会直接抛异常。超宽的电影(比如 2.4:1)就会触发,所以要先夹一下:

    /** Android 只允许 1:2.39 到 2.39:1 之间的比例,超出会被拒绝 */
    fun aspectRatio(width: Float, height: Float): Rational? {
    if (width <= 0 || height <= 0) return null
    // 比上下限稍微收一点,防止换算成分数时四舍五入落到范围外
    val ratio = (width / height).coerceIn(0.42f, 2.38f)
    return Rational((ratio * 1000).roundToInt(), 1000)
    }

    进入方式分版本:

    • Android 12+:setAutoEnterEnabled(true),播放时回到桌面,系统自动把视频缩小成小窗。配合 setSourceRectHint 告诉系统画面在屏幕上的位置,缩小动画会从画面本身平滑过渡。
    • Android 10–11:没有自动进入,要在 onUserLeaveHint()(用户主动离开应用时回调)里手动调用 enterPictureInPictureMode:

    override fun onUserLeaveHint() {
    super.onUserLeaveHint()
    // Android 12+ 会自动进入画中画;老版本在这里手动进入
    val settings = AppSettings(this)
    if (Build.VERSION.SDK_INT < 31 && settings.autoPip && player.current != null && player.player.isPlaying && Pip.supported(this)) {
    runCatching { enterPictureInPictureMode(Pip.params(this, true, player.videoAspect, autoEnter = false, seekSeconds = settings.seekSeconds)) }
    }
    }

    小窗里的按钮(快退、播放/暂停、快进)是 RemoteAction,点击后由系统发出一个 PendingIntent 广播,播放器收到后执行对应操作。Android 12 起 PendingIntent 必须显式声明是否可变,这里用 FLAG_IMMUTABLE。

    10.2 耳机和蓝牙按键

    val session = runCatching { MediaSession.Builder(context, player).build() }.getOrNull()

    Media3 的 MediaSession 把播放器接入系统的媒体控制体系。有了它,耳机线控、蓝牙耳机的播放/暂停/切歌按键会自动转发给播放器,不用自己处理按键事件。

    10.3 “用 UnitPlayer 打开”

    在清单里声明两个 intent-filter:一个接收本地视频文件,一个接收视频链接:

    <intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <data android:scheme="content" />
    <data android:scheme="file" />
    <data android:mimeType="video/*" />
    </intent-filter>
    <intent-filter>
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="http" />
    <data android:scheme="https" />
    <data android:mimeType="video/*" />
    </intent-filter>

    Activity 是 singleTask 模式,已经在运行时,新的打开请求走 onNewIntent。从别的应用打开的视频,关闭播放器时调用 moveTaskToBack(true),直接回到原来的应用,而不是停在播放器的首页。

    10.4 应用内切换语言

    用 AppCompatDelegate.setApplicationLocales() 切换语言。Android 13 起这就是系统的"按应用设置语言"功能,和系统设置里的选项是同步的(需要在清单里用 localeConfig 声明支持哪些语言);Android 12 及以下由 AppCompat 自己保存选择,清单里要开启 AppLocalesMetadataHolderService 的 autoStoreLocales。一共 7 种语言,改文字时 7 份 strings.xml 要一起改,条数保持一致。

    10.5 一个输入法的小坑

    在中文输入法下往地址框里输 IP,冒号和点经常被转成全角的":“和”.",地址就无效了。解决办法是给地址类输入框指定英文的输入提示语言,输入法会自动切到英文模式:

    keyboardOptions = KeyboardOptions(
    keyboardType = KeyboardType.Uri,
    autoCorrectEnabled = false,
    hintLocales = LATIN, // LocaleList(en-US)
    imeAction = ImeAction.Next,
    )


    十一、签名、构建和上架 Google Play

    11.1 两把密钥

    Google Play 现在默认使用 Play 应用签名,涉及两把密钥:

    密钥谁保管用途
    上传密钥 开发者 给上传的 AAB 签名,证明是你传的
    应用签名密钥 Google 给最终分发到用户手机上的 APK 签名

    上传密钥丢了可以联系 Google 重置;应用签名密钥由 Google 托管,不存在丢失的问题。即便如此,上传密钥也要放在 git 仓库之外并另外备份。构建脚本从仓库外的 keystore.properties 读密码,文件不存在时照样能编译,只是发布版不签名,换一台机器也能正常构建。

    发布版开启了 isMinifyEnabled 和 isShrinkResources。Media3 和 OkHttp 都自带混淆规则,不需要额外写 ProGuard 配置。每次上传新版本,versionCode 加 1。

    11.2 新账号的封闭测试门槛

    新注册的个人开发者账号不能直接发布正式版,要先完成封闭测试:

    • 至少 12 名测试人员,每人连续保持加入至少 14 天;
    • 中途退出的测试人员不计入,退出后再加入,要重新连续算 14 天;
    • 只有封闭测试计入,内部测试不算;
    • 满足条件后,在 Play Console 的"信息中心"申请正式版,Google 会问测试情况、收到的反馈和做了哪些改进。

    测试人员用 Google 网上论坛(Google Groups)管理最方便:建一个"任何人都可以加入"的群组,把群组设为测试人员,再把群组链接和测试链接(https://play.google.com/apps/testing/<包名>)发出去就行。

    11.3 Play Console 踩坑记录

  • 截图不能带透明通道。PNG 带 alpha 会被拒,42 张截图全部转成了 RGB。
  • 先核对商品详情的默认语言。创建应用时默认语言被设成了 zh-CN,里面填的却是英文,后来改成 en-US,再把 zh-CN 换成中文内容。
  • 目标受众别随手全勾。勾了 13 岁以下就要遵守更严格的"家庭"政策,视频播放器只勾 18 岁以上就行。
  • 读视频的权限要写用途说明。申请 READ_MEDIA_VIDEO 会被要求说明核心用途,要讲清楚为什么系统的一次性选择器不够用、数据只在本机读取、不上传。
  • "保存为草稿"不算完成。商品详情要点"下一步"走到最后再"保存",改动才会进入"发布概览"。
  • 素材库一次上传有上限。截图栏最多 8 张,全选 42 张也只会收前 8 张,多语言只能按语言分批传。上传前给文件名加上语言前缀(比如 ja-01-library.png),不然素材库里同名的截图分不清是哪种语言。
  • 所有改动统一在"发布概览"送审。审核进行中再提交,会取消当前审核、全部重新排队。我送审后又改了一个反馈邮箱,结果整批重新排队。改动最好一次做完再送审。
  • "含原生代码、未上传调试符号"可以忽略。来自 Compose 自带的图形库,只是提醒。
  • 调试版和 Play 版签名不同。自己装测试版前要先卸载调试版,卸载会清掉数据。

  • 十二、后续计划

    • targetSdk 升到 37,适配 Android 17 的局域网访问权限;
    • 后台播放(需要前台服务,还要在 Play 上额外声明);
    • 平板的网格布局;
    • 网络文件夹的排序和搜索;
    • 评估是否接入 Media3 的 FFmpeg 音频扩展,补上 AC-3、DTS 这类音轨。

    结语

    从时间线看,功能代码用了一个晚上,花时间更多的是各种"规则":不同 Android 版本的权限差异、协议细节,以及 Play Console 里的声明和审核要求。和 Claude Code 协作时,代码可以交给它写,但"支持哪些系统版本"“哪些操作要人来确认”“对外发布什么内容”,还是要由人来定。

    UnitPlayer 目前在 Google Play 封闭测试中,如果你有 Android 10 及以上的手机,欢迎帮忙测试:

  • 加入测试群组:https://groups.google.com/g/unitplayer-testers
  • 打开测试链接,点"成为测试人员":https://play.google.com/apps/testing/com.unitapp.unitplayer
  • 用同一个 Google 账号在 Play 商店安装,保持加入 14 天
  • 有问题或建议欢迎在评论区留言。

    赞(0)
    未经允许不得转载:171主机测评 » 用 Claude Code 开发安卓视频播放器:从 ExoPlayer、WebDAV 到上架 Google Play 的完整流程与原理
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址