Skip to content

feat: 添加离线视频下载和缓存功能#1682

Merged
Predidit merged 21 commits intoPredidit:mainfrom
0Chencc:feat/offline-download
Feb 5, 2026
Merged

feat: 添加离线视频下载和缓存功能#1682
Predidit merged 21 commits intoPredidit:mainfrom
0Chencc:feat/offline-download

Conversation

@0Chencc
Copy link
Contributor

@0Chencc 0Chencc commented Jan 31, 2026

基于此前我实现的对广告的过滤,我实现了完整的视频缓存功能.

在此版本下,我们可以在点击剧集后,选集对视频进行缓存.

在设置页面->下载管理访问后,可以查看下载进度,并直接播放.

同时在已经下载的内容中,可以在正常的播放窗口直接使用缓存.

这个版本的代码可能需要review,我觉得这个功能很有意思所以迫不及待提起pr,目前我已经在本地测试有效

play setting_download_page setting_download_page_detail

摘要

实现完整的M3U8/HLS及直接视频文件离线下载功能,支持批量剧集选择、下载管理界面及本地播放支持。

新增文件:

  • Hive模型:DownloadRecord(类型ID:7)、DownloadEpisode(类型ID:8)
  • M3U8解析器:主媒体播放列表、加密密钥、URL解析
  • 广告过滤器:基于不连续组的广告检测与移除
  • 下载引擎:并发分段下载、暂停/续传、重试机制
  • 支持基于Range的续传功能的直接文件下载回退方案
  • 下载控制器:基于WebView的URL解析、批量调度
  • 下载管理页面:进度显示、暂停/续传/重试/删除
  • 视频页批量剧集选择表
  • 轻量级本地视频播放器页面(media_kit)

修改文件:

  • storage.dart:注册Hive适配器,开启下载箱
  • index_module.dart:注册DownloadController与IDownloadRepository
  • init_page.dart:启动时初始化下载控制器
  • settings_module.dart:新增/download路由
  • my_page.dart:新增下载管理入口
  • video_page.dart:新增下载按钮及单集状态图标
  • player_controller.dart:检测并使用本地缓存进行播放

Implement complete M3U8/HLS and direct video file offline download
with batch episode selection, download management UI, and local
playback support.

New files:
- Hive models: DownloadRecord (TypeID:7), DownloadEpisode (TypeID:8)
- M3U8 parser: master/media playlist, encryption keys, URL resolution
- Ad filter: discontinuity-group based ad detection and removal
- Download engine: concurrent segment download, pause/resume, retry
- Direct file download fallback with Range-based resume support
- Download controller: WebView-based URL resolution, batch orchestration
- Download management page with progress, pause/resume/retry/delete
- Batch episode selection sheet from video page
- Lightweight local video player page (media_kit)

Modified files:
- storage.dart: register Hive adapters, open downloads Box
- index_module.dart: register DownloadController and IDownloadRepository
- init_page.dart: initialize download controller on startup
- settings_module.dart: add /download route
- my_page.dart: add download management entry
- video_page.dart: add download button and per-episode status icons
- player_controller.dart: detect and use local cache for playback
…starting

- Extract buildFullUrl() and buildHttpHeaders() into Plugin class to
  eliminate duplicated logic in DownloadController
- Add IDownloadManager interface and DownloadRequest data class to
  replace 9+ parameter methods and enable proper DI
- Remove DownloadManager factory singleton, register via Modular DI
- Move forceAdBlocker setting access into IDownloadRepository
- Fix bug where _processQueue() was a no-op that silently discarded
  queued downloads when parallel limit was reached
@Predidit
Copy link
Owner

Predidit commented Feb 1, 2026

我们可以有限考虑上面提到的逻辑上的问题

UI/UX 问题可以之后解决,主要是现在此功能的 UX 不适合移动端,误触概率很高

将 Windows 平台的 WebviewController 替换为 HeadlessWebview,
避免在无 UI 的 M3U8 解析场景下产生内存泄漏。可见 Webview Widget
替换为占位 UI,WebView 完全在后台运行。
部分视频源将实际分片嵌套在 M3U8 引用中。新增 resolveNestedSegments()
递归展开嵌套引用,使下载管线能获取到真实的 TS 分片。
覆盖 master/media playlist 解析、VOD 检测、EVENT 流拒绝及嵌套
M3U8 分片展开,使用 Mux 和 Apple 的公开 HLS 测试流。
此前解析器仅靠 ENDLIST 和兜底逻辑推断 VOD,未显式识别
PLAYLIST-TYPE:VOD 标签,与注释描述不一致。新增 isExplicitVod
标志并补充对应测试用例。
@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 2, 2026

