/* 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 几点体会
二、整体架构
#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
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)
}
状态码要翻译成用户能看懂的提示:
| 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 请求:
所以服务器必须支持 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 }
}
两个兼容性的坑:
在 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()
}
原理上有三点:
[ 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 据此选择解析器:
| .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 签名,证明是你传的 |
| 应用签名密钥 | 给最终分发到用户手机上的 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 踩坑记录
十二、后续计划
- targetSdk 升到 37,适配 Android 17 的局域网访问权限;
- 后台播放(需要前台服务,还要在 Play 上额外声明);
- 平板的网格布局;
- 网络文件夹的排序和搜索;
- 评估是否接入 Media3 的 FFmpeg 音频扩展,补上 AC-3、DTS 这类音轨。
结语
从时间线看,功能代码用了一个晚上,花时间更多的是各种"规则":不同 Android 版本的权限差异、协议细节,以及 Play Console 里的声明和审核要求。和 Claude Code 协作时,代码可以交给它写,但"支持哪些系统版本"“哪些操作要人来确认”“对外发布什么内容”,还是要由人来定。
UnitPlayer 目前在 Google Play 封闭测试中,如果你有 Android 10 及以上的手机,欢迎帮忙测试:
有问题或建议欢迎在评论区留言。



![[Android] 简捷分身双开 -应用多开分身独立运行-171主机测评](https://www.171host.com/wp-content/uploads/2026/11/20261101024518-6ae6a7bec2a4c-220x150.jpg)
