更新记录
1.0.0(2026-11-04)
- 新增 Android / iOS 双端 M3U8(HLS)下载能力:
- 解析 master playlist,自动选择最高码率变体
- 解析 media playlist 的
#EXTINF/#EXT-X-KEY/#EXT-X-MAP - 标准
METHOD=AES-128解密(IV 支持显式IV=0x..与按 media sequence 推导两种) - 分片断点续传:已存在且非空的分片自动跳过
- 任务状态落盘
task.json,App 重启后仍可查询
- 新增本地 HTTP 服务(Android
ServerSocket/ iOSNetwork.framework),监听127.0.0.1, 支持Range请求,把本地 m3u8 直接喂给<video>做离线播放。 - 新增 API:
isSupport/startLocalServer/stopLocalServer/getLocalServerPort/startDownload/startBatchDownload/pauseDownload/resumeDownload/cancelDownload/deleteDownload/getDownloadStatus/getLocalPlayUrl/getCachedSize/getStorageInfo/onProgress/offProgress。 - 非 App 平台(H5 / 小程序)统一安全降级,
isSupport()返回false。
平台兼容性
uni-app(3.99)
| Vue2 | Vue3 | Chrome | Safari | app-vue | app-nvue | Android | iOS | 鸿蒙 |
|---|---|---|---|---|---|---|---|---|
| √ | √ | × | × | √ | √ | 6.0 | √ | × |
| 微信小程序 | 支付宝小程序 | 抖音小程序 | 百度小程序 | 快手小程序 | 京东小程序 | 鸿蒙元服务 | QQ小程序 | 飞书小程序 | 小红书小程序 | 快应用-华为 | 快应用-联盟 |
|---|---|---|---|---|---|---|---|---|---|---|---|
| × | × | × | × | × | × | × | × | × | × | × | × |
hy-m3u8-downloader
M3U8(HLS)视频下载 + 离线播放 UTS 插件,双端原生实现(Android / iOS)。
解析 m3u8 → 下载分片(支持 AES-128 解密)→ 重写本地 playlist → 起 127.0.0.1 本地 HTTP 服务 → 把地址丢给
<video>即可离线播放。 不依赖任何第三方库:Android 用ServerSocket+HttpURLConnection,iOS 用Network.framework+CommonCrypto,云打包不会多拉 maven / CocoaPods 依赖。
能力清单
- 解析 master playlist(自动选最高码率)
- 标准
METHOD=AES-128分片解密(IV 支持显式IV=0x..与按 media sequence 推导) - 断点续传:已存在的分片自动跳过
- 任务状态落盘
task.json,App 重启后还能查回状态 - 内置 127.0.0.1 本地 HTTP 服务(支持
Range),把本地 m3u8 喂给<video> - 存储空间查询、单任务体积统计
目录
平台支持
| 平台 | 支持 | 说明 |
|---|---|---|
| uni-app · App-Android | ✅ | minSdkVersion 26(需与宿主 manifest.json 一致) |
| uni-app · App-iOS | ✅ | deploymentTarget 13.0 |
| uni-app · H5 | ❌ | 无本地文件系统 / 无法起本地 HTTP 服务 |
| 微信等各家小程序 | ❌ | 同上;调用返回 false,不抛错 |
| uni-app x | ❌ | 本插件按 uni-app(vue2 / vue3)编写 |
| 鸿蒙 | ❌ | 未实现,isSupport() 返回 false |
业务侧统一用 isSupport() 判断后再显示「下载」入口:
// #ifdef APP-PLUS
import * as m3u8Downloader from '@/uni_modules/hy-m3u8-downloader'
// #endif
const supported = m3u8Downloader.isSupport()
⚠️ 导入路径用目录名:
@/uni_modules/hy-m3u8-downloader里的hy-m3u8-downloader就是uni_modules下的文件夹名(不是package.json的id)。uni-app 解析 UTS 模块时按目录路径匹配。⚠️ 必须用命名空间导入(
import * as xxx)。UTS 模块只生成具名导出, 写成import m3u8Downloader from '...'(默认导入)会在编译期报"default" is not exported by ".../hy-m3u8-downloader?uts-proxy"。 也可以按名导入:import { isSupport, startDownload } from '@/uni_modules/hy-m3u8-downloader'。
工作原理
m3u8 地址
│ ① 拉取 playlist(master 自动挑最高码率)
▼
分片列表 ──② 逐个下载 + AES-128 解密──▶ 沙盒 m3u8_videos/<videoId>/
│ ├── playlist.m3u8(已重写,去掉 KEY)
▼ ├── segment_000.ts ...
③ 起本地服务 127.0.0.1:19685 ◀──────────── └── task.json(状态快照)
│
▼
④ <video src="http://127.0.0.1:19685/<videoId>/playlist.m3u8">
下载不依赖本地服务,只有「播放」才需要;服务在 onLaunch 起来一次即可(幂等)。
快速开始(6 步跑通)
步骤 1:确认插件目录
你的项目/
└── uni_modules/
└── hy-m3u8-downloader/ ← 文件夹名就是导入路径
├── package.json
├── utssdk/
└── example/m3u8-download-demo.vue
utssdk 下的 .kt / .swift 是真正干活的原生混编文件,不要删、不要改名,否则编译期报 unresolved reference。
步骤 2:manifest.json 配置
Android 要求 minSdkVersion ≥ 26(插件自身声明 26,宿主不能更低):
{
"app-plus": {
"distribute": {
"android": {
"minSdkVersion": 26
}
}
}
}
如果播放时提示 CLEARTEXT communication to 127.0.0.1 not permitted
(targetSdkVersion ≥ 28 默认禁明文),需要在 Android 配置里放开明文流量:
{
"app-plus": {
"distribute": {
"android": {
"minSdkVersion": 26,
"permissions": [],
"abiFilters": ["armeabi-v7a", "arm64-v8a"]
}
}
}
}
HBuilderX 图形界面的位置:manifest.json → App常用其它设置 → Android 设置 → 允许 http 明文传输(不同版本叫法略有差异)。
iOS 无需配置 ATS:连接目标是 IP 字面量,默认放行。
步骤 3:制作自定义基座(最容易踩的坑)
UTS 插件不在标准基座里。直接「运行到手机」会报「当前基座不包含该插件」:
HBuilderX → 运行 → 运行到手机或模拟器 → 制作自定义调试基座
→ 选「打自定义调试基座」→ 等待打包完成 → 再选「运行到自定义基座」
改过 .kt / .swift 之后必须重新制作基座,热重载不会更新原生代码。
步骤 4:App.vue 里启动本地服务
// App.vue
// #ifdef APP-PLUS
import * as m3u8Downloader from '@/uni_modules/hy-m3u8-downloader'
// #endif
export default {
onLaunch() {
// #ifdef APP-PLUS
try {
if (m3u8Downloader.isSupport()) {
m3u8Downloader.startLocalServer({ port: 19685 })
}
} catch (e) {
console.log('[m3u8] startLocalServer failed', e)
}
// #endif
}
}
端口默认 19685,被占用会自动 +1 重试(最多 20 次),实际端口用 getLocalServerPort() 取。
步骤 5:页面里发起第一个下载
<template>
<view style="padding: 24rpx">
<button @click="start">开始下载</button>
<text>{{ status }} {{ progress }}%</text>
<button @click="play" :disabled="status !== 'completed'">播放</button>
</view>
</template>
<script setup>
import { ref, onUnmounted } from 'vue'
// #ifdef APP-PLUS
import * as m3u8Downloader from '@/uni_modules/hy-m3u8-downloader'
// #endif
const status = ref('none')
const progress = ref(0)
const M3U8_URL = 'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8'
const VIDEO_ID = 'ep01'
const start = () => {
// #ifdef APP-PLUS
// ① 先注册进度监听(单槽位,全局只有一份,页面销毁记得 off)
m3u8Downloader.onProgress((res) => {
if (res.videoId !== VIDEO_ID) return
status.value = res.status
progress.value = res.progress
if (res.error) console.log('失败:', res.error)
})
// ② 入队(同步返回是否入队成功)
const ok = m3u8Downloader.startDownload({
videoId: VIDEO_ID,
m3u8Url: M3U8_URL,
title: '大雄兔 第 1 集'
})
if (!ok) uni.showToast({ title: '入队失败', icon: 'none' })
// #endif
}
const play = () => {
// #ifdef APP-PLUS
m3u8Downloader.startLocalServer({ port: 19685 }) // 幂等
const url = m3u8Downloader.getLocalPlayUrl(VIDEO_ID)
if (!url) return uni.showToast({ title: '还没下完', icon: 'none' })
// url 形如 http://127.0.0.1:19685/ep01/playlist.m3u8
// #endif
}
onUnmounted(() => {
// #ifdef APP-PLUS
m3u8Downloader.offProgress()
// #endif
})
</script>
步骤 6:把地址丢给 <video>
<video :src="playUrl" :autoplay="true" controls object-fit="contain" />
到这里「下载 → 播本地」就通了。下面是可以直接照搬的完整业务实现。
完整示例:剧集缓存页
项目里的 pages/index/index.vue(以及插件自带的 example/m3u8-download-demo.vue)是一个完整 Demo,包含:
- 剧集列表(8 个公开免费测试流),逐集「缓存 / 暂停 / 继续 / 重试 / 播放」
- 实时进度条 + 已下载体积
- 手机存储总量、可用空间、本应用缓存占用量(带条形占比图)
- 缓存列表页:所有任务的进度、状态、体积,支持删除单集 / 一键清理
- App 重启后自动恢复缓存列表并标记为「已暂停」(点继续即续传)
- 非 App 平台自动降级为「模拟下载」,保证 H5 能预览 UI
可直接复制的片段见 example/m3u8-download-demo.vue,下面是它的骨架(省略样式):
<template>
<view class="page">
<!-- 存储概览 -->
<view class="card">
<text>可用 {{ pretty(availableBytes) }} / 共 {{ pretty(totalBytes) }}</text>
<text>缓存占用 {{ pretty(totalCache) }}({{ doneCount }}/{{ list.length }} 集)</text>
<button size="mini" @click="clearAll">一键清理</button>
</view>
<!-- 剧集 -->
<view v-for="ep in list" :key="ep.id" class="row">
<text>{{ ep.title }}</text>
<view class="bar"><view class="bar-in" :style="{ width: ep.progress + '%' }"></view></view>
<text>{{ ep.progress }}% · {{ pretty(ep.size) }}</text>
<view class="ops">
<text v-if="ep.status === 'none'" @click="download(ep)">缓存</text>
<text v-else-if="ep.status === 'downloading'" @click="pause(ep.id)">暂停</text>
<text v-else-if="ep.status === 'paused'" @click="resume(ep)">继续</text>
<text v-else-if="ep.status === 'failed'" @click="download(ep)">重试</text>
<text v-else @click="play(ep.id)">播放</text>
<text @click="remove(ep.id)">删除</text>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onUnmounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
// #ifdef APP-PLUS
import * as m3u8Downloader from '@/uni_modules/hy-m3u8-downloader'
// #endif
const INDEX_KEY = 'm3u8_cache_index' // 自维护的任务索引(插件没有 list 接口)
const list = ref([]) // 由 loadCacheList() 填充,见「场景 4」
const storage = ref({ availableBytes: 0, totalBytes: 0 })
const playerUrl = ref('') // 播放器弹层的 src
const pretty = (n) => {
n = Number(n) || 0
if (n < 1024) return n + ' B'
if (n < 1048576) return (n / 1024).toFixed(0) + ' KB'
if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB'
return (n / 1073741824).toFixed(2) + ' GB'
}
const totalCache = computed(() => list.value.reduce((s, x) => s + (x.size || 0), 0))
const doneCount = computed(() => list.value.filter((x) => x.status === 'completed').length)
/* ---------- 下载 ---------- */
const download = (ep) => {
// #ifdef APP-PLUS
if (storage.value.availableBytes > 0 && storage.value.availableBytes < 100 * 1024 * 1024) {
return uni.showToast({ title: '空间不足 100MB', icon: 'none' })
}
const ok = m3u8Downloader.startDownload({
videoId: ep.id, m3u8Url: ep.m3u8, title: ep.title
})
if (!ok) return uni.showToast({ title: '入队失败', icon: 'none' })
upsert({ ...ep, status: 'downloading', progress: 0, size: 0 })
// #endif
}
const pause = (id) => {
// #ifdef APP-PLUS
m3u8Downloader.pauseDownload(id)
patch(id, { status: 'paused' })
// #endif
}
// 继续 / 重试统一走 startDownload:App 重启后内存任务表丢了也能续传
const resume = (ep) => {
// #ifdef APP-PLUS
m3u8Downloader.startDownload({ videoId: ep.id, m3u8Url: ep.m3u8, title: ep.title })
patch(ep.id, { status: 'downloading', error: '' })
// #endif
}
const play = (id) => {
// #ifdef APP-PLUS
m3u8Downloader.startLocalServer({ port: 19685 })
const url = m3u8Downloader.getLocalPlayUrl(id)
if (!url) return uni.showToast({ title: '本地文件不存在', icon: 'none' })
playerUrl.value = url
// #endif
}
/* ---------- 删除 ---------- */
const remove = (id) => {
uni.showModal({
title: '删除缓存',
content: '删除后需重新下载才能离线播放',
confirmColor: '#ff6b6b',
success: (r) => {
if (!r.confirm) return
// #ifdef APP-PLUS
m3u8Downloader.deleteDownload(id)
// #endif
list.value = list.value.filter((x) => x.id !== id)
saveIndex()
refreshStorage()
}
})
}
const clearAll = () => {
if (!list.value.length) return
uni.showModal({
title: '一键清理',
content: `将删除全部缓存,释放约 ${pretty(totalCache.value)}`,
confirmColor: '#ff6b6b',
success: (r) => {
if (!r.confirm) return
list.value.forEach((x) => {
// #ifdef APP-PLUS
m3u8Downloader.deleteDownload(x.id)
// #endif
})
list.value = []
saveIndex()
refreshStorage()
}
})
}
/* ---------- 进度 ---------- */
const onProgress = (res) => {
const t = list.value.find((x) => x.id === res.videoId)
if (!t) return
t.status = res.status
t.progress = Math.round(res.progress || 0)
t.error = res.error || ''
// #ifdef APP-PLUS
t.size = m3u8Downloader.getCachedSize(res.videoId)
// #endif
if (res.status === 'completed') refreshStorage()
}
/* ---------- 索引 ---------- */
const saveIndex = () => {
uni.setStorageSync(INDEX_KEY, list.value.map((x) => ({
id: x.id, title: x.title, m3u8: x.m3u8
})))
}
const upsert = (item) => {
const i = list.value.findIndex((x) => x.id === item.id)
if (i >= 0) list.value[i] = { ...list.value[i], ...item }
else list.value.unshift(item)
saveIndex()
}
const patch = (id, obj) => {
const t = list.value.find((x) => x.id === id)
if (t) Object.assign(t, obj)
}
const refreshStorage = () => {
// #ifdef APP-PLUS
try {
const s = JSON.parse(m3u8Downloader.getStorageInfo())
if (s && s.totalBytes) storage.value = s
} catch (e) {}
// #endif
}
onLoad(() => {
// #ifdef APP-PLUS
m3u8Downloader.onProgress(onProgress)
// #endif
// list.value = loadCacheList() // 索引 × 插件状态,实现见下方「场景 4」
refreshStorage()
})
onUnmounted(() => {
// #ifdef APP-PLUS
m3u8Downloader.offProgress()
// #endif
})
</script>
完整版(含模拟数据降级、播放器弹层、状态合并)见项目根目录的
pages/index/index.vue。
常用场景代码
1. 单集下载 + 进度条
// #ifdef APP-PLUS
const id = 'ep01'
m3u8Downloader.onProgress((res) => {
if (res.videoId !== id) return
// status: pending | downloading | paused | completed | failed | none
progress.value = res.progress // 0 - 100
bytes.value = res.downloadedBytes
if (res.status === 'failed') uni.showToast({ title: res.error, icon: 'none' })
})
m3u8Downloader.startDownload({
videoId: id, // 同时是缓存目录名,只用字母数字_-
m3u8Url: 'https://cdn.example.com/ep01/index.m3u8',
title: '第 1 集'
})
// #endif
进度条(不依赖 UI 库):
<view class="bar">
<view class="bar-in" :style="{ width: progress + '%' }"></view>
</view>
2. 批量缓存
// #ifdef APP-PLUS
const videos = episodes.map((ep) => ({
videoId: ep.id,
m3u8Url: ep.m3u8,
title: ep.title
}))
const count = m3u8Downloader.startBatchDownload({ videos })
uni.showToast({ title: `已入队 ${count} 个`, icon: 'none' })
// #endif
全局最多 2 个任务并行,单任务内分片顺序下载(更稳、不易被 CDN 限流)。 剩余的排队任务状态是
pending,进度事件里能看到状态变化。
3. 暂停 / 继续 / 取消
// #ifdef APP-PLUS
m3u8Downloader.pauseDownload('ep01') // 当前分片下完后停
m3u8Downloader.resumeDownload('ep01') // 仅对「内存里还存在的」暂停/失败任务有效
m3u8Downloader.cancelDownload('ep01') // 停止并置为 none,已下载分片保留
m3u8Downloader.deleteDownload('ep01') // 停止并删除整个缓存目录
// #endif
⚠️ resumeDownload 内部是 tasks[id] ?: return,App 重启后内存任务表已丢失,它会静默什么都不做。
所以「继续 / 重试」推荐统一用 startDownload(同样会跳过已存在分片,等价于断点续传)。
4. 缓存列表(插件没有 list 接口)
插件不提供「列出全部任务」的 API(任务表是原生内存态)。标准做法:自己存一份索引,
列表打开时用 getDownloadStatus + getCachedSize 把真实状态和体积补回去。
// #ifdef APP-PLUS
const INDEX_KEY = 'm3u8_cache_index'
export const readIndex = () => uni.getStorageSync(INDEX_KEY) || []
export const writeIndex = (list) => uni.setStorageSync(INDEX_KEY, list)
/** 下载入队时把元信息记进来 */
export function remember(ep) {
const list = readIndex().filter((x) => x.id !== ep.id)
list.unshift({ id: ep.id, title: ep.title, m3u8: ep.m3u8, createdAt: Date.now() })
writeIndex(list)
}
/** 打开缓存页:索引 × 插件状态 */
export function loadCacheList() {
return readIndex()
.map((item) => {
let st = {}
try {
st = JSON.parse(m3u8Downloader.getDownloadStatus(item.id)) || {}
} catch (e) {}
const size = m3u8Downloader.getCachedSize(item.id)
const raw = st.status || 'none'
// 目录被外部清掉了 → 从列表里剔除
if (raw === 'none' && size <= 0) return null
// App 重启后原生任务表为空,状态会停在 downloading/pending,标成 paused 更符合直觉
const status = raw === 'downloading' || raw === 'pending' ? 'paused' : raw
return {
...item,
status: status === 'none' ? 'paused' : status,
progress: Math.round(st.progress || 0),
error: '',
size
}
})
.filter(Boolean)
.sort((a, b) => b.createdAt - a.createdAt)
}
// #endif
5. 手机存储与缓存总占用
// #ifdef APP-PLUS
// 手机整体存储(不是运行内存)
const { availableBytes, totalBytes } =
JSON.parse(m3u8Downloader.getStorageInfo())
// 单个任务占用
const one = m3u8Downloader.getCachedSize('ep01')
// 全部缓存占用
const total = readIndex().reduce(
(sum, item) => sum + m3u8Downloader.getCachedSize(item.id), 0
)
const usedPercent = ((totalBytes - availableBytes) / totalBytes) * 100
// #endif
下载前的空间拦截(插件也会返回错误码 9280301):
if (availableBytes < 300 * 1024 * 1024) {
return uni.showToast({ title: '可用空间不足 300MB', icon: 'none' })
}
6. 删除单集 / 一键清理
// 单集
m3u8Downloader.deleteDownload('ep01')
// 记得同步删掉自己的索引
writeIndex(readIndex().filter((x) => x.id !== 'ep01'))
// 一键清理
readIndex().forEach((item) => m3u8Downloader.deleteDownload(item.id))
writeIndex([])
删除后建议重新 getStorageInfo() 刷新一次,可用空间会立刻变化。
7. 离线播放
// #ifdef APP-PLUS
// ① 服务必须先起来(幂等,可重复调用)
m3u8Downloader.startLocalServer({ port: 19685 })
const port = m3u8Downloader.getLocalServerPort()
if (!port) return uni.showToast({ title: '本地服务启动失败', icon: 'none' })
// ② 拿本地地址(未下载完成 / 服务未启动时返回 null)
const url = m3u8Downloader.getLocalPlayUrl('ep01')
if (!url) return uni.showToast({ title: '请先下载完成', icon: 'none' })
// ③ 播放
playUrl.value = url // http://127.0.0.1:19685/ep01/playlist.m3u8
// #endif
可选:探活一次,避免「端口拿到了但服务没真正监听」(iOS 历史版本出现过):
uni.request({
url: `http://127.0.0.1:${port}/ping`,
success: () => console.log('本地服务正常'),
fail: () => console.log('本地服务未就绪')
})
<video :src="playUrl" :autoplay="true" controls object-fit="contain" @error="onVideoError" />
8. App 重启后续传
任务表是内存态,重启后丢失;但分片和 task.json 都在磁盘上。用同样的 videoId 再调一次即可:
// #ifdef APP-PLUS
// 重启后:状态能读出来(可能是 paused / completed),但任务不在内存表里
const st = JSON.parse(m3u8Downloader.getDownloadStatus('ep01'))
// 继续下载:不要用 resumeDownload,直接 startDownload
m3u8Downloader.startDownload({
videoId: 'ep01',
m3u8Url: 'https://cdn.example.com/ep01/index.m3u8', // 必须再传一次
title: '第 1 集'
})
// #endif
9. vue2(options API)写法
// #ifdef APP-PLUS
import * as m3u8Downloader from '@/uni_modules/hy-m3u8-downloader'
// #endif
export default {
data() {
return { tasks: {}, storage: { availableBytes: 0, totalBytes: 0 } }
},
onLoad() {
// #ifdef APP-PLUS
m3u8Downloader.onProgress(this.onProgress)
this.refreshStorage()
// #endif
},
onUnload() {
// #ifdef APP-PLUS
m3u8Downloader.offProgress()
// #endif
},
methods: {
onProgress(res) {
// 保证 videoId 对应的对象是响应式的:用 $set
this.$set(this.tasks, res.videoId, { ...res })
},
refreshStorage() {
// #ifdef APP-PLUS
this.storage = JSON.parse(m3u8Downloader.getStorageInfo())
// #endif
},
download(ep) {
// #ifdef APP-PLUS
m3u8Downloader.startDownload({ videoId: ep.id, m3u8Url: ep.m3u8, title: ep.title })
// #endif
}
}
}
API 参考
所有方法同步返回,下载进度通过 onProgress 事件回传。非 App 平台调用返回 false / null / 0 / '{}',不抛错。
| 方法 | 签名 | 说明 |
|---|---|---|
isSupport |
() => boolean |
当前平台是否支持 |
startLocalServer |
(options?: { port?: number }) => boolean |
启动本地 HTTP 服务,默认端口 19685,被占用自动 +1(最多试 20 次) |
stopLocalServer |
() => void |
停止服务 |
getLocalServerPort |
() => number |
当前端口,未启动返回 0 |
startDownload |
(options: { videoId, m3u8Url, title? }) => boolean |
开始下载;已下载完成的再次调用直接复用 |
startBatchDownload |
(options: { videos: StartDownloadOptions[] }) => number |
批量入队,返回入队数量(全局最多 2 个并行) |
pauseDownload |
(videoId: string) => void |
暂停(当前分片下完后停) |
resumeDownload |
(videoId: string) => void |
继续(已存在分片自动跳过;仅内存中存在的任务) |
cancelDownload |
(videoId: string) => void |
取消,保留已下载分片 |
deleteDownload |
(videoId: string) => void |
取消并删除本地目录 |
getDownloadStatus |
(videoId: string) => string |
同步查状态,返回 JSON 文本;无任务时 status: 'none' |
getLocalPlayUrl |
(videoId: string) => string \| null |
本地播放地址;未完成或服务未启动返回 null |
getCachedSize |
(videoId: string) => number |
单任务占用字节数 |
getStorageInfo |
() => string |
返回 JSON 文本 { availableBytes, totalBytes } |
onProgress |
(cb: (res: DownloadProgress) => void) => void |
注册进度监听(单槽位,后注册会覆盖前者) |
offProgress |
(cb?: Function) => void |
移除监听(任何调用都会清空当前监听) |
StartDownloadOptions
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
videoId |
string | ✅ | 唯一 ID,同时作为缓存目录名;建议只用字母、数字、_、- |
m3u8Url |
string | ✅ | 播放地址,支持 master playlist(自动选最高码率) |
title |
string | ❌ | 仅用于展示 / 日志 |
m3u8Downloader.startDownload({
videoId: 'ep01',
m3u8Url: 'https://cdn.example.com/ep01/index.m3u8',
title: '第 1 集'
})
返回值速查
m3u8Downloader.isSupport() // true / false
m3u8Downloader.startLocalServer({ port: 19685 }) // true / false
m3u8Downloader.getLocalServerPort() // 19685(未启动为 0)
m3u8Downloader.startDownload({ ... }) // true / false(入队是否成功)
m3u8Downloader.startBatchDownload({ videos }) // 数字:实际入队数量
m3u8Downloader.getDownloadStatus('ep01') // JSON 字符串
m3u8Downloader.getLocalPlayUrl('ep01') // 'http://127.0.0.1:19685/ep01/playlist.m3u8' | null
m3u8Downloader.getCachedSize('ep01') // 字节数
m3u8Downloader.getStorageInfo() // '{"availableBytes":..,"totalBytes":..}'
数据结构
DownloadProgress(进度事件)
| 字段 | 类型 | 说明 |
|---|---|---|
videoId |
string | 任务 ID |
status |
string | none / pending / downloading / paused / completed / failed |
progress |
number | 0–100(按「已完成分片数 / 总分片数」算) |
downloadedBytes |
number | 已下载字节 |
totalBytes |
number | 总字节(未做全量预扫,通常为 0,仅用于展示) |
error |
string | 失败文案(含错误码),成功 / 进行中为空串 |
DownloadStatus(getDownloadStatus 解析后)
| 字段 | 类型 | 说明 |
|---|---|---|
videoId |
string | 任务 ID |
status |
string | 同上 |
progress |
number | 0–100 |
downloadedBytes / totalBytes |
number | 字节数 |
title |
string | 入队时的标题 |
error |
string | 失败文案 |
let st = {}
try {
st = JSON.parse(m3u8Downloader.getDownloadStatus('ep01')) || {}
} catch (e) {}
if (st.status === 'completed') { /* ... */ }
StorageInfo(getStorageInfo 解析后)
| 字段 | 类型 | 说明 |
|---|---|---|
availableBytes |
number | 手机可用存储空间 |
totalBytes |
number | 手机总存储空间 |
本地文件结构
<沙盒>/m3u8_videos/<videoId>/
├── playlist.m3u8 # 重写后的本地 playlist(分片已解密,不再包含 #EXT-X-KEY)
├── segment_000.ts # 分片,序号 3 位补零
├── segment_001.ts
├── init_mp4 # 仅 fMP4(#EXT-X-MAP)源才有
└── task.json # 任务状态快照(重启后仍可查)
- Android:
getExternalFilesDir()/m3u8_videos(卸载 App 即清理,不需要存储权限) - iOS:
Documents/m3u8_videos(开了UIFileSharingEnabled可从 Finder 拷出)
错误码
9 位分段:9 | 模块 28 | 类别 | 序号。
| 码 | 含义 | 业务侧建议 |
|---|---|---|
| 9280001 | 缺少必填参数 | 检查 videoId / m3u8Url 是否为空 |
| 9280002 | videoId 或 m3u8Url 不合法 | 同上 |
| 9280003 | 本地播放服务未启动 | 先 startLocalServer |
| 9280004 | 端口被占用 | 换端口(内部已自动 +1 重试 20 次) |
| 9280101 | 任务已取消 | 无需提示 |
| 9280102 | 任务不存在 | 忽略 |
| 9280201 | 网络请求失败 | 提示重试;确认 m3u8 可直连(防盗链需把 token 拼进 URL) |
| 9280202 | m3u8 解析失败 | 可能返回的是 HTML 错误页,或直播流(无 EXT-X-ENDLIST) |
| 9280203 | 分片解密失败 | key / IV 不匹配,或源用了 SAMPLE-AES / DRM |
| 9280204 | 分片下载不完整 | 重试 |
| 9280301 | 存储空间不足 | 下载前用 getStorageInfo 拦一道 |
| 9280302 | 本地文件写入失败 | 检查磁盘 / 目录权限 |
| 9280401 | 当前平台不支持 | 用 isSupport() 隐藏入口 |
error 字段是「文案 + 错误码」的组合串,直接 toast 即可。
常见坑与 FAQ
Q:编译报「当前基座不包含该插件」? 用标准基座运行了。UTS 插件必须制作自定义调试基座(见步骤 3),改过原生代码后要重制。
Q:编译报 "default" is not exported by "...?uts-proxy"?
用了默认导入。改成 import * as m3u8Downloader from '@/uni_modules/hy-m3u8-downloader'。
Q:编译报找不到模块 @/uni_modules/xxx?
导入路径要写 uni_modules 下的目录名,不是 package.json 里的 id。两者不一致时以目录名为准。
Q:下载完成但播放黑屏?
先确认 getLocalServerPort() 不是 0(服务没起来时 getLocalPlayUrl 返回 null);
Android 再确认已放开明文流量(步骤 2)。
Q:getDownloadStatus 返回 status: 'none',但文件明明在?
内存任务表为空(App 重启过)且 task.json 不完整时会出现。用同样的 videoId 调 startDownload 即可重新认领并续传。
Q:为什么进度会从 0 直接跳到 100?
分片很少时上报比较跳;progress 按分片数量算,进度事件有 300ms 节流。
Q:能同时下几个? 全局最多 2 个并行,单任务内分片顺序下载。
Q:totalBytes 一直是 0?
不做全量预扫(要先把整个 playlist 全请求一遍,代价大)。体积请用 getCachedSize。
Q:m3u8 带防盗链(Referer / Cookie)?
当前实现只带 User-Agent。要么把签名拼进 URL,要么改原生代码:
Android M3U8DownloaderBridge.kt 的 fetchBytes、iOS M3U8DownloaderBridge.swift 的 fetchData 里加请求头。
Q:支持加密视频吗?
支持标准 HLS 的 METHOD=AES-128(key 明文可取)。不支持 SAMPLE-AES(FairPlay / Widevine 等 DRM),这类源即使下完也放不了。
Q:支持直播吗?
不支持。只处理点播(末尾带 #EXT-X-ENDLIST);直播 playlist 一直在变,没有「下载完成」的概念。
Q:H5 上能跑吗?
不能。isSupport() 返回 false,所有接口安全降级。业务上用 isSupport() 隐藏入口,
或用 // #ifdef APP-PLUS 包住调用;要做到「H5 也能看 UI」,自己加一层模拟数据(参考 pages/index/index.vue)。
Q:缓存目录在哪?能导出吗?
Android 在 getExternalFilesDir()/m3u8_videos(需 adb 或文件管理器),iOS 在 Documents/m3u8_videos,
开了 UIFileSharingEnabled 可直接从 Finder 拷出。
参考
更新日志
见 changelog.md。

收藏人数:
购买普通授权版(
试用
赞赏(0)
下载 16
赞赏 0
下载 12742089
赞赏 1959
赞赏
京公网安备:11010802035340号