1
This commit is contained in:
@@ -3,6 +3,8 @@ import 'dart:async';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
|
||||
import '../media/local_media_server.dart';
|
||||
|
||||
/// 一个播放槽位:Player + 视图控制器 + 当前绑定的视频
|
||||
class PlayerSlot {
|
||||
PlayerSlot(this.player);
|
||||
@@ -44,6 +46,20 @@ class FeedPlayerPool {
|
||||
int _currentIndex = 0;
|
||||
int get currentIndex => _currentIndex;
|
||||
|
||||
/// 代际计数器:每次 bindWindow 自增,await 恢复后校验,过期调用直接退出。
|
||||
/// 快速连划时多个 bindWindow 并发 await open,无互斥会导致旧调用恢复后
|
||||
/// 误 play 装有旧媒体的槽位(幽灵声音来源)。
|
||||
int _generation = 0;
|
||||
|
||||
/// 是否挂起(切出首页 Tab / App 退后台):挂起期间 bindWindow 的 open
|
||||
/// 完成后不允许自动 play —— 否则「先暂停、open 后完成又 play」的竞态
|
||||
/// 会让视频在后台继续出声(切到"我的"听到同一视频声音的根因)
|
||||
bool _suspended = false;
|
||||
|
||||
/// 最近一次 bindWindow(resume 时需等在途 open 完成再 play,避免 play 被
|
||||
/// open(play:false) 内部的 pause 覆盖导致恢复后不播)
|
||||
Future<void>? _pendingBind;
|
||||
|
||||
/// 初始化(创建 3 槽位并订阅各自进度流)
|
||||
void init() {
|
||||
for (var i = 0; i < slotCount; i++) {
|
||||
@@ -65,26 +81,40 @@ class FeedPlayerPool {
|
||||
bool get isCurrentPlaying => currentSlot.player.state.playing;
|
||||
|
||||
/// 绑定窗口:index 为当前页;items 为已解析好播放地址的窗口数据源
|
||||
Future<void> bindWindow(int index, List<({int videoId, String url})> items) async {
|
||||
///
|
||||
/// 窗口策略 [index, index+1]:只保留当前页(播放)和下一页(预缓冲)。
|
||||
/// 上一页槽位 stop 清空:一是满足"划走即停止缓存下载"(pause 不停止缓冲,
|
||||
/// stop 才断开),二是 stop 后槽内无媒体,即使误 play 也不会出声(防幽灵)。
|
||||
Future<void> bindWindow(int index, List<({int videoId, String url})> items) {
|
||||
final fut = _bindWindow(index, items);
|
||||
_pendingBind = fut;
|
||||
return fut;
|
||||
}
|
||||
|
||||
Future<void> _bindWindow(
|
||||
int index, List<({int videoId, String url})> items) async {
|
||||
final gen = ++_generation;
|
||||
// 1. 先切换当前页指针(currentSlot / isCurrentPlaying / 完成回调都依赖它)
|
||||
_currentIndex = index;
|
||||
|
||||
// 窗口内应保留的槽位集合 [index-1, index, index+1]
|
||||
// 窗口内应保留的槽位集合 [index, index+1]
|
||||
final activeSet = <int>{};
|
||||
for (var offset = -1; offset <= 1; offset++) {
|
||||
for (var offset = 0; offset <= 1; offset++) {
|
||||
final i = index + offset;
|
||||
if (i >= 0 && i < items.length) activeSet.add(slotOf(i));
|
||||
}
|
||||
|
||||
// 2. 立即静音(同步执行,不等后续网络 open):
|
||||
// - 出窗槽位:stop(断音 + 清缓存),并复位
|
||||
// - 窗口内槽位:全部 pause(含刚划走的上一页,防止切页期间串音)
|
||||
// - 出窗槽位:stop(断音 + 断缓存 + 清媒体),并复位
|
||||
// - 窗口内非当前槽位:pause(下一页预缓冲不自动播)
|
||||
// 这一步必须早于任何 await,否则 open 新视频期间旧视频仍在出声
|
||||
for (var s = 0; s < slotCount; s++) {
|
||||
final slot = _slots[s];
|
||||
if (slot.videoId < 0) continue;
|
||||
if (activeSet.contains(s)) {
|
||||
try { slot.player.pause(); } catch (_) {}
|
||||
if (s != slotOf(index)) {
|
||||
try { slot.player.pause(); } catch (_) {}
|
||||
}
|
||||
} else {
|
||||
unawaited(slot.player.stop());
|
||||
slot.videoId = -1;
|
||||
@@ -93,7 +123,7 @@ class FeedPlayerPool {
|
||||
}
|
||||
|
||||
// 3. 窗口内槽位按需加载(open 完才 play)
|
||||
for (var offset = -1; offset <= 1; offset++) {
|
||||
for (var offset = 0; offset <= 1; offset++) {
|
||||
final i = index + offset;
|
||||
if (i < 0 || i >= items.length) continue;
|
||||
final slot = _slots[slotOf(i)];
|
||||
@@ -102,14 +132,44 @@ class FeedPlayerPool {
|
||||
slot.videoId = item.videoId;
|
||||
slot.completeNotified = false;
|
||||
if (item.url.isNotEmpty) {
|
||||
await slot.player.open(Media(item.url), play: false);
|
||||
// 经本地代理:HLS 分片 1G LRU 缓存,回看命中缓存不再下载
|
||||
await slot.player.open(Media(LocalMediaServer.I.wrap(item.url)), play: false);
|
||||
// await 期间用户可能又翻页:本次绑定已过期,退出(最新绑定会接管)
|
||||
if (gen != _generation) return;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 当前槽:播放
|
||||
// 4. 当前槽:播放(播前再校验代际与挂起态,防过期调用/后台挂起时唤醒媒体)
|
||||
if (gen != _generation || _suspended) return;
|
||||
currentSlot.player.play();
|
||||
}
|
||||
|
||||
/// 挂起(切出首页底部 Tab / App 退后台 / 切走当前策略):
|
||||
/// 立即暂停当前槽,并阻止在途 bindWindow 的 open 完成后自动播放
|
||||
void suspend() {
|
||||
_suspended = true;
|
||||
try {
|
||||
currentSlot.player.pause();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/// 恢复(切回首页 / App 回前台 / 策略切回):解除挂起;若 bind 仍在 open,
|
||||
/// 等其完成后再 play,避免 play 被 open(play:false) 的内部 pause 覆盖
|
||||
Future<void> resume() async {
|
||||
_suspended = false;
|
||||
final pending = _pendingBind;
|
||||
if (pending != null) {
|
||||
try {
|
||||
await pending;
|
||||
} catch (_) {}
|
||||
}
|
||||
if (!_suspended) {
|
||||
try {
|
||||
currentSlot.player.play();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
/// 每槽位独立订阅:≥95% 或播完触发一次 complete 回调
|
||||
void _hookSlot(PlayerSlot slot) {
|
||||
var lastPosition = Duration.zero;
|
||||
@@ -135,7 +195,7 @@ class FeedPlayerPool {
|
||||
}
|
||||
}
|
||||
// 自动循环(抖音/TikTok 式体验:当前槽播完从头再来)
|
||||
if (slot == currentSlot) {
|
||||
if (slot == currentSlot && !_suspended) {
|
||||
slot.player.seek(Duration.zero);
|
||||
slot.player.play();
|
||||
}
|
||||
@@ -155,6 +215,22 @@ class FeedPlayerPool {
|
||||
}
|
||||
}
|
||||
|
||||
/// 全部停止并清空(进入全屏播放器前调用):停音 + 断缓存 + 清媒体。
|
||||
/// 与 pause 不同,stop 后缓存下载断开,且槽内无媒体不会幽灵出声。
|
||||
/// 返回 Feed 后需重新 bindWindow 恢复当前页。
|
||||
void stopAll() {
|
||||
// 使所有进行中的 bindWindow 过期(其 await 恢复后不会再 play)
|
||||
_generation++;
|
||||
// stopAll 后通常跟着 rebind 重新播放(如从登录页返回),解除挂起态
|
||||
_suspended = false;
|
||||
for (final slot in _slots) {
|
||||
if (slot.videoId < 0) continue;
|
||||
unawaited(slot.player.stop());
|
||||
slot.videoId = -1;
|
||||
slot.completeNotified = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 释放全部资源(Feed 页 dispose 时调用)
|
||||
Future<void> dispose() async {
|
||||
for (final sub in _subs) {
|
||||
|
||||
Reference in New Issue
Block a user