对于此前提到的内容里目前只有ui没有做优化,我们可以在功能测试完毕后,我来提交ui的改动

对于Windows Webview控制器改动HeadlessWebview,我目前没有Windows设备进行测试,完整的测试我会在两天后进行测试.

之前确实遗漏了 #EXT-X-PLAYLIST-TYPE:VOD
的显式处理——注释里写了三种判定条件,但代码只实现了两种,VOD
标签被直接忽略了,靠的是兜底逻辑(有分片且非 EVENT)恰好产生正确结果。

已在 29766e6 中修复:解析器现在显式检测 PLAYLIST-TYPE:VOD 标签,加入了
isExplicitVod 标志。测试用例也补上了,包括"有 VOD 标签但无 ENDLIST"和"有 VOD
标签但零分片"两种边界情况,可以用 dart run test/m3u8_parser_test.dart
跑一下验证。

另外测试中用了两个公开的 HLS 测试流可以做端到端验证:

@Predidit
Copy link
Owner

Predidit commented Feb 3, 2026

在使用操作符重载修复 m3u8Key 类的比较问题并解决嵌套 m3u8 后,在我看来这个 m3u8 解析实现已经没有问题,新增了一条关于 windows headless webview 的修改建议

关于后续的下载功能的 UX 问题,也就是移动端容易误触选集按钮本身和上面的下载按钮,我觉得我们可以进行分离

在在 video_page.dart 中使用一个 FloatingActionsButton 或是在播放控制面板上加一个按钮,点开之后使用 bottomSheet 展开一个新的分集列表,可以在那个分集列表里点击按钮进行下载,并且可以考虑多选

@Predidit
Copy link
Owner

Predidit commented Feb 3, 2026

#1666 合并后存在合并冲突需要修复

disposeEnvironment() 内部已通过 headless_instances_.clear() 销毁
所有 headless 实例,移除额外的 headlessWebview.dispose() 调用,
避免两个异步方法通道调用产生竞争导致异常。

经原生 C++ 代码验证:disposeEnvironment 会依次清理所有实例和环境,
不存在双重释放问题;保留 disposeEnvironment 调用以支持代理配置热更新。
@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 3, 2026

关于你提到的"修改前大片注释"和"双重释放崩溃"的问题,我对 webview_windows fork
的原生 C++ 代码进行了彻查。

原注释说的是 WebviewController.dispose() 和
WebviewController.disposeEnvironment()
不能同时调用,因为两者会争抢同一份原生资源。但现在换成 HeadlessWebview
后情况不同:

  • headlessWebview.dispose() 发送 disposeHeadless,从 headless_instances_ map
    中 erase 对应条目
  • disposeEnvironment() 发送 disposeEnvironment,执行
    headless_instances_.clear() + webview_host_.reset()

由于 erase 会物理移除条目,后续 clear
不会重复销毁已移除的条目,理论上不存在双重释放。但这里有个隐患:两者都是 async
但 dispose() 签名是 void,不 await 的话两个方法通道调用可能竞争。如果
disposeEnvironment 先到达原生侧清掉了实例,随后 disposeHeadless 找不到条目会
invalid_id 异常。

最终方案是只调 disposeEnvironment(),不调 headlessWebview.dispose()。理由:

  1. disposeEnvironment 内部已通过 clear() 销毁所有实例,无需重复调用
  2. 消除竞争条件
  3. 保留 disposeEnvironment 调用以支持代理配置热更新(这是我们相比 abb9f62
    额外加的功能)

已在 8626ca4 中提交修复,代码中也加了注释说明原因

解决与 PR Predidit#1666 (定时关闭功能) 的 import 冲突
@Predidit
Copy link
Owner

Predidit commented Feb 3, 2026

我们为什么不再进行 headlessWebview 对象是否为空的判断,而是在任何情况下都为其赋值为空并执行 disposeEnvironment

@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 3, 2026

我们为什么不再进行 headlessWebview 对象是否为空的判断,而是在任何情况下都为其赋值为空并执行 disposeEnvironment

  1. 简化逻辑:既然原生侧已经处理了未初始化的情况,Dart 侧不需要重复判断
  2. 确保代理配置可重置:disposeEnvironment() 的另一个作用是释放共享的 WebView2
    环境,以便下次 init()时可以重新配置代理。如果加了条件判断,可能会遗漏某些边界情况.

