更新记录

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 / iOS Network.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。

隐私、权限声明

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

Android:INTERNET(下载分片)、ACCESS_NETWORK_STATE;iOS:无额外权限(仅 App 沙盒内文件)

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

本插件仅在本机完成视频分片下载与本地播放,不上传任何数据;插件会读取目标 m3u8 地址及其分片/key(仅用户主动传入的地址)。

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

无

暂无用户评论。