Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 耗时日志只记录打开流程,不代表首帧显示耗时。真机切集等待时长尚未验证,不保证无缝播放。
2 changes: 1 addition & 1 deletion lib/app_layout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import 'diary_service.dart';

Future<void> main(List<String> 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));
Expand Down
46 changes: 42 additions & 4 deletions lib/playback_preloader.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) =>
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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();
Expand All @@ -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;
}
Expand Down
39 changes: 27 additions & 12 deletions lib/player_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -673,14 +673,16 @@ class _PlayerScreenState extends State<PlayerScreen>
_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],
Expand Down Expand Up @@ -906,6 +908,7 @@ class _PlayerScreenState extends State<PlayerScreen>
return;
}
_interactions.cancel();
final opening = Stopwatch()..start();
if (widget.handoff != null &&
handoffPlan == null &&
recoveryAction == null) {
Expand Down Expand Up @@ -1014,8 +1017,14 @@ class _PlayerScreenState extends State<PlayerScreen>
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');
Expand Down Expand Up @@ -1045,7 +1054,9 @@ class _PlayerScreenState extends State<PlayerScreen>
_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,
Expand All @@ -1060,6 +1071,9 @@ class _PlayerScreenState extends State<PlayerScreen>
return;
}
_openedIndex = index;
DiaryService.add(
'[Play] opened episode=${widget.detail.episodes[index].number} preloaded=${warmed != null} elapsedMs=${opening.elapsedMilliseconds}',
);
_enhancement.mediaReady();
_attachLanPlayback();
_health.reset();
Expand Down Expand Up @@ -1768,7 +1782,8 @@ class _PlayerScreenState extends State<PlayerScreen>
crossAxisAlignment: WrapCrossAlignment.center,
children: [
FilledButton.tonalIcon(
onPressed: () => DiaryService.showDiaryDialog(context),
onPressed: () =>
DiaryService.showDiaryDialog(context),
icon: const Icon(Icons.receipt_long_rounded),
label: const Text('查看播放日记'),
),
Expand All @@ -1779,8 +1794,8 @@ class _PlayerScreenState extends State<PlayerScreen>
label: const Text('改为在线播放'),
)
else if (!_localFailure &&
!widget.localOnly &&
widget.repository.supportsSourceManagement)
!widget.localOnly &&
widget.repository.supportsSourceManagement)
SourceDiagnosticsButton(
repository: widget.repository,
store: widget.store,
Expand Down
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
Loading