不过为了代码一致性,可以改为

  if (headlessWebview != null) {
    headlessWebview = null;
  }
  WebviewController.disposeEnvironment();

@Predidit
Copy link
Owner

Predidit commented Feb 3, 2026

你是对的,我们保留当前无判断的状态

这样逻辑层面就不再有任何问题,只剩下UI需要处理

关于UI方面,你有什么想法吗,重新实现一个完整的离线播放器似乎会产生大量重复代码

@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 3, 2026

UI的构建我正在本地测试,我在思考怎么用一个比较优雅的方式来完成这个工作.

@Predidit
Copy link
Owner

Predidit commented Feb 3, 2026

我现在意识到一个烦人的架构问题

webview_controller 的本质是页面控制器,本身也是全局单例,在当前用例下,我们把这个全局单例控制器注入到了离线下载模块中并调用

这里可能导致严重的状态污染,也就是有下载任务在执行时,观看新的在线视频会导致 webview 状态混乱

考虑到播放器的困境,我在想我们能不能一劳永逸解决这一问题,由于当前 webview_controller 已经都是无头 webview 实现,不再需要页面上的小部件作为宿主,我在考虑能不能将其抽象为一个单一的 Provider ,这个 Provider 的数据可以来自由 webview 实现的解析器,也可以来自缓存

@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 3, 2026

没有问题,我思考一下这个架构应该怎么做,正因为这个问题我才没有马上完善离线播放器,因为我认为这应该涉及的改动比较大,所以我不敢草率去做这个变动

@ErBWs
Copy link
Contributor

ErBWs commented Feb 3, 2026

我没记错的话还有一个阻碍是 ohos 无法使用无头 webview

在播放器右上角三点菜单中添加"下载"子菜单,包含:
- 下载当前集:一键下载正在播放的集数
- 批量下载:打开选集下载面板进行多选

此改动将下载功能与选集按钮分离,优化移动端用户体验,
避免误触选集按钮上方的下载按钮。
@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 3, 2026

对于下载的入口,我进行了调整.

效果如下:

  1. 播放任意视频,点击右上角三点菜单(⋮)
  2. 确认"下载"子菜单出现在"外部播放"和"定时关闭"之间
  3. 点击"下载当前集" — 应提示"已添加到下载队列"
  4. 再次点击"下载当前集" — 应提示"当前集已在下载列表中"
  5. 点击"批量下载" — 应打开选集下载 BottomSheet

@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 3, 2026

还有一个阻碍是 ohos 无法使用无头 webview

如果是这样的话,可能只能对鸿蒙的适配做一个降级处理.

我们可以在 ohos 的 VideoPage 或某个全局容器中保持一个隐藏WebView Widget,供下载模块使用。这样:

  • 下载功能可正常工作
  • 与主仓库的架构差异最小
  • 代价是额外的隐藏 Widget 资源

@ErBWs 你觉得这样的实现怎么样?

@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 3, 2026

我先将这个ohos的适配性改动上传,因为我没有任何鸿蒙相关的设备,所以无法调试.

@ErBWs
Copy link
Contributor

ErBWs commented Feb 3, 2026

ohos 的 videopage 目前一直额外存在一个 webview 组件。

我已经很久没有碰过 kazumi 的代码了,有点忘记播放器的具体实现了。我这几天也会看一下代码看看有没有比较好的解决方案

@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 3, 2026

ohos 的 videopage 目前一直额外存在一个 webview 组件。

我已经很久没有碰过 kazumi 的代码了,有点忘记播放器的具体实现了。我这几天也会看一下代码看看有没有比较好的解决方案

好嘞!

- 新增 lib/providers/ 模块:
  - IVideoSourceProvider 接口定义视频源解析抽象
  - WebViewVideoSourceProvider 封装 headless WebView 解析
  - CachedVideoSourceProvider 支持本地缓存视频读取
  - CompositeVideoSourceProvider 组合策略(优先缓存)

- VideoPageController 重构:
  - 原生播放器模式使用独立 Provider 实例
  - WebView 渲染模式保持使用共享 Controller
  - 支持取消正在进行的解析操作

- DownloadController 重构:
  - 使用 WebViewVideoSourceProvider 替代直接操作 WebView
  - 解决与 VideoPage 共享 WebView 单例导致的状态冲突

此重构确保 VideoPage 和 DownloadController 的视频解析相互独立,
避免同时下载和播放时的状态污染问题。
@Predidit
Copy link
Owner

