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

+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;
}
/// 只画会话仍持有的旧画面,控制器一旦被换掉就不再引用。