fix(player): 优化上下滑切集黑屏
This commit is contained in:
1 parent
b83b5799e3
commit
222bacdeeb
10 files changed
+625
-122
No files matched your search
@@ -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