fix(player): 优化上下滑切集黑屏
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// 只画会话仍持有的旧画面,控制器一旦被换掉就不再引用。
|
||||
|
||||
Reference in new issue
Block a user