fix(player): 优化上下滑切集黑屏

This commit is contained in:
Zhou Weidong committed 2026-10-26 13:15:56 +08:00
1 parent b83b5799e3
commit 222bacdeeb
10 files changed
+625 -122

No files matched your search

@@ -0,0 +1,177 @@
// Author: fruit.com.cn
part of 'playback_session.dart';
/// 当前集加上一集、下一集,切集时最多三个解码器。
const int maxLivePlayers = 3;
/// 另一条播放路由必须等当前路由释放完全部原生播放器。
class PlaybackCoordinator {
PlaybackSession? _owner;
Future<void> _handoff = Future<void>.value();
Future<bool> _acquire(PlaybackSession session, bool Function() valid) {
final result = _handoff.then((_) async {
if (!valid()) return false;
if (_owner != session) {
final previous = _owner;
await previous?._releasePlayers();
if (previous != null && previous.livePlayerCount != 0) return false;
_owner = null;
if (!valid()) return false;
_owner = session;
}
return true;
});
_handoff = result.then<void>((_) {});
return result;
}
void _forget(PlaybackSession session) {
if (_owner == session && session.livePlayerCount == 0) _owner = null;
}
}
_PlayerSlot? _usableWarm(
PlaybackSession session,
_PlayerSlot? slot,
int index,
) {
if (slot == null || slot.index != index || slot.failure != null) return null;
if (session.isLocked) return null;
return slot;
}
/// 滑走的一集若仍是相邻集,就留着最后一帧,其余槽位释放。
Future<void> _parkNeighbors(
PlaybackSession session,
_PlayerSlot? old,
_PlayerSlot? warmNext,
_PlayerSlot? warmPrevious,
) async {
await _placeNeighbor(session, old);
await _placeNeighbor(session, warmNext);
await _placeNeighbor(session, warmPrevious);
}
Future<void> _placeNeighbor(PlaybackSession session, _PlayerSlot? slot) async {
if (slot == null || slot == session._active || slot.retiring) return;
if (session._next == null && slot.index == session._nextIndex) {
session._next = slot;
return;
}
if (session._previous == null && slot.index == session._previousIndex) {
session._previous = slot;
return;
}
await session._retire(slot);
}
/// 当前集已经出画后,静默准备上一集和下一集。
void _warmNeighbors(PlaybackSession session) {
final request = session._generation;
session._preloadTask = () async {
await Future.wait([
_warmOne(session, request, session._nextIndex, next: true),
_warmOne(session, request, session._previousIndex, next: false),
]);
}();
}
Future<void> _warmOne(
PlaybackSession session,
int request,
int? index, {
required bool next,
}) async {
final candidate = await _allocateNeighbor(
session,
request,
index,
next: next,
);
if (candidate == null) return;
await candidate.ready;
if (session._closed || !_stillHeld(session, candidate, next: next)) return;
if (candidate.failure == null) {
session._notify();
return;
}
await session._enqueue(() async {
if (!_stillHeld(session, candidate, next: next)) return;
_clearNeighbor(session, next: next);
await session._retire(candidate);
});
}
Future<_PlayerSlot?> _allocateNeighbor(
PlaybackSession session,
int request,
int? index, {
required bool next,
}) async {
_PlayerSlot? created;
await session._enqueue(() async {
if (session._closed || request != session._generation) return;
final allowed = _neighborAllowed(session, index);
final current = next ? session._next : session._previous;
if (!allowed || current?.index != index) {
_clearNeighbor(session, next: next);
await session._retire(current);
}
if (!_canAllocate(session, request, allowed: allowed, next: next)) {
return;
}
try {
created = session._create(index!);
} on StateError catch (error) {
if (kDebugMode) debugPrint('相邻集预热跳过: $error');
return;
}
if (next) {
session._next = created;
} else {
session._previous = created;
}
});
return created;
}
bool _neighborAllowed(PlaybackSession session, int? index) {
return index != null &&
index >= 0 &&
index < session.episodes.length &&
index != session._currentIndex &&
session.canPlay(session.episodes[index]);
}
bool _canAllocate(
PlaybackSession session,
int request, {
required bool allowed,
required bool next,
}) {
final held = next ? session._next : session._previous;
return session._visible &&
!session._overlayOpen &&
request == session._generation &&
session._active?.prepared == true &&
allowed &&
held == null;
}
bool _stillHeld(
PlaybackSession session,
_PlayerSlot slot, {
required bool next,
}) {
return (next ? session._next : session._previous) == slot;
}
void _clearNeighbor(PlaybackSession session, {required bool next}) {
if (next) {
session._next = null;
return;
}
session._previous = null;
}
+58 -100
View File
@@ -7,6 +7,8 @@ import '../models/drama.dart';
import '../models/player_port.dart';
import 'video_player_port.dart';
part 'playback_neighbors.dart';
/// 新纹理第一帧常是黑的,旧画面只盖住这一小段。
const Duration _qualityHandoffHold = Duration(milliseconds: 200);
@@ -14,37 +16,7 @@ typedef PlayerFactory = PlayerPort Function(Episode episode);
typedef PlayerPreparation =
Future<void> Function(Episode episode, PlayerPort player);
/// Shared by routes: the previous session releases all native players before
/// another session allocates. The app therefore has at most two, not two per page.
class PlaybackCoordinator {
PlaybackSession? _owner;
Future<void> _handoff = Future<void>.value();
Future<bool> _acquire(PlaybackSession session, bool Function() valid) {
final result = _handoff.then((_) async {
if (!valid()) return false;
if (_owner != session) {
final previous = _owner;
await previous?._releasePlayers();
// A failed native disposal cannot be treated as a free decoder slot.
if (previous != null && previous.livePlayerCount != 0) return false;
_owner = null;
if (!valid()) return false;
_owner = session;
}
return true;
});
_handoff = result.then<void>((_) {});
return result;
}
void _forget(PlaybackSession session) {
if (_owner == session && session.livePlayerCount == 0) _owner = null;
}
}
/// Owns current + one prepared successor. All allocation, playback and disposal
/// operations are serialized; initialization can run while current plays.
/// Owns current plus previous/next frames; allocation stays serialized.
class PlaybackSession extends ChangeNotifier {
PlaybackSession({
required this.episodes,
@@ -70,12 +42,14 @@ class PlaybackSession extends ChangeNotifier {
final Set<_PlayerSlot> _live = {};
_PlayerSlot? _active;
_PlayerSlot? _next;
_PlayerSlot? _previous;
_PlayerSlot? _handoff;
Future<void> _operations = Future<void>.value();
Future<void> _preloadTask = Future<void>.value();
int _generation = 0;
int _currentIndex = 0;
int? _nextIndex;
int? _previousIndex;
bool _selected = false;
bool _loading = false;
bool _closed = false;
@@ -120,7 +94,7 @@ class PlaybackSession extends ChangeNotifier {
/// during a swipe without starting audio or registering history listeners.
PlayerPort? preparedPlayerAt(int index) {
if (_closed) return null;
for (final slot in [_active, _next]) {
for (final slot in [_active, _next, _previous]) {
if (slot == null ||
slot.retiring ||
slot.index != index ||
@@ -142,24 +116,36 @@ class PlaybackSession extends ChangeNotifier {
}
/// 队列重排后把当前仍在播放的 slot 映射到新索引,不重新创建播放器。
void remapCurrentIndex(int index, {int? preloadIndex}) {
void remapCurrentIndex(int index, {int? preloadIndex, int? previousIndex}) {
if (_closed || index < 0 || index >= episodes.length) return;
final activeSlot = _active;
if (activeSlot == null || activeSlot.index != _currentIndex) {
_currentIndex = index;
_nextIndex = preloadIndex;
_rememberNeighbors(preloadIndex, previousIndex);
_notify();
return;
}
_generation++;
activeSlot.index = index;
_currentIndex = index;
_nextIndex = preloadIndex;
_rememberNeighbors(preloadIndex, previousIndex);
final staleNext = _next;
final stalePrevious = _previous;
_next = null;
_previous = null;
_notify();
unawaited(_enqueue(() async => _retire(staleNext)));
if (_visible && !_overlayOpen) _schedulePreload();
unawaited(
_enqueue(() async {
await _retire(staleNext);
await _retire(stalePrevious);
}),
);
if (_visible && !_overlayOpen) _warmNeighbors(this);
}
void _rememberNeighbors(int? preloadIndex, int? previousIndex) {
_nextIndex = preloadIndex;
_previousIndex = previousIndex;
}
Future<void> _enqueue(Future<void> Function() operation) {
@@ -176,7 +162,9 @@ class PlaybackSession extends ChangeNotifier {
}
_PlayerSlot _create(int index) {
if (_live.length >= 2) throw StateError('Playback resource limit reached');
if (_live.length >= maxLivePlayers) {
throw StateError('Playback resource limit reached');
}
final slot = _PlayerSlot(index, factory(episodes[index]));
_live.add(slot);
slot.ready = () async {
@@ -198,9 +186,14 @@ class PlaybackSession extends ChangeNotifier {
Future<void> _retire(_PlayerSlot? slot) async {
if (slot == null || !_live.contains(slot)) return;
slot.retiring = true;
final cleared = _active == slot || _next == slot || _handoff == slot;
final cleared =
_active == slot ||
_next == slot ||
_previous == slot ||
_handoff == slot;
if (_active == slot) _active = null;
if (_next == slot) _next = null;
if (_previous == slot) _previous = null;
if (_handoff == slot) _handoff = null;
if (cleared) _notify();
await waitForFrame?.call();
@@ -215,7 +208,11 @@ class PlaybackSession extends ChangeNotifier {
/// preloadIndex comes from the visible queue, so filtered categories and series
/// use exactly the same mechanism without warming an unrelated episode.
Future<void> select(int index, {int? preloadIndex}) async {
Future<void> select(
int index, {
int? preloadIndex,
int? previousIndex,
}) async {
if (_closed || index < 0 || index >= episodes.length) return;
// 切到另一集时,上一集按下的暂停不跟着走:滑过去就应该自动播放。
// 重复选中同一集(例如页面重新可见)不在这里恢复,尊重用户按下的暂停。
@@ -225,7 +222,7 @@ class PlaybackSession extends ChangeNotifier {
final request = ++_generation;
_selected = true;
_currentIndex = index;
_nextIndex = preloadIndex;
_rememberNeighbors(preloadIndex, previousIndex);
_error = null;
if (!_visible) {
_loading = false;
@@ -260,19 +257,22 @@ class PlaybackSession extends ChangeNotifier {
return;
}
if (old != null) await old.player.pause();
final warm = _next;
final warmNext = _next;
final warmPrevious = _previous;
_active = null;
_next = null;
final hit = warm?.index == index && warm?.failure == null && !isLocked;
if (hit) {
_previous = null;
final warm =
_usableWarm(this, warmNext, index) ??
_usableWarm(this, warmPrevious, index);
if (warm != null) {
target = _active = warm;
_loading = !warm!.prepared;
_loading = !warm.prepared;
_notify();
// Start a ready successor before awaiting slow disposal of the old one.
if (warm.prepared) await _start(warm, request);
}
await _retire(old);
if (!hit) await _retire(warm);
await _parkNeighbors(this, old, warmNext, warmPrevious);
if (_closed || request != _generation || isLocked) return;
if (target == null) {
target = _active = _create(index);
@@ -285,7 +285,7 @@ class PlaybackSession extends ChangeNotifier {
await slot.ready;
if (_closed || request != _generation || _active != slot) return;
await _enqueue(() => _start(slot, request));
if (!_closed && request == _generation) _schedulePreload();
if (!_closed && request == _generation) _warmNeighbors(this);
}
/// 后台准备新片源,成功后再替换当前播放器。失败时原播放器保持不动。
@@ -316,7 +316,7 @@ class PlaybackSession extends ChangeNotifier {
if (previous != null && _handoff == previous && _active == slot) {
await _enqueue(() => _retire(previous));
}
if (!_closed && request == _generation) _schedulePreload();
if (!_closed && request == _generation) _warmNeighbors(this);
return _active == slot && slot.failure == null;
}
@@ -422,54 +422,6 @@ class PlaybackSession extends ChangeNotifier {
slot.playing = shouldPlay;
}
void _schedulePreload() {
final request = _generation;
final index = _nextIndex;
_preloadTask = () async {
_PlayerSlot? candidate;
await _enqueue(() async {
if (_closed || request != _generation) return;
final allowed =
index != null &&
index >= 0 &&
index < episodes.length &&
index != _currentIndex &&
canPlay(episodes[index]);
if (!allowed || _next?.index != index) {
final stale = _next;
_next = null;
await _retire(stale);
}
if (!_visible ||
_overlayOpen ||
request != _generation ||
_active?.prepared != true ||
!allowed) {
return;
}
try {
candidate = _next ??= _create(index);
} catch (_) {
// Optional allocation failure must not interrupt the playing item.
}
});
final slot = candidate;
if (slot == null) return;
await slot.ready;
if (_closed || _next != slot) return;
if (slot.failure != null) {
// Optional work fails silently. A later explicit selection retries.
await _enqueue(() async {
if (_next != slot) return;
_next = null;
await _retire(slot);
});
} else {
_notify();
}
}();
}
void setAppResumed(bool value) {
_appResumed = value;
_syncPlayback();
@@ -494,18 +446,24 @@ class PlaybackSession extends ChangeNotifier {
void _syncPlayback() {
if (_closed) return;
if (_visible && _selected && active == null) {
unawaited(select(_currentIndex, preloadIndex: _nextIndex));
unawaited(
select(
_currentIndex,
preloadIndex: _nextIndex,
previousIndex: _previousIndex,
),
);
return;
}
final slot = _active;
if (slot != null) unawaited(_enqueue(() => _applyPlayback(slot)));
if (_visible && !_overlayOpen && !_loading) _schedulePreload();
if (_visible && !_overlayOpen && !_loading) _warmNeighbors(this);
}
Future<void> _releasePlayers() {
_generation++;
return _enqueue(() async {
_active = _next = _handoff = null;
_active = _next = _previous = _handoff = null;
_loading = false;
_notify();
for (final slot in _live.toList()) {
+20 -2
View File
@@ -299,6 +299,7 @@ class PlayerLogic extends ChangeNotifier {
session.remapCurrentIndex(
keepIndex,
preloadIndex: nextVisibleIndex(keepIndex),
previousIndex: previousVisibleIndex(keepIndex),
);
_jumpToFeedPage(keepIndex);
_notifyAfterFrameIfNeeded();
@@ -530,6 +531,7 @@ class PlayerLogic extends ChangeNotifier {
if (done.isCompleted) return;
if (value.hasError || !value.isBuffering) done.complete();
}
controller.addListener(onChange);
try {
await done.future.timeout(const Duration(seconds: 4));
@@ -566,7 +568,7 @@ class PlayerLogic extends ChangeNotifier {
}
final ready = await ensureTicket(index, forceRefresh: true);
if (ready) {
await session.select(index, preloadIndex: nextVisibleIndex(index));
await _selectVisible(index);
}
return ready;
} finally {
@@ -968,7 +970,16 @@ class PlayerLogic extends ChangeNotifier {
return;
}
if (!ready && ticketError != null) return;
await session.select(index, preloadIndex: nextVisibleIndex(index));
await _selectVisible(index);
}
/// 按可见队列选择一集,并预热上一集与下一集。
Future<void> _selectVisible(int index) {
return session.select(
index,
preloadIndex: nextVisibleIndex(index),
previousIndex: previousVisibleIndex(index),
);
}
int? nextVisibleIndex(int index) {
@@ -978,6 +989,13 @@ class PlayerLogic extends ChangeNotifier {
: null;
}
/// 可见队列里当前集的上一集;已经是第一集时为空。
int? previousVisibleIndex(int index) {
final position = visibleIndices.indexOf(index);
if (position <= 0) return null;
return visibleIndices[position - 1];
}
bool get hasNextEpisode => nextVisibleIndex(session.currentIndex) != null;
Future<void> playNextEpisode() async {
+8 -2
View File
@@ -41,6 +41,7 @@ import 'widgets/player_overlay.dart';
import 'widgets/player_overlay_models.dart';
import 'widgets/player_speed_sheet.dart';
import 'widgets/player_unlock_sheet.dart';
import 'widgets/player_opening_frame.dart';
import 'widgets/player_video_surface.dart';
import '../../core/data/repositories/watch_history_repository.dart';
import '../../core/data/repositories/playback_repository.dart';
@@ -552,7 +553,9 @@ class _PlayerPageState extends State<PlayerPage>
final ready = videoReady || port != null;
final theater = logic.pip || logic.pipPreparing;
final locked = logic.session.isLocked || episode.locked;
if (selected && videoPort != null && videoPort.controller.value.isInitialized) {
if (selected &&
videoPort != null &&
videoPort.controller.value.isInitialized) {
unawaited(
_spriteAxisService.prepareAt(
episode.thumbnailsUrl,
@@ -574,6 +577,7 @@ class _PlayerPageState extends State<PlayerPage>
handoff: handoffPort?.controller,
videoUrl: videoPort?.controller.dataSource ?? episode.videoUrl,
theater: theater,
openingFrame: playerOpeningFrame(episode.thumbnailsUrl),
),
if (selected &&
!theater &&
@@ -726,7 +730,9 @@ class _PlayerPageState extends State<PlayerPage>
},
onEpisodes: () => logic.showEpisodes(context),
onMore: _openCurrentMore,
onScrubStart: videoReady ? () => logic.beginScrub(videoPort!) : null,
onScrubStart: videoReady
? () => logic.beginScrub(videoPort!)
: null,
onScrubEnd: _finishFeedScrub,
onScrubVisibilityChanged: _setScrubbing,
cancelScrubRequest: widget.scrubCancelRequest,
@@ -0,0 +1,167 @@
// Author: fruit.com.cn
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import '../../../core/config/app_config.dart';
import '../../../core/data/services/sprite_thumbnail_asset_loader.dart';
import '../../../core/data/services/sprite_thumbnail_axis.dart';
import 'scrub_preview_card.dart';
/// 无雪碧图地址时不垫帧,交还给纯黑底。
Widget? playerOpeningFrame(String? thumbnailsUrl) {
final url = thumbnailsUrl?.trim() ?? '';
if (url.isEmpty) return null;
return PlayerOpeningFrame(thumbnailsUrl: url);
}
/// 解码出视频画面前,用本集雪碧图第一帧垫底。
class PlayerOpeningFrame extends StatefulWidget {
const PlayerOpeningFrame({
super.key,
required this.thumbnailsUrl,
this.loader,
this.cache,
});
final String thumbnailsUrl;
final SpriteThumbnailAssetLoader? loader;
final OpeningFrameCache? cache;
@override
State<PlayerOpeningFrame> createState() => _PlayerOpeningFrameState();
}
class _PlayerOpeningFrameState extends State<PlayerOpeningFrame> {
SpriteThumbnailFrame? _frame;
int _serial = 0;
@override
void initState() {
super.initState();
unawaited(_load());
}
@override
void didUpdateWidget(covariant PlayerOpeningFrame oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.thumbnailsUrl != widget.thumbnailsUrl) {
_frame = null;
unawaited(_load());
}
}
Future<void> _load() async {
final serial = ++_serial;
final frame = await (widget.cache ?? OpeningFrameCache.shared).load(
widget.thumbnailsUrl,
loader: widget.loader,
);
if (!mounted || serial != _serial) return;
setState(() => _frame = frame);
}
@override
Widget build(BuildContext context) {
final frame = _frame;
if (frame == null) return const SizedBox.expand();
return SpriteCropPreview(
key: const Key('player-opening-sprite'),
image: frame.image,
sourceRect: frame.sourceRect,
);
}
}
/// 相邻页共用的雪碧图首帧缓存,避免每页重复拉 VTT。
class OpeningFrameCache {
OpeningFrameCache({this.capacity = 6});
static final OpeningFrameCache shared = OpeningFrameCache();
final int capacity;
final Map<String, SpriteThumbnailFrame> _frames = {};
final Map<String, Future<SpriteThumbnailFrame?>> _loads = {};
/// 取 [thumbnailsUrl] 对应的第一帧;失败返回 null。
Future<SpriteThumbnailFrame?> load(
String? thumbnailsUrl, {
SpriteThumbnailAssetLoader? loader,
}) {
final url = _normalize(thumbnailsUrl);
if (url == null) return Future<SpriteThumbnailFrame?>.value();
final cached = _frames[url];
if (cached != null) return Future.value(cached);
return _loads.putIfAbsent(url, () => _fetch(url, loader));
}
Future<SpriteThumbnailFrame?> _fetch(
String url,
SpriteThumbnailAssetLoader? loader,
) async {
try {
final asset = loader ?? DioSpriteThumbnailAssetLoader();
final text = await asset.loadVtt(Uri.parse(url));
if (text == null || text.isEmpty) return null;
final axis = parseSpriteThumbnailVtt(text, vttUrl: url);
final cue = axis.cues.isEmpty ? null : axis.cues.first;
if (cue == null) return null;
final decoded = await asset.loadSprite(
axis.resolveSpriteUri(cue.spriteFile),
);
if (decoded == null) return null;
final frame = SpriteThumbnailFrame(
image: decoded.image,
sourceRect: _scaledRect(decoded, cue.sourceRect),
spriteUrl: axis.resolveSpriteUri(cue.spriteFile).toString(),
cue: cue,
);
_store(url, frame);
return frame;
} catch (_) {
return null;
} finally {
_loads.remove(url);
}
}
void _store(String url, SpriteThumbnailFrame frame) {
_frames.remove(url);
_frames[url] = frame;
while (_frames.length > capacity) {
final oldest = _frames.keys.first;
_frames.remove(oldest)?.image.dispose();
}
}
ui.Rect _scaledRect(SpriteThumbnailDecodedImage decoded, ui.Rect source) {
if (decoded.originalWidth <= 0 || decoded.originalHeight <= 0) {
return source;
}
final scaleX = decoded.image.width / decoded.originalWidth;
final scaleY = decoded.image.height / decoded.originalHeight;
return ui.Rect.fromLTWH(
source.left * scaleX,
source.top * scaleY,
source.width * scaleX,
source.height * scaleY,
);
}
String? _normalize(String? raw) {
final value = AppConfig.mediaUrl(raw?.trim() ?? '');
if (value.isEmpty) return null;
return value;
}
/// 测试收尾时清空缓存。
void clear() {
for (final frame in _frames.values) {
frame.image.dispose();
}
_frames.clear();
_loads.clear();
}
}
@@ -18,10 +18,14 @@ class PlayerVideoSurface extends StatefulWidget {
required this.videoUrl,
required this.theater,
this.handoff,
this.openingFrame,
});
final VideoPlayerController? controller;
final VideoPlayerController? handoff;
/// 解码出第一帧之前垫上的视频首帧,没有则为空。
final Widget? openingFrame;
final String videoUrl;
final bool theater;
@@ -60,6 +64,11 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
key: Key('player-fallback-background'),
color: AppColors.mediaBlack,
),
if (!reveal && widget.openingFrame != null)
KeyedSubtree(
key: const Key('player-opening-frame'),
child: widget.openingFrame!,
),
AnimatedOpacity(
key: const Key('player-video-opacity'),
opacity: reveal ? 1 : 0,
@@ -99,10 +108,9 @@ class _PlayerVideoSurfaceState extends State<PlayerVideoSurface> {
}
bool _shouldReveal(VideoPlayerValue? value, bool covered) {
if (covered) return value?.isInitialized ?? false;
if (value == null || value.hasError || !value.isInitialized) return false;
if (value.size.width <= 0) return false;
if (covered) return true;
return value.position > Duration.zero || value.isPlaying;
return value.size.width > 0 && value.size.height > 0;
}
/// 只画会话仍持有的旧画面,控制器一旦被换掉就不再引用。