Predidit commented Feb 4, 2026

这应该是我最后建议的变更了,这个实现现在实在是太棒了

我有两个问题

  1. 我们保留基于位置的匹配作为回退是基于什么考虑,用于应付什么特殊情况,因为我没有想到可能需要回退的情况。

  2. 这个 pr 其实有一个重要的后续工作,也就是在此 pr 合并后实现 Android 的后台下载,这对移动端体验来说是有必要的,在那时你有兴趣继续进行相关工作吗。

@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 4, 2026

这应该是我最后建议的变更了,这个实现现在实在是太棒了

我有两个问题

  1. 我们保留基于位置的匹配作为回退是基于什么考虑,用于应付什么特殊情况,因为我没有想到可能需要回退的情况。
  2. 这个 pr 其实有一个重要的后续工作,也就是在此 pr 合并后实现 Android 的后台下载,这对移动端体验来说是有必要的,在那时你有兴趣继续进行相关工作吗。
  1. 保留基于位置的匹配作为回退是因为我在实际测试遇到的一个bug,我尝试一次性下载多个视频,但是由于下载调起的延迟问题可能会出现这样的情况:

同时下载5集,完成了第一集,第二集,第三集,第五集。
我在点击播放第五集时会出现索引问题而产生红屏报错。保留基于位置的匹配回退就是为了处理这个情况的。这在我实际测试中可以有效解决这个问题

  1. 没有问题,我很愿意参与

…_inappwebview

将低版本 Android 的 WebView 回退实现从 webview_flutter 迁移到
flutter_inappwebview 的 PlatformHeadlessInAppWebView,使其兼容
WebViewVideoSourceProvider 的 headless 架构。使用 evaluateJavascript()
在 onLoadStart/onLoadStop 回调中注入脚本替代 UserScript AT_DOCUMENT_START,
同时移除 webview_flutter 依赖。
@Predidit
Copy link
Owner

Predidit commented Feb 4, 2026

啊,这让我想到另一个问题,如果我们在正常在线播放流程中使用了缓存,我们是否应该显示一个 toast 提醒使用者,这样即使当他们遇到我们考虑不周全导致的缓存错位时,也可以删除缓存

而不是认为解析环节出了问题

@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 4, 2026

啊,这让我想到另一个问题,如果我们在正常在线播放流程中使用了缓存,我们是否应该显示一个 toast 提醒使用者,这样即使当他们遇到我们考虑不周全导致的缓存错位时,也可以删除缓存

而不是认为解析环节出了问题

可以,本pr中虽然 CachedVideoSourceProvider 和 CompositeVideoSourceProvider的代码已经写好了,但它们是死代码——在线播放路径(video_controller.dart:228)始终只实例化,WebViewVideoSourceProvider,从不检查本地是否有已下载的集数。

我接下来提交一个改动来应用这个功能,并且同时加入toast

@Predidit
Copy link
Owner

Predidit commented Feb 4, 2026

抱歉这个pr太庞大了,我有些看晕了,先不要启用这些死代码,暂时移除他们,作为后续pr添加,这个pr的规模已经太大了。

@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 4, 2026

抱歉这个pr太庞大了,我有些看晕了,先不要启用这些死代码,暂时移除他们,作为后续pr添加,这个pr的规模已经太大了。

没问题,我也是觉得太庞大了所以我不敢一次性搞定 :)

@Predidit
Copy link
Owner

Predidit commented Feb 4, 2026

实际上我现在就想到一个位置索引错位导致的缺陷,并且不会因为抛出异常而进行回退

也就是我们同时下载1到5集,第2集未完成,播放器播放第2集会变成第3集

你对此问题有什么看法吗,还是我的理解出现了问题

@Predidit
Copy link
Owner

Predidit commented Feb 4, 2026

我们移除上面提到的死代码和我最后的关于位置疑问后就可以结束这个pr

如果已经完成了 Android 低版本回退代码路径测试的话

@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 4, 2026

实际上我现在就想到一个位置索引错位导致的缺陷,并且不会因为抛出异常而进行回退

也就是我们同时下载1到5集,第2集未完成,播放器播放第2集会变成第3集

你对此问题有什么看法吗,还是我的理解出现了问题

不会,目前如果遇到这样的情况:第一集,第二集,第三集,第四集(未完成下载),第五集。在离线播放器的选集会显示第一集,第二集,第三集,第五集,不会显示第四集,这样也方便用户可以清楚地看到第四集并未完成下载。

