fix(player): 收紧图片与快速切集资源占用
This commit is contained in:
1 parent
2fef7470e0
commit
80c962a95c
10 files changed
+281
-30
No files matched your search
@@ -81,6 +81,8 @@ class PlaybackSession extends ChangeNotifier {
|
||||
// Includes initialization and asynchronous disposal, until native release ends.
|
||||
int get livePlayerCount => _live.length;
|
||||
int? get preloadedIndex => _next?.prepared == true ? _next?.index : null;
|
||||
int? get previousPreloadedIndex =>
|
||||
_previous?.prepared == true ? _previous?.index : null;
|
||||
Future<void> get playbackSettled => _operations;
|
||||
Future<void> get preloadSettled => _preloadTask;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import '../../config/app_config.dart';
|
||||
import 'sprite_thumbnail_asset_loader.dart';
|
||||
|
||||
const Duration spriteVttCacheTtl = Duration(minutes: 10);
|
||||
const int spriteImageCacheCapacity = 2;
|
||||
const int spriteImageCacheCapacity = 1;
|
||||
|
||||
/// 单个 VTT cue 对应的 sprite 裁剪区域。
|
||||
class SpriteThumbnailCue {
|
||||
@@ -148,7 +148,6 @@ class SpriteThumbnailAxisService extends ChangeNotifier
|
||||
final Set<String> _failedSprites = {};
|
||||
bool _disposed = false;
|
||||
|
||||
@visibleForTesting
|
||||
int get spriteCacheSize => _spriteCache.length;
|
||||
|
||||
@visibleForTesting
|
||||
@@ -156,7 +155,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier
|
||||
.map((url) => Uri.tryParse(url)?.pathSegments.lastOrNull ?? url)
|
||||
.toList();
|
||||
|
||||
/// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。
|
||||
/// 播放器就绪时预取当前 cue;容量大于 1 时顺带预取相邻 sprite。
|
||||
@override
|
||||
Future<void> prepareAt(String? thumbnailsUrl, Duration position) async {
|
||||
final url = _normalizeUrl(thumbnailsUrl);
|
||||
@@ -269,6 +268,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier
|
||||
if (current == null) return const [];
|
||||
final index = axis.cues.indexOf(current);
|
||||
final candidates = <SpriteThumbnailCue>[current];
|
||||
if (spriteCacheCapacity <= 1) return candidates;
|
||||
for (final direction in const [1, -1]) {
|
||||
for (var step = 1; step < axis.cues.length; step++) {
|
||||
final neighborIndex = index + direction * step;
|
||||
|
||||
@@ -5,6 +5,24 @@ import 'package:flutter/material.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
const int _minImageDecodePixels = 96;
|
||||
const int _maxImageDecodePixels = 2048;
|
||||
|
||||
/// 将布局逻辑尺寸换算成有限像素尺寸,兼顾小头像和全屏首帧。
|
||||
int resolveImageDecodePixels(double? logicalExtent, double devicePixelRatio) {
|
||||
final ratio = devicePixelRatio.isFinite && devicePixelRatio > 0
|
||||
? devicePixelRatio
|
||||
: 1.0;
|
||||
final extent =
|
||||
logicalExtent != null && logicalExtent.isFinite && logicalExtent > 0
|
||||
? logicalExtent
|
||||
: 120.0;
|
||||
return (extent * ratio)
|
||||
.ceil()
|
||||
.clamp(_minImageDecodePixels, _maxImageDecodePixels)
|
||||
.toInt();
|
||||
}
|
||||
|
||||
/// 封面图统一入口:后端给的是相对路径,这里补服务地址并处理加载失败。
|
||||
class RemoteImage extends StatelessWidget {
|
||||
const RemoteImage(
|
||||
@@ -17,6 +35,8 @@ class RemoteImage extends StatelessWidget {
|
||||
this.fallbackIconKey,
|
||||
this.fallbackAspectRatio,
|
||||
this.fallbackColor = AppColors.raised,
|
||||
this.decodeWidth,
|
||||
this.decodeHeight,
|
||||
});
|
||||
|
||||
final String path;
|
||||
@@ -27,21 +47,56 @@ class RemoteImage extends StatelessWidget {
|
||||
final Key? fallbackIconKey;
|
||||
final double? fallbackAspectRatio;
|
||||
final Color fallbackColor;
|
||||
final int? decodeWidth;
|
||||
final int? decodeHeight;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = AppConfig.mediaUrl(path);
|
||||
if (url.isEmpty) return _fallback(brand: showFallbackIcon);
|
||||
return Image.network(
|
||||
url,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon),
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded || frame != null) return child;
|
||||
return _fallback(brand: showFallbackIcon);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final pixelRatio = MediaQuery.devicePixelRatioOf(context);
|
||||
final layoutWidth = constraints.hasBoundedWidth
|
||||
? constraints.maxWidth
|
||||
: width;
|
||||
final aspectRatio =
|
||||
fallbackAspectRatio != null &&
|
||||
fallbackAspectRatio!.isFinite &&
|
||||
fallbackAspectRatio! > 0
|
||||
? fallbackAspectRatio
|
||||
: null;
|
||||
final inferredHeight = constraints.hasBoundedHeight
|
||||
? constraints.maxHeight
|
||||
: height ??
|
||||
(layoutWidth != null && aspectRatio != null
|
||||
? layoutWidth / aspectRatio
|
||||
: null);
|
||||
final cacheWidth =
|
||||
decodeWidth ?? resolveImageDecodePixels(layoutWidth, pixelRatio);
|
||||
final cacheHeight =
|
||||
decodeHeight ??
|
||||
(inferredHeight == null
|
||||
? null
|
||||
: resolveImageDecodePixels(inferredHeight, pixelRatio));
|
||||
return Image.network(
|
||||
url,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
cacheWidth: cacheWidth
|
||||
.clamp(_minImageDecodePixels, _maxImageDecodePixels)
|
||||
.toInt(),
|
||||
cacheHeight: cacheHeight
|
||||
?.clamp(_minImageDecodePixels, _maxImageDecodePixels)
|
||||
.toInt(),
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon),
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded || frame != null) return child;
|
||||
return _fallback(brand: showFallbackIcon);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,6 +93,9 @@ class PlayerLogic extends ChangeNotifier {
|
||||
String selectedQuality = 'AUTO';
|
||||
Duration? _qualitySeek;
|
||||
int pageRequest = 0;
|
||||
int? _pendingSelectionIndex;
|
||||
int? _pendingSelectionRevision;
|
||||
Future<void>? _selectionDrain;
|
||||
bool muted = false;
|
||||
double playbackSpeed = 1.0;
|
||||
bool stoppedAtFeedEnd = false;
|
||||
@@ -957,6 +960,37 @@ class PlayerLogic extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
/// 翻页入口使用 single-flight:执行中的切集不被重复排队,只保留最新目标。
|
||||
Future<void> requestEpisodeSelection(int index, {int? revision}) {
|
||||
if (_disposed || index < 0 || index >= sessionEpisodes.length) {
|
||||
return Future<void>.value();
|
||||
}
|
||||
_pendingSelectionIndex = index;
|
||||
_pendingSelectionRevision = revision;
|
||||
return _selectionDrain ??= _drainEpisodeSelections();
|
||||
}
|
||||
|
||||
Future<void> _drainEpisodeSelections() async {
|
||||
try {
|
||||
while (!_disposed) {
|
||||
final index = _pendingSelectionIndex;
|
||||
if (index == null) break;
|
||||
final revision = _pendingSelectionRevision;
|
||||
_pendingSelectionIndex = null;
|
||||
_pendingSelectionRevision = null;
|
||||
await selectEpisode(index, revision: revision);
|
||||
}
|
||||
} finally {
|
||||
_selectionDrain = null;
|
||||
final index = _pendingSelectionIndex;
|
||||
if (!_disposed && index != null) {
|
||||
unawaited(
|
||||
requestEpisodeSelection(index, revision: _pendingSelectionRevision),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> selectEpisode(int index, {int? revision}) async {
|
||||
final feedRevisionAtStart = revision ?? feedRevision;
|
||||
final request = ++pageRequest;
|
||||
|
||||
@@ -353,6 +353,28 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _selectPage(int index) async {
|
||||
await logic.requestEpisodeSelection(index);
|
||||
if (!mounted || logic.session.currentIndex != index) return;
|
||||
await logic.session.preloadSettled;
|
||||
if (!mounted || logic.session.currentIndex != index) return;
|
||||
_logResourceWatermark();
|
||||
}
|
||||
|
||||
void _logResourceWatermark() {
|
||||
if (!kDebugMode) return;
|
||||
final imageCache = PaintingBinding.instance.imageCache;
|
||||
debugPrint(
|
||||
'[player-resources] '
|
||||
'slots=${logic.session.livePlayerCount} '
|
||||
'warmNext=${logic.session.preloadedIndex ?? '-'} '
|
||||
'warmPrevious=${logic.session.previousPreloadedIndex ?? '-'} '
|
||||
'sprites=${_spriteAxisService.spriteCacheSize} '
|
||||
'imageCacheBytes=${imageCache.currentSizeBytes} '
|
||||
'imageCacheCount=${imageCache.currentSize}',
|
||||
);
|
||||
}
|
||||
|
||||
void _setScrubbing(bool value) {
|
||||
if (!mounted) return;
|
||||
if (_scrubbing == value) return;
|
||||
@@ -507,7 +529,7 @@ class _PlayerPageState extends State<PlayerPage>
|
||||
onPageChanged: (position) {
|
||||
final index = indices[position];
|
||||
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
|
||||
unawaited(logic.selectEpisode(index));
|
||||
unawaited(_selectPage(index));
|
||||
if (position >= indices.length - 3) {
|
||||
final loadMore = widget.onLoadMore;
|
||||
if (loadMore != null) unawaited(loadMore());
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
|
||||
import '../../../core/config/app_config.dart';
|
||||
import '../../../core/data/services/sprite_thumbnail_axis.dart';
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/widgets/remote_image.dart';
|
||||
import 'scrub_preview_card.dart';
|
||||
|
||||
enum PlayerOpeningFallback { handoff, poster, thumbnails, black }
|
||||
@@ -149,12 +150,25 @@ class _PlayerOpeningFrameState extends State<PlayerOpeningFrame> {
|
||||
children: [
|
||||
const ColoredBox(color: AppColors.mediaBlack),
|
||||
if (_hasPoster)
|
||||
Image.network(
|
||||
AppConfig.mediaUrl(widget.posterUrl!.trim()),
|
||||
key: const Key('player-opening-poster'),
|
||||
fit: widget.fit,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => _handlePosterError(),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final pixelRatio = MediaQuery.devicePixelRatioOf(context);
|
||||
return Image.network(
|
||||
AppConfig.mediaUrl(widget.posterUrl!.trim()),
|
||||
key: const Key('player-opening-poster'),
|
||||
fit: widget.fit,
|
||||
cacheWidth: resolveImageDecodePixels(
|
||||
constraints.maxWidth,
|
||||
pixelRatio,
|
||||
),
|
||||
cacheHeight: resolveImageDecodePixels(
|
||||
constraints.maxHeight,
|
||||
pixelRatio,
|
||||
),
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: (_, _, _) => _handlePosterError(),
|
||||
);
|
||||
},
|
||||
)
|
||||
else
|
||||
_spriteOrBlack(),
|
||||
|
||||
@@ -75,7 +75,9 @@ void main() {
|
||||
expect(axis.cues, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('should_keep_only_two_decoded_sprites_in_lru', (tester) async {
|
||||
testWidgets('should_keep_one_sprite_and_reload_after_eviction', (
|
||||
tester,
|
||||
) async {
|
||||
final loader = _FakeSpriteLoader(vtt: _spriteVtt);
|
||||
final service = SpriteThumbnailAxisService(
|
||||
loader: loader,
|
||||
@@ -84,20 +86,37 @@ void main() {
|
||||
addTearDown(service.dispose);
|
||||
|
||||
await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero);
|
||||
expect(service.spriteCacheSize, 2);
|
||||
expect(
|
||||
service.spriteCacheKeys,
|
||||
containsAll(['sprite_001.jpg', 'sprite_002.jpg']),
|
||||
);
|
||||
|
||||
await service.prepareAt(
|
||||
'https://cdn.test/thumbnails.vtt',
|
||||
const Duration(milliseconds: 2500),
|
||||
);
|
||||
await service.prepareAt(
|
||||
'https://cdn.test/thumbnails.vtt',
|
||||
const Duration(milliseconds: 3500),
|
||||
);
|
||||
|
||||
expect(service.spriteCacheSize, 2);
|
||||
expect(service.spriteCacheKeys, contains('sprite_002.jpg'));
|
||||
expect(service.spriteCacheKeys, isNot(contains('sprite_001.jpg')));
|
||||
expect(service.spriteCacheSize, 1);
|
||||
expect(service.spriteCacheKeys, ['sprite_003.jpg']);
|
||||
expect(loader.images[0].debugDisposed, isTrue);
|
||||
expect(loader.images[1].debugDisposed, isTrue);
|
||||
expect(loader.images[2].debugDisposed, isFalse);
|
||||
expect(
|
||||
service.cachedFrameAt(
|
||||
'https://cdn.test/thumbnails.vtt',
|
||||
const Duration(milliseconds: 3500),
|
||||
),
|
||||
isNotNull,
|
||||
);
|
||||
|
||||
await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero);
|
||||
expect(service.spriteCacheSize, 1);
|
||||
expect(service.spriteCacheKeys, ['sprite_001.jpg']);
|
||||
expect(loader.images[2].debugDisposed, isTrue);
|
||||
expect(loader.images[3].debugDisposed, isFalse);
|
||||
expect(
|
||||
service.cachedFrameAt('https://cdn.test/thumbnails.vtt', Duration.zero),
|
||||
isNotNull,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('should_cache_vtt_and_retry_once_after_403', (tester) async {
|
||||
@@ -229,6 +248,7 @@ class _FakeSpriteLoader implements SpriteThumbnailAssetLoader {
|
||||
final List<Object>? vttResponses;
|
||||
int vttCalls = 0;
|
||||
final List<String> spriteCalls = [];
|
||||
final List<ui.Image> images = [];
|
||||
|
||||
@override
|
||||
Future<String?> loadVtt(Uri uri) async {
|
||||
@@ -244,6 +264,7 @@ class _FakeSpriteLoader implements SpriteThumbnailAssetLoader {
|
||||
Future<SpriteThumbnailDecodedImage?> loadSprite(Uri uri) async {
|
||||
spriteCalls.add(uri.pathSegments.last);
|
||||
final image = await _createImage(320, 180);
|
||||
images.add(image);
|
||||
return SpriteThumbnailDecodedImage(
|
||||
image: image,
|
||||
originalWidth: 320,
|
||||
|
||||
@@ -422,6 +422,42 @@ void main() {
|
||||
await _drain(logic);
|
||||
});
|
||||
|
||||
test('should_coalesce_rapid_page_selection_and_keep_pool_bounded', () async {
|
||||
final factory = _RecordingFactory();
|
||||
final playback = FakePlaybackRepository();
|
||||
final logic = _logic(FakeWatchHistory(), playback: playback);
|
||||
logic.attach(
|
||||
PlayerPage(
|
||||
drama: _drama('rapid', numbers: const [1, 2, 3, 4, 5, 6]),
|
||||
initialIndex: 0,
|
||||
),
|
||||
isMounted: () => true,
|
||||
);
|
||||
logic.start(factory: factory.create, coordinator: PlaybackCoordinator());
|
||||
await _drain(logic);
|
||||
|
||||
await Future.wait([
|
||||
for (var index = 1; index <= 5; index++)
|
||||
logic.requestEpisodeSelection(index),
|
||||
]);
|
||||
await _drain(logic);
|
||||
|
||||
expect(logic.session.currentIndex, 5);
|
||||
expect(playback.requests, containsAll(['rapid-1', 'rapid-2', 'rapid-6']));
|
||||
expect(playback.requests, isNot(contains('rapid-3')));
|
||||
expect(playback.requests, isNot(contains('rapid-4')));
|
||||
expect(playback.requests, isNot(contains('rapid-5')));
|
||||
expect(logic.session.livePlayerCount, lessThanOrEqualTo(3));
|
||||
expect(factory.live, lessThanOrEqualTo(3));
|
||||
|
||||
await logic.requestEpisodeSelection(0);
|
||||
await _drain(logic);
|
||||
expect(logic.session.currentIndex, 0);
|
||||
|
||||
logic.dispose();
|
||||
await _drain(logic);
|
||||
});
|
||||
|
||||
group('surface flags', () {
|
||||
test('should_set_immersive_when_picture_in_picture_callback_fires', () async {
|
||||
final factory = _RecordingFactory();
|
||||
|
||||
@@ -64,6 +64,38 @@ void main() {
|
||||
expect(loader.vttCalls, 0);
|
||||
});
|
||||
|
||||
testWidgets('should_decode_fullscreen_poster_at_screen_width', (
|
||||
tester,
|
||||
) async {
|
||||
final service = SpriteThumbnailAxisService(loader: _FakeSpriteLoader());
|
||||
addTearDown(service.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData(devicePixelRatio: 3),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 390,
|
||||
height: 844,
|
||||
child: PlayerOpeningFrame(
|
||||
posterUrl: 'https://cdn.test/poster.jpg',
|
||||
thumbnailsUrl: '',
|
||||
spriteSource: service,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final image = tester.widget<Image>(
|
||||
find.byKey(const Key('player-opening-poster')),
|
||||
);
|
||||
final provider = image.image as ResizeImage;
|
||||
expect(provider.width, 1170);
|
||||
});
|
||||
|
||||
testWidgets('should_return_no_opening_frame_when_thumbnails_is_empty', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:fruitshorts/core/widgets/remote_image.dart';
|
||||
|
||||
void main() {
|
||||
test('should_bound_layout_pixels_for_small_and_large_extents', () {
|
||||
expect(resolveImageDecodePixels(12, 3), 96);
|
||||
expect(resolveImageDecodePixels(120, 3), 360);
|
||||
expect(resolveImageDecodePixels(4000, 3), 2048);
|
||||
});
|
||||
|
||||
testWidgets('should_decode_grid_cover_at_layout_width_times_dpr', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: const MediaQueryData(devicePixelRatio: 3),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 120,
|
||||
height: 160,
|
||||
child: RemoteImage('/files/grid-cover.jpg'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final image = tester.widget<Image>(find.byType(Image));
|
||||
final provider = image.image as ResizeImage;
|
||||
expect(provider.width, 360);
|
||||
expect(provider.height, 480);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user