diff --git a/README.md b/README.md index d3d312a..b2e6a74 100644 --- a/README.md +++ b/README.md @@ -187,3 +187,11 @@ python3 scripts/sync_source.py --check 站源是 Go 原生 provider(`native/core/provider_*.go`),不是运行期加载的 Python 源。新增站源需接入:`provider_huangguo.go`(常量 / 白名单 / `canonicalProviderSource` / `GetHuangguoChapters`)、`provider_media.go`(baseURL / host 反查 / 播放分派)、`app_categories.go`、`app_cover_metadata.go`、`app_runtime.go`(Config 字段 + 目录 / 搜索 / 详情分派 + 空页放行),并在 `lib/models.dart` 登记 `SourceSite`。 注意:Go RE2 正则**不支持 lookahead**。解析多线路播放列表时不能用 `(?=...)` 做分段,否则非捕获组会消耗下一段开头;应改用显式字符串截断(从容器标记之后查找下一段标记)。 + +## 短剧下一集预加载优化 + +原策略需要当前集播放到一半或剩余 45 秒以内,并保持 5 秒缓冲;电视的向前读取上限也是约 5 秒,预加载可能迟迟不能触发。时长不超过 2 分钟的短剧现在播放约 2 秒、有至少 3 秒缓冲即可开始准备下一集;不足 8 秒的极短视频按约四分之一时长触发。长视频保留播放后段触发,临近结尾按实际剩余时长检查缓冲。 + +预加载启动防抖缩短为 200 毫秒;当前缓冲低于 1.5 秒时取消未完成的预取,缓冲恢复到 3 秒后重试,已准备的结果保留。仍然只准备下一集,原生开头数据预取保持最多 4 MiB / 6 秒限制。自动连播和手动下一集均复用匹配的会话;过期、集数、画质或在线模式不匹配的结果不会交给播放器,无效结果会释放。播放日记新增准备耗时、预取字节数和复用记录,不包含新增媒体地址或凭证。 + +基于上游源码的 7 项预加载策略/生命周期测试和 2 项合成 TV 切集测试通过:80 秒短剧在第 2 秒、3 秒缓冲时触发,自动和手动切集均使用预取会话、没有第二次地址解析。播放器 open 耗时日志只记录打开流程,不代表首帧显示耗时。真机切集等待时长尚未验证,不保证无缝播放。 diff --git a/lib/app_layout.dart b/lib/app_layout.dart index 800ea32..aa831d6 100644 --- a/lib/app_layout.dart +++ b/lib/app_layout.dart @@ -6,7 +6,7 @@ import 'package:flutter/services.dart'; export 'app_build.dart'; -const appVersion = '0.2.64'; +const appVersion = '0.2.65'; ThemeData televisionTheme(ThemeData theme) { final colors = theme.colorScheme; diff --git a/lib/main.dart b/lib/main.dart index a698e46..df46368 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,7 +24,7 @@ import 'diary_service.dart'; Future main(List arguments) async { WidgetsFlutterBinding.ensureInitialized(); - DiaryService.add('[App] 应用启动,版本: 0.2.64+71, 平台: ${Platform.operatingSystem}'); + DiaryService.add('[App] 应用启动,版本: $appVersion, 平台: ${Platform.operatingSystem}'); if (Platform.isAndroid) { await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setSystemUIOverlayStyle(AppTheme.systemBars(Brightness.dark)); diff --git a/lib/playback_preloader.dart b/lib/playback_preloader.dart index 87bc12f..d82b6a4 100644 --- a/lib/playback_preloader.dart +++ b/lib/playback_preloader.dart @@ -3,8 +3,34 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'core_bridge.dart'; +import 'diary_service.dart'; import 'models.dart'; +enum PlaybackPreloadAction { wait, prepare, pause } + +PlaybackPreloadAction playbackPreloadAction({ + required Duration duration, + required Duration position, + required Duration buffer, +}) { + final length = duration.inMilliseconds; + final elapsed = position.inMilliseconds; + final remaining = length - elapsed; + if (length <= 0 || remaining <= 0) return PlaybackPreloadAction.pause; + final headroom = buffer.inMilliseconds - elapsed; + final pauseThreshold = remaining < 1500 ? remaining : 1500; + if (headroom < pauseThreshold) return PlaybackPreloadAction.pause; + final start = length < 8000 ? (length ~/ 4).clamp(500, 2000) : 2000; + if (elapsed < start || + (length > 120000 && elapsed < length ~/ 2 && remaining > 45000)) { + return PlaybackPreloadAction.wait; + } + final requiredBuffer = remaining < 3000 ? remaining : 3000; + return headroom >= requiredBuffer + ? PlaybackPreloadAction.prepare + : PlaybackPreloadAction.wait; +} + class PlaybackPreloader extends ChangeNotifier { PlaybackPreloader(this.repository, {DateTime Function()? now}) : _now = now ?? DateTime.now; @@ -31,7 +57,7 @@ class PlaybackPreloader extends ChangeNotifier { : '下一集播放地址已准备'; } if (loading || _delay != null) return '正在准备下一集'; - return error.isNotEmpty ? '下一集预加载暂不可用,切集时会重新获取' : '播放后段提前准备下一集'; + return error.isNotEmpty ? '下一集预加载暂不可用,切集时会重新获取' : '播放稳定后提前准备下一集'; } String _key(Drama drama, Episode episode, int quality, bool online) => @@ -61,10 +87,11 @@ class PlaybackPreloader extends ChangeNotifier { return; } final ticket = _generation; - _delay = Timer(const Duration(milliseconds: 600), () async { + _delay = Timer(const Duration(milliseconds: 200), () async { _delay = null; loading = true; notifyListeners(); + final elapsed = Stopwatch()..start(); try { final plan = await repository.preload( drama, @@ -76,7 +103,10 @@ class PlaybackPreloader extends ChangeNotifier { if (plan != null) await repository.release(plan.session); return; } - if (plan == null || plan.url.isEmpty) throw AppFailure('未取得预加载结果'); + if (plan == null || plan.url.isEmpty) { + if (plan != null) _release(plan); + throw AppFailure('未取得预加载结果'); + } var expires = _now().add(const Duration(minutes: 2)); if (plan.expiresAt > 0) { final authorizationExpiry = DateTime.fromMillisecondsSinceEpoch( @@ -93,6 +123,9 @@ class PlaybackPreloader extends ChangeNotifier { _ready = plan; _expires = expires; error = ''; + DiaryService.add( + '[Preload] ready episode=${episode.number} bytes=${plan.prefetchedBytes} elapsedMs=${elapsed.elapsedMilliseconds}', + ); } catch (failure) { if (_closed || ticket != _generation) return; error = failure.toString(); @@ -118,7 +151,12 @@ class PlaybackPreloader extends ChangeNotifier { _now().isBefore(_expires) ? _ready : null; - if (result != null) _ready = null; + if (result != null) { + _ready = null; + DiaryService.add( + '[Preload] reuse episode=${episode.number} bytes=${result.prefetchedBytes}', + ); + } clear(); return result; } diff --git a/lib/player_screen.dart b/lib/player_screen.dart index dcebf72..b6a0aa2 100644 --- a/lib/player_screen.dart +++ b/lib/player_screen.dart @@ -673,14 +673,16 @@ class _PlayerScreenState extends State _preloader.pause(); return; } - final duration = state.duration.inMilliseconds; - final position = state.position.inMilliseconds; - if (duration <= 0 || - position < 2000 || - (position < duration ~/ 2 && duration - position > 45000) || - state.buffer.inMilliseconds - position < 5000) { + final action = playbackPreloadAction( + duration: state.duration, + position: state.position, + buffer: state.buffer, + ); + if (action == PlaybackPreloadAction.pause) { + _preloader.pause(); return; } + if (action != PlaybackPreloadAction.prepare) return; _preloader.prepare( widget.detail.drama, widget.detail.episodes[_index + 1], @@ -906,6 +908,7 @@ class _PlayerScreenState extends State return; } _interactions.cancel(); + final opening = Stopwatch()..start(); if (widget.handoff != null && handoffPlan == null && recoveryAction == null) { @@ -1014,8 +1017,14 @@ class _PlayerScreenState extends State await platform.setProperty('vd-lavc-skiploopfilter', 'all'); await platform.setProperty('vd-lavc-skipidct', 'all'); await platform.setProperty('vd-lavc-threads', '2'); - await platform.setProperty('demuxer-max-bytes', '${4 * 1024 * 1024}'); - await platform.setProperty('demuxer-max-back-bytes', '${1 * 1024 * 1024}'); + await platform.setProperty( + 'demuxer-max-bytes', + '${4 * 1024 * 1024}', + ); + await platform.setProperty( + 'demuxer-max-back-bytes', + '${1 * 1024 * 1024}', + ); await platform.setProperty('demuxer-readahead-secs', '5'); } else { await platform.setProperty('hwdec', 'auto-safe'); @@ -1045,7 +1054,9 @@ class _PlayerScreenState extends State _plan = plan; installed = true; _acceptErrors = true; - DiaryService.add('[Play] 调用 _player.open: url=${plan.url}, headers=${plan.headers.keys.toList()}'); + DiaryService.add( + '[Play] 调用 _player.open: url=${plan.url}, headers=${plan.headers.keys.toList()}', + ); await _player.open( Media( plan.url, @@ -1060,6 +1071,9 @@ class _PlayerScreenState extends State return; } _openedIndex = index; + DiaryService.add( + '[Play] opened episode=${widget.detail.episodes[index].number} preloaded=${warmed != null} elapsedMs=${opening.elapsedMilliseconds}', + ); _enhancement.mediaReady(); _attachLanPlayback(); _health.reset(); @@ -1768,7 +1782,8 @@ class _PlayerScreenState extends State crossAxisAlignment: WrapCrossAlignment.center, children: [ FilledButton.tonalIcon( - onPressed: () => DiaryService.showDiaryDialog(context), + onPressed: () => + DiaryService.showDiaryDialog(context), icon: const Icon(Icons.receipt_long_rounded), label: const Text('查看播放日记'), ), @@ -1779,8 +1794,8 @@ class _PlayerScreenState extends State label: const Text('改为在线播放'), ) else if (!_localFailure && - !widget.localOnly && - widget.repository.supportsSourceManagement) + !widget.localOnly && + widget.repository.supportsSourceManagement) SourceDiagnosticsButton( repository: widget.repository, store: widget.store, diff --git a/pubspec.yaml b/pubspec.yaml index d4a890e..3563807 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: duanju_app description: 独立运行的多端短剧应用 publish_to: none -version: 0.2.64+71 +version: 0.2.65+72 environment: sdk: '>=3.12.0 <4.0.0' diff --git a/test/playback_preloader_test.dart b/test/playback_preloader_test.dart new file mode 100644 index 0000000..55275e9 --- /dev/null +++ b/test/playback_preloader_test.dart @@ -0,0 +1,185 @@ +import 'dart:async'; + +import 'package:duanju_app/models.dart'; +import 'package:duanju_app/playback_preloader.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'fixtures.dart'; + +class PreloadRepository extends FixtureRepository { + final pending = >[]; + final released = []; + final requested = <(int, int, bool)>[]; + int cancelled = 0; + + @override + Future preload( + Drama drama, + Episode episode, { + int quality = 0, + bool online = false, + }) { + requested.add((episode.number, quality, online)); + final request = Completer(); + pending.add(request); + return request.future; + } + + @override + Future cancelPreload() async => cancelled++; + + @override + Future release(String session) async => released.add(session); +} + +void main() { + final drama = FixtureRepository.free; + final episode = Episode({'id': '2'}, 2); + const plan = PlaybackPlan( + url: 'https://media.test/next.mp4', + session: 'next', + prefetchedBytes: 1024 * 1024, + ); + + PlaybackPreloadAction action(int duration, int position, int headroom) => + playbackPreloadAction( + duration: Duration(milliseconds: duration), + position: Duration(milliseconds: position), + buffer: Duration(milliseconds: position + headroom), + ); + + test('short episodes prepare early with reachable TV buffer headroom', () { + expect(action(80000, 1900, 5000), PlaybackPreloadAction.wait); + expect(action(80000, 2000, 3000), PlaybackPreloadAction.prepare); + expect(action(80000, 2000, 2999), PlaybackPreloadAction.wait); + expect(action(120000, 2000, 4500), PlaybackPreloadAction.prepare); + expect(action(4000, 1000, 3000), PlaybackPreloadAction.prepare); + expect(action(80000, 79000, 1000), PlaybackPreloadAction.prepare); + expect(action(80000, 80000, 0), PlaybackPreloadAction.pause); + expect(action(0, 2000, 5000), PlaybackPreloadAction.pause); + }); + + test('low buffer pauses but the middle band keeps an existing request', () { + expect(action(80000, 2000, 1499), PlaybackPreloadAction.pause); + expect(action(80000, 2000, 1500), PlaybackPreloadAction.wait); + expect(action(80000, 2000, 2500), PlaybackPreloadAction.wait); + expect(action(240000, 2000, 5000), PlaybackPreloadAction.wait); + expect(action(240000, 120000, 3000), PlaybackPreloadAction.prepare); + expect(action(300000, 260000, 3000), PlaybackPreloadAction.prepare); + }); + + testWidgets('one prepared session transfers once and survives a pause', ( + tester, + ) async { + final repository = PreloadRepository(); + final preloader = PlaybackPreloader(repository); + preloader.prepare(drama, episode); + preloader.prepare(drama, episode); + await tester.pump(const Duration(milliseconds: 200)); + expect(repository.requested, [(2, 0, false)]); + repository.pending.single.complete(plan); + await tester.pump(); + preloader.pause(); + expect(preloader.take(drama, episode), same(plan)); + expect(preloader.take(drama, episode), isNull); + expect(repository.released, isEmpty); + preloader.dispose(); + }); + + testWidgets('cancelled late results release without replacing the new plan', ( + tester, + ) async { + final repository = PreloadRepository(); + final preloader = PlaybackPreloader(repository); + preloader.prepare(drama, episode); + await tester.pump(const Duration(milliseconds: 200)); + preloader.pause(); + expect(repository.cancelled, 1); + preloader.prepare(drama, episode); + await tester.pump(const Duration(milliseconds: 200)); + repository.pending.first.complete(plan); + repository.pending.last.complete( + const PlaybackPlan(url: 'https://media.test/new.mp4', session: 'new'), + ); + await tester.pump(); + expect(repository.released, ['next']); + expect(preloader.take(drama, episode)?.session, 'new'); + preloader.dispose(); + }); + + testWidgets( + 'quality and online mode mismatches release the prepared session', + (tester) async { + for (final requested in [(720, false), (1080, true)]) { + final repository = PreloadRepository(); + final preloader = PlaybackPreloader(repository); + preloader.prepare(drama, episode, quality: 720, online: true); + await tester.pump(const Duration(milliseconds: 200)); + expect(repository.requested, [(2, 720, true)]); + repository.pending.single.complete(plan); + await tester.pump(); + expect( + preloader.take( + drama, + episode, + quality: requested.$1, + online: requested.$2, + ), + isNull, + ); + await tester.pump(); + expect(repository.released, ['next']); + preloader.dispose(); + } + }, + ); + + testWidgets('expired authorization is never handed to the player', ( + tester, + ) async { + var now = DateTime(2026, 10, 7); + final repository = PreloadRepository(); + final preloader = PlaybackPreloader(repository, now: () => now); + preloader.prepare(drama, episode); + await tester.pump(const Duration(milliseconds: 200)); + repository.pending.single.complete( + PlaybackPlan( + url: plan.url, + session: plan.session, + expiresAt: now.add(const Duration(seconds: 10)).millisecondsSinceEpoch, + ), + ); + await tester.pump(); + now = now.add(const Duration(seconds: 5)); + expect(preloader.take(drama, episode), isNull); + await tester.pump(); + expect(repository.released, ['next']); + preloader.dispose(); + }); + + testWidgets('invalid results release and retries respect the backoff', ( + tester, + ) async { + var now = DateTime(2026, 10, 7); + final repository = PreloadRepository(); + final preloader = PlaybackPreloader(repository, now: () => now); + preloader.prepare(drama, episode); + await tester.pump(const Duration(milliseconds: 200)); + repository.pending.single.complete( + const PlaybackPlan(url: '', session: 'invalid'), + ); + await tester.pump(); + expect(repository.released, ['invalid']); + preloader.prepare(drama, episode); + await tester.pump(const Duration(seconds: 1)); + expect(repository.requested.length, 1); + now = now.add(const Duration(seconds: 30)); + preloader.prepare(drama, episode); + await tester.pump(const Duration(milliseconds: 200)); + expect(repository.requested.length, 2); + preloader.dispose(); + repository.pending.last.complete(plan); + await tester.pump(); + expect(repository.released, ['invalid', 'next']); + }); +} diff --git a/test/player_fixtures.dart b/test/player_fixtures.dart index e8d5454..33bc240 100644 --- a/test/player_fixtures.dart +++ b/test/player_fixtures.dart @@ -84,6 +84,20 @@ class ScriptedPlayer extends PlatformPlayer { playingController.add(false); } + void bufferedPosition({ + required Duration duration, + required Duration position, + required Duration buffer, + }) { + state = state.copyWith( + duration: duration, + position: position, + buffer: buffer, + ); + positionController.add(position); + bufferController.add(buffer); + } + void videoSize(int width, int height) { videoParamsController.add( VideoParams(w: width, h: height, dw: width, dh: height), diff --git a/test/television_player_test.dart b/test/television_player_test.dart index 69025a2..3f74ceb 100644 --- a/test/television_player_test.dart +++ b/test/television_player_test.dart @@ -11,6 +11,27 @@ import 'fixtures.dart'; import 'player_fixtures.dart'; import 'remote_test_helpers.dart'; +class NextEpisodeRepository extends RouteRepository { + final preloadedEpisodes = []; + + @override + Future preload( + Drama drama, + Episode episode, { + int quality = 0, + bool online = false, + }) async { + preloadedEpisodes.add(episode.number); + final session = 'preloaded-${episode.number}'; + active.add(session); + return PlaybackPlan( + url: 'https://media.test/$session.mp4', + session: session, + prefetchedBytes: 1024 * 1024, + ); + } +} + void main() { Future settle(WidgetTester tester) async { for (var index = 0; index < 12; index++) { @@ -90,6 +111,38 @@ void main() { await settle(tester); } + for (final automatic in [true, false]) { + testWidgets( + 'short TV episodes reuse preloading on ${automatic ? 'automatic advance' : 'manual next'}', + (tester) async { + final repository = NextEpisodeRepository(); + final player = ScriptedPlayer(); + await mount(tester, repository, player); + player.bufferedPosition( + duration: const Duration(seconds: 80), + position: const Duration(seconds: 2), + buffer: const Duration(seconds: 5), + ); + await settle(tester); + await tester.pump(const Duration(milliseconds: 200)); + await settle(tester); + expect(repository.preloadedEpisodes, [2]); + expect(repository.primaryCalls, 1); + expect(repository.active, {'route-1', 'preloaded-2'}); + if (automatic) { + player.finishEpisode(); + } else { + await tester.tap(find.byKey(const ValueKey('tv-next'))); + } + await settle(tester); + expect(player.opened.last.uri, 'https://media.test/preloaded-2.mp4'); + expect(repository.primaryCalls, 1); + expect(repository.active, {'preloaded-2'}); + await leave(tester, repository, player); + }, + ); + } + testWidgets( 'TV remote pauses, seeks when hidden, seeks on the timeline and returns from fullscreen', (tester) async {