是如实显示,并不会错误地将第五集显示为第四集。

移除未使用的 CachedVideoSourceProvider 和 CompositeVideoSourceProvider,
清理 providers.dart 的 export,减少 PR 中的死代码便于 review。
@0Chencc 0Chencc force-pushed the feat/offline-download branch from 90bc687 to 0945243 Compare February 4, 2026 17:28
@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 4, 2026

稍等,我测试一下android 10版本的回退。

在android10的调试下我定位到一个问题,我明天会来尝试修复

在 onLoadStart 注入的 blob parser 和 iframe redirect 脚本中,
将顶层 callHandler 日志调用包裹 try-catch,防止 bridge 未初始化
时整个脚本崩溃导致 M3U8 拦截失效。
@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 5, 2026

在实际测试中,我还发现一个问题:

  1. 请求 index.json 超时 → DioException
  2. Request.get() (lib/request/request.dart:143-155) 捕获异常,不抛出,而是返回一个伪造的成功 Response:
    Response errResponse = Response(
    data: {'message': '响应超时,请稍后重试!'}, // ← Map<String, String>
    statusCode: 200,
    );
    return errResponse;
  3. getPluginList() 拿到这个 Response,调用 json.decode(res.data) — 但 res.data 此时是 Map<String, String>,不是 JSON
    字符串
  4. 抛出 type '_Map<String, String>' is not a subtype of type 'String'

本质问题:Request.get() 把网络错误包装成假的 200 Response,下游调用者不知道请求已失败,按正常数据处理就会类型错误。

这个改动并不在我们这个pr里,当时设计这个机制是为了什么?我们需要修复这个问题吗?

在体验上我看到客户端的状态是持续加载。

@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 5, 2026

当前最新的改动下,我在pixel 4 + android10的环境下测试,所有功能均正常。

@Predidit Predidit merged commit 85dd497 into Predidit:main Feb 5, 2026
6 checks passed
@Predidit
Copy link
Owner

Predidit commented Feb 5, 2026

@0Chencc

主要是在当前的网络请求架构下,参考 interceptor.dart 的实现

非200的请求会自动触发一个弹窗,我们有时不想要那个弹窗

当然网络请求架构现在有待商榷,如果你有兴趣的话,可以进行修改

@Predidit
Copy link
Owner

Predidit commented Feb 5, 2026

@0Chencc

我已经完成了这个PR后续的代码清理和部分错误修复,如果测试中出现了新的问题请及时告诉我

此外我注意到 android 后台下载的实现似乎相当麻烦,你对这个功能现在大概有什么方案和看法呢

@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 5, 2026

@0Chencc

我已经完成了这个PR后续的代码清理和部分错误修复,如果测试中出现了新的问题请及时告诉我

此外我注意到 android 后台下载的实现似乎相当麻烦,你对这个功能现在大概有什么方案和看法呢

Foreground Service + 通知这样的方案如何?
优点: 可靠、用户可见进度、可暂停/取消、与现有逻辑集成容易
缺点: 必须显示通知(Android 8+ 要求)

@Predidit
Copy link
Owner

Predidit commented Feb 5, 2026

这也是我在考虑的方案,我们不需要考虑 Android8 以下的兼容性,我们在引入 vulkan 之后已经不再能在 Android8 以下设备上运行

@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 5, 2026

是的,我就是之前注意到你在其他issue提到的不再支持Android8以下,所以我觉得使用这个不错?
要来开始尝试这个改动吗

@Predidit
Copy link
Owner

Predidit commented Feb 5, 2026

如果你有兴趣和时间的话,欢迎提交PR,我现在需要处理一些这个PR的收尾问题,例如超时处理和日志处理

@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 5, 2026

没问题

@Predidit
Copy link
Owner

Predidit commented Feb 7, 2026

@0Chencc

Hi, 只是想询问一个后续的问题

我们的为低版本 android 准备的回退 webview 实现的 JS 部分是怎么实现的,似乎和之前的实现不同,这部分代码似乎解析成功率不是特别高

@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 7, 2026

@0Chencc

Hi, 只是想询问一个后续的问题

我们的为低版本 android 准备的回退 webview 实现的 JS 部分是怎么实现的,似乎和之前的实现不同,这部分代码似乎解析成功率不是特别高

我在标准的download以外做了一个回退机制

