更新记录

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/... 直接播

使用流程

  1. 启动本地播放服务:startServer(),建议在 App.vue 的 onLaunch 里调用(幂等,可重复调用)。
  2. 注册监听:downloadListener(callback) 接收全局下载事件(进度 / 完成 / 失败)。
  3. 开始下载:startDownload({ downloadId, url, title })。
  4. 播放:下载完成(事件 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 等打开即可播放。

注意:

  1. 设备必须在同一 Wi-Fi,且路由器没有开启 AP 隔离(访客网络通常隔离);
  2. 切换 lan 会重启服务,已登记的本地文件播放源需要重新 playLocalFile;
  3. 开着局域网监听时,同网段设备能读取本 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 状态

注意事项

  1. 明文流量:本地播放走 http://127.0.0.1,若播放报 CLEARTEXT communication to 127.0.0.1 not permitted,需要在宿主 manifest.json 的 Android 配置里允许明文流量(targetSdkVersion ≥ 28 时默认禁止)。项目里其它 http 接口能正常跑则已经放开。
  2. 播放器选择:<video> 组件能否播 m3u8 取决于 Android 系统播放器(原生 MediaPlayer 支持 HLS);若某些机型黑屏,可改用 ijkplayer / VLC 类播放器组件播同一个本地地址。
  3. 下载与播放互不依赖: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 是按块实时累加的,慢网下也能看到字节在涨。

参考

隐私、权限声明

1. 本插件需要申请的系统权限列表:

android.permission.INTERNET android.permission.ACCESS_NETWORK_STATE android.permission.ACCESS_WIFI_STATE

2. 本插件采集的数据、发送的服务器地址、以及数据用途说明:

插件仅下载用户指定的 m3u8 视频并保存在应用私有目录,不采集、不上传任何数据

3. 本插件是否包含广告,如包含需详细说明广告表达方式、展示频率:

无

暂无用户评论。