更新记录
1.0.0(2026-11-12)
- 提供 16 个方法:
isSupport/downloadListener/startServer/stopServer/getServerPort/getLanIp/startDownload/pauseDownload/resumeDownload/cancelDownload/deleteDownload/getTaskStatus/getPlayUrl/getStorageInfo/playLocalFile/stopLocalFile startServer({ lan: true })支持局域网内其它设备播放,回调返回本机 IP 与局域网播放地址playLocalFile支持直接播放手机上的本地 m3u8 文件(同目录分片由本地服务转发,不复制、不联网)- 下载进度补充字节级估算(边下边统计,慢网下不再长时间停在 0%),并支持
maxBandwidth限制 master 码率 - 支持 master playlist 自动选最高码率、AES-128 分片解密、fMP4(EXT-X-MAP)、自定义请求头
- 内置 127.0.0.1 本地 HTTP 播放服务,支持 Range 分段请求
- 单任务分片并发下载(默认 3),全局最多 2 个任务并行,支持暂停 / 续传 / 删除
- 任务状态与分片落盘,App 重启后可用同一 downloadId 续传
- 纯 Kotlin 实现,不依赖任何三方库
平台兼容性
uni-app(4.72)
| Vue2 | Vue2插件版本 | Vue3 | Vue3插件版本 | Chrome | Safari | app-vue | app-vue插件版本 | app-nvue | app-nvue插件版本 | Android | Android插件版本 | iOS | 鸿蒙 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| √ | 1.0.0 | √ | 1.0.0 | × | × | √ | 1.0.0 | √ | 1.0.0 | 5.0 | 1.0.0 | × | × |
| 微信小程序 | 支付宝小程序 | 抖音小程序 | 百度小程序 | 快手小程序 | 京东小程序 | 鸿蒙元服务 | QQ小程序 | 飞书小程序 | 小红书小程序 | 快应用-华为 | 快应用-联盟 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| × | × | × | × | - | × | × | × | × | - | × | × |
uni-app x(4.72)
| Chrome | Safari | Android | Android插件版本 | iOS | 鸿蒙 | 微信小程序 |
|---|---|---|---|---|---|---|
| × | × | 5.0 | 1.0.0 | × | × | × |
插件介绍
安卓 M3U8(HLS)视频下载 + 离线播放 UTS 原生插件。纯 Kotlin 原生混编实现,不依赖任何三方库,云打包不会额外拉 maven 依赖。
解析 m3u8 → 多线程下载分片(AES-128 自动解密)→ 重写本地 playlist → 内置 127.0.0.1 本地 HTTP 服务 → 把地址交给
<video>即可离线播放。
平台支持
| 平台 | 支持 | 说明 |
|---|---|---|
| App-Android | 是 | minSdkVersion 21 |
| App-iOS | 否 | 未实现,isSupport() 返回 false |
| H5 / 各家小程序 | 否 | 无本地文件系统,调用返回错误码 9290109 |
业务侧统一用 isSupport() 判断后再显示「下载」入口。
必须制作自定义基座。UTS 插件不在标准基座里,用标准基座运行会报「当前基座不包含该插件」;改完
.kt后需重新制作基座。
能力
- master playlist 自动选最高码率子列表
- 标准 HLS
METHOD=AES-128分片解密(IV 支持显式IV=0x...与按序号推导) - fMP4 源(
#EXT-X-MAP)初始化段支持 - 自定义请求头(Referer / Cookie 等防盗链)
- 单任务分片并发下载(默认 3,可调 1-8),全局最多 2 个任务并行
- 暂停 / 继续 / 取消 / 删除;已下载分片自动跳过,支持断点续传
- 任务状态与分片落盘,App 重启后可用同一
downloadId继续 - 内置本地 HTTP 播放服务,支持 Range(播放器 seek 正常)
- 直接播放手机上的本地 m3u8 文件(同目录分片由本地服务转发,不复制、不联网)
- 可开启局域网播放:其它设备用
http://<手机IP>:19685/...直接播
使用流程
- 启动本地播放服务:
startServer(),建议在App.vue的onLaunch里调用(幂等,可重复调用)。 - 注册监听:
downloadListener(callback)接收全局下载事件(进度 / 完成 / 失败)。 - 开始下载:
startDownload({ downloadId, url, title })。 - 播放:下载完成(事件
status == 'completed')后调用getPlayUrl(downloadId)拿到http://127.0.0.1:<port>/<downloadId>/playlist.m3u8,赋给<video>的src即可。
用法
// #ifdef APP-PLUS
import {
isSupport,
downloadListener,
startServer,
stopServer,
getServerPort,
startDownload,
pauseDownload,
resumeDownload,
cancelDownload,
deleteDownload,
getTaskStatus,
getPlayUrl,
getStorageInfo,
playLocalFile,
stopLocalFile,
getLanIp
} from '@/uni_modules/zy-m3u8'
// #endif
也可以用命名空间导入:
import * as m3u8 from '@/uni_modules/zy-m3u8'。 不支持默认导入(import m3u8 from '...'),会报"default" is not exported。
启动 / 停止本地播放服务
// 端口缺省 19685,被占用时自动 +1 重试(最多 20 次)
startServer({ port: 19685 }, (res) => {
if (res.code != 0) {
console.log('启动失败:' + res.message)
return
}
console.log('实际端口:' + res.data.port)
})
console.log('同步查端口:' + getServerPort()) // 未启动返回 0
stopServer((res) => {
console.log(res.code == 0 ? '已停止' : res.message)
})
局域网播放(其它设备访问)
// lan: true 时监听所有网卡(默认 false 只监听 127.0.0.1,仅本机可访问)
startServer({ port: 19685, lan: true }, (res) => {
if (res.code != 0) {
console.log('启动失败:' + res.message)
return
}
console.log('端口:' + res.data.port + ',本机 IP:' + res.data.ip)
})
// 之后 getPlayUrl / playLocalFile 的回调里会多出 lanUrl
getPlayUrl('ep_001', (res) => {
console.log('本机播放:' + res.data.playUrl) // http://127.0.0.1:19685/ep_001/playlist.m3u8
console.log('局域网播放:' + res.data.lanUrl) // http://192.168.1.23:19685/ep_001/playlist.m3u8
})
// 只查 IP(不要求服务已启动)
getLanIp((res) => {
console.log('本机局域网 IP:' + res.data.ip)
})
把 lanUrl 发到同网段的手机 / PC,用浏览器、VLC 等打开即可播放。
注意:
- 设备必须在同一 Wi-Fi,且路由器没有开启 AP 隔离(访客网络通常隔离);
- 切换
lan会重启服务,已登记的本地文件播放源需要重新playLocalFile;- 开着局域网监听时,同网段设备能读取本 App 已下载的视频文件,不需要时记得关掉。
播放手机上的本地 m3u8 文件
已经有一份 m3u8(连同分片在同一目录),不想走下载流程,直接播:
playLocalFile({
// m3u8 的绝对路径,分片需在同目录或其子目录内
path: '/storage/emulated/0/Android/data/io.dcloud.HBuilder/files/video/index.m3u8',
// 可选,缺省按路径自动生成;局域网地址里会用到
playId: 'local_demo'
}, (res) => {
if (res.code != 0) {
console.log('播放失败:' + res.message)
return
}
console.log('本机播放:' + res.data.playUrl)
console.log('局域网播放:' + res.data.lanUrl) // lan 开启时非空
})
// 用完移除该播放源(不影响整个服务)
stopLocalFile('local_demo', (res) => {})
说明:
- 服务未启动时会自动启动(默认端口 19685);
- 不复制文件:只把 m3u8 所在目录按需映射成
http://127.0.0.1:<port>/local/<playId>/xxx,分片按需读取; - playlist 里的相对路径、
file://、/sdcard/...绝对路径都会归一化;http(s)分片原样保留(有网时播放器直接拉); - 分片若加密(
#EXT-X-KEY),key 文件也会由本地服务转发,播放器自行解密; - 读取外部目录需要 App 有权限:应用私有目录(
/storage/emulated/0/Android/data/<包名>/...)可直接读;公共目录在 Android 10+ 需要额外授权。
下载
downloadListener((res) => {
const data = res.data
if (data == null) return
if (data.status == 'progress') {
console.log(data.downloadId + ' ' + data.progress + '%')
} else if (data.status == 'completed') {
console.log('下载完成,本地文件:' + data.savePath)
playVideo(data.downloadId)
} else if (data.status == 'failed') {
console.log('下载失败:' + data.error)
} else if (data.status == 'paused') {
console.log('已暂停:' + data.progress + '%')
}
})
// 开始下载:支持自定义请求头与分片并发
startDownload({
downloadId: 'ep_001',
url: 'https://cdn.xxx.com/ep1/index.m3u8',
title: '第一集',
headers: { Referer: 'https://xxx.com/', 'User-Agent': 'Mozilla/5.0' },
concurrency: 3,
maxRetry: 3
}, (res) => {
if (res.code != 0) console.log('启动失败:' + res.message)
})
// 暂停 / 继续 / 取消(取消保留已下载分片,便于下次续传)
pauseDownload('ep_001', (res) => {})
resumeDownload('ep_001', (res) => {})
cancelDownload('ep_001', (res) => {})
// 删除(连本地目录一起删)
deleteDownload('ep_001', (res) => {})
播放
startServer({ port: 19685 }, () => {
getPlayUrl('ep_001', (res) => {
if (res.code != 0) {
uni.showToast({ title: res.message, icon: 'none' })
return
}
this.playUrl = res.data.playUrl // http://127.0.0.1:19685/ep_001/playlist.m3u8
})
})
<video :src="playUrl" autoplay controls></video>
查询状态与空间
// 内存中没有的(如 App 重启后)会读磁盘快照,都没有返回 9290006
getTaskStatus('ep_001', (res) => {
if (res.code == 0) console.log(res.data.taskStatus, res.data.progress)
})
getStorageInfo((res) => {
console.log('可用 ' + res.data.storageAvailable + ' / 共 ' + res.data.storageTotal)
})
插件方法
| 方法 | 说明 | 回调 data.status |
|---|---|---|
isSupport() |
当前平台是否支持,同步返回 boolean | - |
downloadListener(callback) |
注册全局下载事件监听(持续回调) | 见下方说明 |
startServer(options, callback) |
启动本地播放服务,options.port 端口缺省 19685,options.lan 是否允许局域网访问(缺省 false) |
serverStart |
stopServer(callback) |
停止本地播放服务 | serverStop |
getServerPort() |
同步获取端口,未启动返回 0 | - |
getLanIp(callback) |
查询本机局域网 IPv4(不要求服务已启动) | lanIp |
startDownload(options, callback) |
开始下载 | start |
pauseDownload(downloadId, callback) |
暂停(当前分片下完后停) | paused |
resumeDownload(downloadId, callback) |
继续(已存在分片自动跳过) | start |
cancelDownload(downloadId, callback) |
取消(保留已下载分片) | cancelled |
deleteDownload(downloadId, callback) |
取消并删除本地目录 | deleted |
getTaskStatus(downloadId, callback) |
查询任务状态 | taskInfo |
getPlayUrl(downloadId, callback) |
获取本地播放地址(lan 模式下同时返回 lanUrl) |
playUrl |
getStorageInfo(callback) |
查询磁盘空间 | storage |
playLocalFile(options, callback) |
播放手机上的本地 m3u8 文件(不下载、不复制,服务未启动会自动启动) | playUrl |
stopLocalFile(playId, callback) |
移除本地文件播放源(不停止服务) | localStop |
回调结果结构
{
code : 0, // 0 表示成功,非 0 为错误码
message : '', // 错误描述,成功时为空串
data : {
status : 'progress', // 事件类型
downloadId : 'ep_001', // 任务 ID
title : '第一集', // 任务标题
url : 'https://...', // 源 m3u8 地址
playUrl : '', // 本地播放地址(completed / playUrl 时有值)
savePath : '/storage/.../zy-m3u8/ep_001/playlist.m3u8',
taskStatus : 'downloading', // none / pending / downloading / paused / completed / failed / cancelled
progress : 42, // 0 - 100(按已完成分片数)
segmentIndex : 21, // 已下载分片数
segmentCount : 50, // 分片总数
downloadedBytes : 10485760,
totalBytes : 12582912, // 按分片 Content-Length 累计,未预扫描
duration : 300, // 视频时长(秒,按 #EXTINF 累加)
port : 19685, // serverStart 时的实际端口
ip : '192.168.1.23', // 本机局域网 IP(lan 模式 / getLanIp 时有值)
lanUrl : '', // 局域网播放地址(lan 开启时非空)
storageAvailable : 0, // storage 事件
storageTotal : 0, // storage 事件
error : '' // 失败原因
}
}
downloadListener收到status == 'failed'时,code为对应错误码、message为失败文案,data.taskStatus为failed,data.error同样带原因。
DownloadOptions
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
downloadId |
string | 是 | 任务唯一 ID,同时作为缓存目录名(仅字母、数字、下划线、连字符,1-64 位) |
url |
string | 是 | m3u8 地址(http/https) |
title |
string | 否 | 仅用于展示 / 日志 |
headers |
object | 否 | 自定义请求头(防盗链) |
concurrency |
number | 否 | 分片并发数,缺省 3,范围 1-8 |
maxRetry |
number | 否 | 单个分片重试次数,缺省 3 |
maxBandwidth |
number | 否 | master playlist 最大码率(bps),缺省 0 表示不限制(选最高码率)。慢网建议限到 800000 左右:分片更小,首个分片更快下完,进度也更平滑 |
PlayLocalOptions
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
path |
string | 是 | m3u8 文件绝对路径,分片需在同一目录或其子目录内 |
playId |
string | 否 | 播放源 ID(同时是 URL 的一级路径),缺省按路径自动生成;lanUrl 里会用到 |
本地文件结构
<应用私有外部目录>/zy-m3u8/<downloadId>/
├── playlist.m3u8 # 重写后的本地播放列表(分片已解密,不含 #EXT-X-KEY)
├── segment_000.ts # 分片,序号 3 位补零
├── segment_001.ts
├── init_mp4 # 仅 fMP4(#EXT-X-MAP)源才有
└── task.json # 任务状态快照(重启后可查)
目录为 getExternalFilesDir()/zy-m3u8,卸载 App 即清理,不需要任何存储权限。
错误码
| 错误码 | 说明 | 业务侧建议 |
|---|---|---|
| 9290001 | 参数错误 | 检查 downloadId / url 是否合法 |
| 9290002 | 本地播放服务未启动 | 先 startServer |
| 9290003 | 启动本地播放服务失败 | 换端口 |
| 9290004 | 任务正在进行中 | 忽略或提示等待 |
| 9290005 | 本地播放服务已停止 | - |
| 9290006 | 任务不存在 | 忽略 |
| 9290007 | 删除任务失败 | 检查目录权限 |
| 9290008 | 操作失败 | 提示重试 |
| 9290101 | 网络请求失败 | 检查网络 / 地址是否可直连 |
| 9290102 | m3u8 解析失败 | 可能返回的是 HTML 错误页(防盗链),或不是 m3u8 |
| 9290103 | 分片解密失败 | key 与 IV 不匹配,或源使用了非 AES-128 方案 |
| 9290104 | 分片下载失败 | 提示重试;已下载分片会保留 |
| 9290105 | 存储空间不足 | 下载前用 getStorageInfo 拦一道 |
| 9290106 | 本地文件写入失败 | 检查磁盘空间 |
| 9290107 | 不支持的播放列表 | 直播流(无 #EXT-X-ENDLIST)或 SAMPLE-AES / DRM |
| 9290108 | 本地文件不存在 | 还没下载完成 |
| 9290109 | 当前平台不支持 | 用 isSupport() 隐藏入口 |
权限说明
| 权限 | 用途 |
|---|---|
android.permission.INTERNET |
下载 m3u8 与分片、监听本地端口 |
android.permission.ACCESS_NETWORK_STATE |
读取网络状态 |
android.permission.ACCESS_WIFI_STATE |
读取 WiFi 状态 |
注意事项
- 明文流量:本地播放走
http://127.0.0.1,若播放报CLEARTEXT communication to 127.0.0.1 not permitted,需要在宿主manifest.json的 Android 配置里允许明文流量(targetSdkVersion ≥ 28 时默认禁止)。项目里其它 http 接口能正常跑则已经放开。 - 播放器选择:
<video>组件能否播 m3u8 取决于 Android 系统播放器(原生 MediaPlayer 支持 HLS);若某些机型黑屏,可改用 ijkplayer / VLC 类播放器组件播同一个本地地址。 - 下载与播放互不依赖:
startDownload只做网络拉取与落盘,只有播放才需要本地服务。
FAQ
Q:下载完成但播放黑屏?
先确认 getServerPort() 不是 0(服务没起来时 getPlayUrl 会返回 9290002);再确认明文流量已放开(见注意事项 1)。
Q:m3u8 带防盗链(需要 Referer / Cookie)怎么办?
在 startDownload 的 headers 里传即可,所有分片与 key 请求都会带上。
Q:支持加密视频吗?
支持标准 HLS 的 METHOD=AES-128(key 明文可取)。不支持 SAMPLE-AES(FairPlay / Widevine 等 DRM),这类源即使下载也无法用普通播放器播放,会返回 9290107。
Q:支持直播吗?
不支持。只处理点播(末尾带 #EXT-X-ENDLIST),直播 playlist 返回 9290107。
Q:App 被杀后任务会怎样?
任务表是内存态,重启后丢失;但已下载分片与 task.json 都在磁盘上。用同样的 downloadId 再调一次 startDownload 即可续传(已存在分片自动跳过),getTaskStatus 也能读回磁盘上的状态。
Q:能同时下几个?
全局最多 2 个任务并行;单任务内分片默认 3 并发(concurrency 可调),移动端网络更稳,也避免被 CDN 限流。
Q:下载中途暂停后再播放能播已下载部分吗?
不能。只有全部分片下载完成才会生成 playlist.m3u8,未完成时 getPlayUrl 返回 9290108。
Q:进度长时间是 0%?
progress 同时参考「已完成分片数」与「已下载字节 / 按平均分片大小估算的总字节」,并保证单调不回退。若进度仍是 0 且 downloadedBytes 不涨,说明首个分片都还没开始收到数据(网络/CDN 慢或地址不可直连);如果 downloadedBytes 在涨而百分比涨得慢,是总时长较长、分片较大所致,可用 maxBandwidth 限制码率,或换码率档位更低的测试流。
Q:进度事件很密集吗?
进度事件做了 300ms 节流;downloadedBytes 是按块实时累加的,慢网下也能看到字节在涨。

收藏人数:
购买源码授权版(
试用
使用 HBuilderX 导入示例项目
赞赏(0)
下载 69
赞赏 0
下载 12761349
赞赏 1959
赞赏
京公网安备:11010802035340号