这个特性不取决于 Android 系统版本,而是取决于设备上安装的 WebView APK 版本。 Android 的 System WebView是可以独立于系统更新的(通过 Google Play / 应用商店升级)。DOCUMENT_START_SCRIPT 需要 Chromium WebView 91+(约 2021 5 月发布)。
所以会触发回退的场景是:

  • 设备的 System WebView(或 Chrome,如果用作 WebView Provider)版本低于 91
  • 这在老旧设备、不联网的设备、或没有 Google Play 的设备上比较常见
  • 比如一台 Android 10 的设备,如果从未更新过 WebView,系统自带的可能只有 80 多版本,就会走回退路径

简单说:不是 Android 系统版本低,而是 WebView 组件版本低,导致缺少 DOCUMENT_START_SCRIPT 能力,从而走到了只能用evaluateJavascript() 手动注入的回退实现,产生时机竞争和跨域 iframe 无法注入的问题。

ErBWs pushed a commit to ErBWs/Kazumi that referenced this pull request Feb 7, 2026
* feat: add offline video download and caching

Implement complete M3U8/HLS and direct video file offline download
with batch episode selection, download management UI, and local
playback support.

New files:
- Hive models: DownloadRecord (TypeID:7), DownloadEpisode (TypeID:8)
- M3U8 parser: master/media playlist, encryption keys, URL resolution
- Ad filter: discontinuity-group based ad detection and removal
- Download engine: concurrent segment download, pause/resume, retry
- Direct file download fallback with Range-based resume support
- Download controller: WebView-based URL resolution, batch orchestration
- Download management page with progress, pause/resume/retry/delete
- Batch episode selection sheet from video page
- Lightweight local video player page (media_kit)

Modified files:
- storage.dart: register Hive adapters, open downloads Box
- index_module.dart: register DownloadController and IDownloadRepository
- init_page.dart: initialize download controller on startup
- settings_module.dart: add /download route
- my_page.dart: add download management entry
- video_page.dart: add download button and per-episode status icons
- player_controller.dart: detect and use local cache for playback

* refactor: decouple DownloadController and fix queued downloads never starting

- Extract buildFullUrl() and buildHttpHeaders() into Plugin class to
  eliminate duplicated logic in DownloadController
- Add IDownloadManager interface and DownloadRequest data class to
  replace 9+ parameter methods and enable proper DI
- Remove DownloadManager factory singleton, register via Modular DI
- Move forceAdBlocker setting access into IDownloadRepository
- Fix bug where _processQueue() was a no-op that silently discarded
  queued downloads when parallel limit was reached

* refactor: Windows WebView 控制器改用 HeadlessWebview

将 Windows 平台的 WebviewController 替换为 HeadlessWebview,
避免在无 UI 的 M3U8 解析场景下产生内存泄漏。可见 Webview Widget
替换为占位 UI,WebView 完全在后台运行。

* feat: 下载前展开嵌套 M3U8 播放列表

部分视频源将实际分片嵌套在 M3U8 引用中。新增 resolveNestedSegments()
递归展开嵌套引用,使下载管线能获取到真实的 TS 分片。

* test: 添加 M3U8 解析器验证测试

覆盖 master/media playlist 解析、VOD 检测、EVENT 流拒绝及嵌套
M3U8 分片展开,使用 Mux 和 Apple 的公开 HLS 测试流。

* fix: 显式处理 #EXT-X-PLAYLIST-TYPE:VOD 标签

此前解析器仅靠 ENDLIST 和兜底逻辑推断 VOD,未显式识别
PLAYLIST-TYPE:VOD 标签,与注释描述不一致。新增 isExplicitVod
标志并补充对应测试用例。

* fix: 修复 Windows WebView dispose 可能的竞争条件

disposeEnvironment() 内部已通过 headless_instances_.clear() 销毁
所有 headless 实例,移除额外的 headlessWebview.dispose() 调用,
避免两个异步方法通道调用产生竞争导致异常。

经原生 C++ 代码验证:disposeEnvironment 会依次清理所有实例和环境,
不存在双重释放问题;保留 disposeEnvironment 调用以支持代理配置热更新。

* feat: 播放器菜单新增下载入口

在播放器右上角三点菜单中添加"下载"子菜单,包含:
- 下载当前集:一键下载正在播放的集数
- 批量下载:打开选集下载面板进行多选

此改动将下载功能与选集按钮分离,优化移动端用户体验,
避免误触选集按钮上方的下载按钮。

* refactor: 引入 VideoSourceProvider 抽象层解决 WebView 状态污染

