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.
|
// Includes initialization and asynchronous disposal, until native release ends.
|
||||||
int get livePlayerCount => _live.length;
|
int get livePlayerCount => _live.length;
|
||||||
int? get preloadedIndex => _next?.prepared == true ? _next?.index : null;
|
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 playbackSettled => _operations;
|
||||||
Future<void> get preloadSettled => _preloadTask;
|
Future<void> get preloadSettled => _preloadTask;
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import '../../config/app_config.dart';
|
|||||||
import 'sprite_thumbnail_asset_loader.dart';
|
import 'sprite_thumbnail_asset_loader.dart';
|
||||||
|
|
||||||
const Duration spriteVttCacheTtl = Duration(minutes: 10);
|
const Duration spriteVttCacheTtl = Duration(minutes: 10);
|
||||||
const int spriteImageCacheCapacity = 2;
|
const int spriteImageCacheCapacity = 1;
|
||||||
|
|
||||||
/// 单个 VTT cue 对应的 sprite 裁剪区域。
|
/// 单个 VTT cue 对应的 sprite 裁剪区域。
|
||||||
class SpriteThumbnailCue {
|
class SpriteThumbnailCue {
|
||||||
@@ -148,7 +148,6 @@ class SpriteThumbnailAxisService extends ChangeNotifier
|
|||||||
final Set<String> _failedSprites = {};
|
final Set<String> _failedSprites = {};
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
|
|
||||||
@visibleForTesting
|
|
||||||
int get spriteCacheSize => _spriteCache.length;
|
int get spriteCacheSize => _spriteCache.length;
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
@@ -156,7 +155,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier
|
|||||||
.map((url) => Uri.tryParse(url)?.pathSegments.lastOrNull ?? url)
|
.map((url) => Uri.tryParse(url)?.pathSegments.lastOrNull ?? url)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
/// 播放器就绪时预取当前 cue 与相邻 sprite,拖动帧不触发网络或解码。
|
/// 播放器就绪时预取当前 cue;容量大于 1 时顺带预取相邻 sprite。
|
||||||
@override
|
@override
|
||||||
Future<void> prepareAt(String? thumbnailsUrl, Duration position) async {
|
Future<void> prepareAt(String? thumbnailsUrl, Duration position) async {
|
||||||
final url = _normalizeUrl(thumbnailsUrl);
|
final url = _normalizeUrl(thumbnailsUrl);
|
||||||
@@ -269,6 +268,7 @@ class SpriteThumbnailAxisService extends ChangeNotifier
|
|||||||
if (current == null) return const [];
|
if (current == null) return const [];
|
||||||
final index = axis.cues.indexOf(current);
|
final index = axis.cues.indexOf(current);
|
||||||
final candidates = <SpriteThumbnailCue>[current];
|
final candidates = <SpriteThumbnailCue>[current];
|
||||||
|
if (spriteCacheCapacity <= 1) return candidates;
|
||||||
for (final direction in const [1, -1]) {
|
for (final direction in const [1, -1]) {
|
||||||
for (var step = 1; step < axis.cues.length; step++) {
|
for (var step = 1; step < axis.cues.length; step++) {
|
||||||
final neighborIndex = index + direction * step;
|
final neighborIndex = index + direction * step;
|
||||||
|
|||||||
@@ -5,6 +5,24 @@ import 'package:flutter/material.dart';
|
|||||||
import '../config/app_config.dart';
|
import '../config/app_config.dart';
|
||||||
import '../theme/app_colors.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 {
|
class RemoteImage extends StatelessWidget {
|
||||||
const RemoteImage(
|
const RemoteImage(
|
||||||
@@ -17,6 +35,8 @@ class RemoteImage extends StatelessWidget {
|
|||||||
this.fallbackIconKey,
|
this.fallbackIconKey,
|
||||||
this.fallbackAspectRatio,
|
this.fallbackAspectRatio,
|
||||||
this.fallbackColor = AppColors.raised,
|
this.fallbackColor = AppColors.raised,
|
||||||
|
this.decodeWidth,
|
||||||
|
this.decodeHeight,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String path;
|
final String path;
|
||||||
@@ -27,21 +47,56 @@ class RemoteImage extends StatelessWidget {
|
|||||||
final Key? fallbackIconKey;
|
final Key? fallbackIconKey;
|
||||||
final double? fallbackAspectRatio;
|
final double? fallbackAspectRatio;
|
||||||
final Color fallbackColor;
|
final Color fallbackColor;
|
||||||
|
final int? decodeWidth;
|
||||||
|
final int? decodeHeight;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final url = AppConfig.mediaUrl(path);
|
final url = AppConfig.mediaUrl(path);
|
||||||
if (url.isEmpty) return _fallback(brand: showFallbackIcon);
|
if (url.isEmpty) return _fallback(brand: showFallbackIcon);
|
||||||
return Image.network(
|
return LayoutBuilder(
|
||||||
url,
|
builder: (context, constraints) {
|
||||||
width: width,
|
final pixelRatio = MediaQuery.devicePixelRatioOf(context);
|
||||||
height: height,
|
final layoutWidth = constraints.hasBoundedWidth
|
||||||
fit: fit,
|
? constraints.maxWidth
|
||||||
gaplessPlayback: true,
|
: width;
|
||||||
errorBuilder: (_, _, _) => _fallback(brand: showFallbackIcon),
|
final aspectRatio =
|
||||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
fallbackAspectRatio != null &&
|
||||||
if (wasSynchronouslyLoaded || frame != null) return child;
|
fallbackAspectRatio!.isFinite &&
|
||||||
return _fallback(brand: showFallbackIcon);
|
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';
|
String selectedQuality = 'AUTO';
|
||||||
Duration? _qualitySeek;
|
Duration? _qualitySeek;
|
||||||
int pageRequest = 0;
|
int pageRequest = 0;
|
||||||
|
int? _pendingSelectionIndex;
|
||||||
|
int? _pendingSelectionRevision;
|
||||||
|
Future<void>? _selectionDrain;
|
||||||
bool muted = false;
|
bool muted = false;
|
||||||
double playbackSpeed = 1.0;
|
double playbackSpeed = 1.0;
|
||||||
bool stoppedAtFeedEnd = false;
|
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 {
|
Future<void> selectEpisode(int index, {int? revision}) async {
|
||||||
final feedRevisionAtStart = revision ?? feedRevision;
|
final feedRevisionAtStart = revision ?? feedRevision;
|
||||||
final request = ++pageRequest;
|
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) {
|
void _setScrubbing(bool value) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (_scrubbing == value) return;
|
if (_scrubbing == value) return;
|
||||||
@@ -507,7 +529,7 @@ class _PlayerPageState extends State<PlayerPage>
|
|||||||
onPageChanged: (position) {
|
onPageChanged: (position) {
|
||||||
final index = indices[position];
|
final index = indices[position];
|
||||||
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
|
widget.onDramaChanged?.call(logic.queue.entryAt(index).drama);
|
||||||
unawaited(logic.selectEpisode(index));
|
unawaited(_selectPage(index));
|
||||||
if (position >= indices.length - 3) {
|
if (position >= indices.length - 3) {
|
||||||
final loadMore = widget.onLoadMore;
|
final loadMore = widget.onLoadMore;
|
||||||
if (loadMore != null) unawaited(loadMore());
|
if (loadMore != null) unawaited(loadMore());
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../../core/config/app_config.dart';
|
import '../../../core/config/app_config.dart';
|
||||||
import '../../../core/data/services/sprite_thumbnail_axis.dart';
|
import '../../../core/data/services/sprite_thumbnail_axis.dart';
|
||||||
import '../../../core/theme/app_colors.dart';
|
import '../../../core/theme/app_colors.dart';
|
||||||
|
import '../../../core/widgets/remote_image.dart';
|
||||||
import 'scrub_preview_card.dart';
|
import 'scrub_preview_card.dart';
|
||||||
|
|
||||||
enum PlayerOpeningFallback { handoff, poster, thumbnails, black }
|
enum PlayerOpeningFallback { handoff, poster, thumbnails, black }
|
||||||
@@ -149,12 +150,25 @@ class _PlayerOpeningFrameState extends State<PlayerOpeningFrame> {
|
|||||||
children: [
|
children: [
|
||||||
const ColoredBox(color: AppColors.mediaBlack),
|
const ColoredBox(color: AppColors.mediaBlack),
|
||||||
if (_hasPoster)
|
if (_hasPoster)
|
||||||
Image.network(
|
LayoutBuilder(
|
||||||
AppConfig.mediaUrl(widget.posterUrl!.trim()),
|
builder: (context, constraints) {
|
||||||
key: const Key('player-opening-poster'),
|
final pixelRatio = MediaQuery.devicePixelRatioOf(context);
|
||||||
fit: widget.fit,
|
return Image.network(
|
||||||
gaplessPlayback: true,
|
AppConfig.mediaUrl(widget.posterUrl!.trim()),
|
||||||
errorBuilder: (_, _, _) => _handlePosterError(),
|
key: const Key('player-opening-poster'),
|
||||||
|
fit: widget.fit,
|
||||||
|
cacheWidth: resolveImageDecodePixels(
|
||||||
|
constraints.maxWidth,
|
||||||
|
pixelRatio,
|
||||||
|
),
|
||||||
|
cacheHeight: resolveImageDecodePixels(
|
||||||
|
constraints.maxHeight,
|
||||||
|
pixelRatio,
|
||||||
|
),
|
||||||
|
gaplessPlayback: true,
|
||||||
|
errorBuilder: (_, _, _) => _handlePosterError(),
|
||||||
|
);
|
||||||
|
},
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
_spriteOrBlack(),
|
_spriteOrBlack(),
|
||||||
|
|||||||
@@ -75,7 +75,9 @@ void main() {
|
|||||||
expect(axis.cues, isEmpty);
|
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 loader = _FakeSpriteLoader(vtt: _spriteVtt);
|
||||||
final service = SpriteThumbnailAxisService(
|
final service = SpriteThumbnailAxisService(
|
||||||
loader: loader,
|
loader: loader,
|
||||||
@@ -84,20 +86,37 @@ void main() {
|
|||||||
addTearDown(service.dispose);
|
addTearDown(service.dispose);
|
||||||
|
|
||||||
await service.prepareAt('https://cdn.test/thumbnails.vtt', Duration.zero);
|
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(
|
await service.prepareAt(
|
||||||
'https://cdn.test/thumbnails.vtt',
|
'https://cdn.test/thumbnails.vtt',
|
||||||
const Duration(milliseconds: 2500),
|
const Duration(milliseconds: 2500),
|
||||||
);
|
);
|
||||||
|
await service.prepareAt(
|
||||||
|
'https://cdn.test/thumbnails.vtt',
|
||||||
|
const Duration(milliseconds: 3500),
|
||||||
|
);
|
||||||
|
|
||||||
expect(service.spriteCacheSize, 2);
|
expect(service.spriteCacheSize, 1);
|
||||||
expect(service.spriteCacheKeys, contains('sprite_002.jpg'));
|
expect(service.spriteCacheKeys, ['sprite_003.jpg']);
|
||||||
expect(service.spriteCacheKeys, isNot(contains('sprite_001.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 {
|
testWidgets('should_cache_vtt_and_retry_once_after_403', (tester) async {
|
||||||
@@ -229,6 +248,7 @@ class _FakeSpriteLoader implements SpriteThumbnailAssetLoader {
|
|||||||
final List<Object>? vttResponses;
|
final List<Object>? vttResponses;
|
||||||
int vttCalls = 0;
|
int vttCalls = 0;
|
||||||
final List<String> spriteCalls = [];
|
final List<String> spriteCalls = [];
|
||||||
|
final List<ui.Image> images = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<String?> loadVtt(Uri uri) async {
|
Future<String?> loadVtt(Uri uri) async {
|
||||||
@@ -244,6 +264,7 @@ class _FakeSpriteLoader implements SpriteThumbnailAssetLoader {
|
|||||||
Future<SpriteThumbnailDecodedImage?> loadSprite(Uri uri) async {
|
Future<SpriteThumbnailDecodedImage?> loadSprite(Uri uri) async {
|
||||||
spriteCalls.add(uri.pathSegments.last);
|
spriteCalls.add(uri.pathSegments.last);
|
||||||
final image = await _createImage(320, 180);
|
final image = await _createImage(320, 180);
|
||||||
|
images.add(image);
|
||||||
return SpriteThumbnailDecodedImage(
|
return SpriteThumbnailDecodedImage(
|
||||||
image: image,
|
image: image,
|
||||||
originalWidth: 320,
|
originalWidth: 320,
|
||||||
|
|||||||
@@ -422,6 +422,42 @@ void main() {
|
|||||||
await _drain(logic);
|
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', () {
|
group('surface flags', () {
|
||||||
test('should_set_immersive_when_picture_in_picture_callback_fires', () async {
|
test('should_set_immersive_when_picture_in_picture_callback_fires', () async {
|
||||||
final factory = _RecordingFactory();
|
final factory = _RecordingFactory();
|
||||||
|
|||||||
@@ -64,6 +64,38 @@ void main() {
|
|||||||
expect(loader.vttCalls, 0);
|
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', (
|
testWidgets('should_return_no_opening_frame_when_thumbnails_is_empty', (
|
||||||
tester,
|
tester,
|
||||||
) async {
|
) 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