- 新增 lib/providers/ 模块:
  - IVideoSourceProvider 接口定义视频源解析抽象
  - WebViewVideoSourceProvider 封装 headless WebView 解析
  - CachedVideoSourceProvider 支持本地缓存视频读取
  - CompositeVideoSourceProvider 组合策略(优先缓存)

- VideoPageController 重构:
  - 原生播放器模式使用独立 Provider 实例
  - WebView 渲染模式保持使用共享 Controller
  - 支持取消正在进行的解析操作

- DownloadController 重构:
  - 使用 WebViewVideoSourceProvider 替代直接操作 WebView
  - 解决与 VideoPage 共享 WebView 单例导致的状态冲突

此重构确保 VideoPage 和 DownloadController 的视频解析相互独立,
避免同时下载和播放时的状态污染问题。

* feat: 离线播放复用主播放器架构

- 删除简化版 DownloadPlayerPage,统一使用 VideoPage
- VideoPageController 新增离线模式支持 (isOfflineMode)
- 离线模式下跳过 WebView 和弹幕请求,支持无网播放
- 离线集数列表使用下载时保存的剧集名称
- 修复历史记录使用实际集数编号 (actualEpisodeNumber)

* refactor: 解耦 VideoPageController 对 DownloadController 的依赖

- IDownloadRepository 扩展接口:
  - getRecordByBangumiId() - 通过番剧ID获取下载记录
  - getEpisode() - 获取指定集数的下载信息
  - getCompletedEpisodes() - 获取已完成下载的集数列表

- VideoPageController 改用 Repository 层:
  - 依赖 IDownloadRepository + IDownloadManager
  - 移除对 DownloadController 的直接依赖
  - 遵循 Controller 不应依赖 Controller 的分层原则

- DownloadController/Page 使用 Repository 新方法:
  - getRecord/getEpisode 委托给 Repository
  - getCompletedEpisodes 提供给 Page 层使用

- CachedVideoSourceProvider 简化:
  - 使用 Repository.getEpisode() 替代手动查询

* refactor: 移除 WebView Widget 层技术债务并改进 Hive 错误处理

- 移除 WebviewItem Widget 及其平台实现文件
- 移除 VideoPage 中的 WebView 流订阅,统一使用 Provider 模式
- 移除 VideoModule 中的 WebviewItemController 单例注册
- 为 downloadController.init() 添加 try-catch 防止数据损坏导致黑屏
- 改进 DownloadRepository.getAllRecords() 逐条读取,单条失败不影响整体

* feat: 离线弹幕缓存功能

* feat: 下载速率显示、并发配置与稳定性改进

- 新增下载速率实时显示(KB/s、MB/s)
- 新增下载设置页面,支持配置集数并发(1-5)和分片并发(1-10)
- 修复 Hive 数据库损坏导致的黑屏问题:
  - 启动时自动检测并恢复损坏的 Box
  - 下载进度仅在状态变化时持久化,大幅减少磁盘 I/O
- 新增优先下载功能(插队),可跳过队列立即下载
- 新增"开始全部"功能,一键恢复剧集所有未完成的下载
- 修复重启后等待中的任务卡住的问题
- 修复 HTTP 416 断点续传错误
- 批量下载时显示提示信息

* refactor: 代码清理与健壮性改进

- 迁移 WebView 基础设施从 pages/webview/ 到 lib/webview/
- 清理 video_page.dart 中 useNativePlayer 死代码分支
- 分片下载使用 .tmp 临时文件防止崩溃导致不完整文件
- 优化 WebViewVideoSourceProvider 生命周期,复用 WebView 实例

* fix: 修复离线播放时 currentEpisode 越界导致崩溃

initForOfflinePlayback 中将实际集数编号直接赋给 currentEpisode,
但离线 roadList 仅包含已下载集数,导致索引越界。
改为在 roadList.data 中查找对应位置作为 currentEpisode。

* fix: 使用 URL 匹配替代位置匹配修复下载集数重排序问题

- 新增 getEpisodeByUrl 通过 episodePageUrl 查找下载记录,位置匹配作为回退
- startDownload 添加 URL 防重复检查,防止列表重排序后重复下载
- 下载状态图标、下载面板、单集下载均改用 URL 匹配
- 移除弹幕设置中重复的"下载时缓存弹幕"选项,保留下载设置中的
- int.parse 改为 int.tryParse 防御性编程
- 从 IVideoSourceProvider 接口层移除 useNativePlayer 参数

* refactor: 重写 WebviewItemControllerImpel 从 webview_flutter 迁移到 flutter_inappwebview

将低版本 Android 的 WebView 回退实现从 webview_flutter 迁移到
flutter_inappwebview 的 PlatformHeadlessInAppWebView,使其兼容
WebViewVideoSourceProvider 的 headless 架构。使用 evaluateJavascript()
在 onLoadStart/onLoadStop 回调中注入脚本替代 UserScript AT_DOCUMENT_START,
同时移除 webview_flutter 依赖。

* chore: 清理阶段4未使用的 VideoSourceProvider 实现

移除未使用的 CachedVideoSourceProvider 和 CompositeVideoSourceProvider,
清理 providers.dart 的 export,减少 PR 中的死代码便于 review。

* fix: 修复旧版 Android WebView onLoadStart 时 callHandler 未就绪导致脚本中止

在 onLoadStart 注入的 blob parser 和 iframe redirect 脚本中,
将顶层 callHandler 日志调用包裹 try-catch,防止 bridge 未初始化
时整个脚本崩溃导致 M3U8 拦截失效。

(cherry picked from commit 85dd497)
@ErBWs
Copy link
Contributor

ErBWs commented Feb 7, 2026

我在移植的时候遇到了一些问题,webview_item 会报错

======== Exception caught by widgets library =======================================================
The following BindNotFoundException was thrown building Positioned:
UnregisteredInstance: WebviewItemController<dynamic> unregistered.
WebviewItemController<dynamic>
#0      AutoInjectorImpl.get (package:auto_injector/src/auto_injector_base.dart:264:7)
#1      BindServiceImpl.getBind (package:flutter_modular/src/infra/services/bind_service_impl.dart:21:31)
#2      GetBindImpl.call (package:flutter_modular/src/domain/usecases/get_bind.dart:17:24)
#3      ModularBase.get (package:flutter_modular/src/presenter/modular_base.dart:140:22)
#4      new _WebviewOhosItemState (package:kazumi/webview/webview_ohos_item.dart:17:15)
#5      WebviewOhosItem.createState (package:kazumi/webview/webview_ohos_item.dart:12:43)

@0Chencc
Copy link
Contributor Author

0Chencc commented Feb 7, 2026

我在移植的时候遇到了一些问题,webview_item 会报错

======== Exception caught by widgets library =======================================================
The following BindNotFoundException was thrown building Positioned:
UnregisteredInstance: WebviewItemController<dynamic> unregistered.
WebviewItemController<dynamic>
#0      AutoInjectorImpl.get (package:auto_injector/src/auto_injector_base.dart:264:7)
#1      BindServiceImpl.getBind (package:flutter_modular/src/infra/services/bind_service_impl.dart:21:31)
#2      GetBindImpl.call (package:flutter_modular/src/domain/usecases/get_bind.dart:17:24)
#3      ModularBase.get (package:flutter_modular/src/presenter/modular_base.dart:140:22)
#4      new _WebviewOhosItemState (package:kazumi/webview/webview_ohos_item.dart:17:15)
#5      WebviewOhosItem.createState (package:kazumi/webview/webview_ohos_item.dart:12:43)

当前主仓库中 WebviewItemController 已不再通过Modular DI注册,而是改用了 WebviewItemControllerFactory静态工厂方法创建实例。

ohos 移植者的代码 webview_ohos_item.dart:17 仍在用旧方式:

// 旧方式 (已废弃) — 会报 UnregisteredInstance
final controller = Modular.get();

解决方案

ohos 移植者需要做两件事:

  1. 在 WebviewItemControllerFactory 中添加 ohos 平台分支

参考 lib/webview/webview_controller.dart:

  class WebviewItemControllerFactory {
    static WebviewItemController getController() {
      // ... 其他平台 ...
      if (Platform.isOhos) {  // 或 ohos 的平台判断方式
        return WebviewOhosItemController();
      }
      return WebviewItemControllerImpel();
    }
  }
  1. 在 WebviewOhosItem Widget 中不再使用 Modular.get()
  // 不要这样做

  final controller = Modular.get<WebviewItemController>();

  // 改为直接通过工厂创建或从外部传入
  final controller = WebviewItemControllerFactory.getController();

由于 ohos 的 WebView 必须绑定可见 Widget(无法 headless),WebviewOhosItem 应该接收一个已创建的 controller实例作为参数,而不是从 DI 容器获取。这样与当前主仓库的架构(WebViewVideoSourceProvider 每次创建独立实例)保持一致。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants