feat(detail): show episode file sizes
This commit is contained in:
@@ -3,6 +3,7 @@ import '../media/media_stream.dart';
|
|||||||
import '../media/media_version.dart';
|
import '../media/media_version.dart';
|
||||||
import 'codec_utils.dart';
|
import 'codec_utils.dart';
|
||||||
import 'resolution_label.dart';
|
import 'resolution_label.dart';
|
||||||
|
import 'formatters.dart';
|
||||||
|
|
||||||
List<String> buildMediaQualityLabels(MediaItem item, {int versionIndex = 0}) {
|
List<String> buildMediaQualityLabels(MediaItem item, {int versionIndex = 0}) {
|
||||||
final version = _selectedVersion(item.mediaVersions, versionIndex);
|
final version = _selectedVersion(item.mediaVersions, versionIndex);
|
||||||
@@ -26,6 +27,20 @@ List<String> buildMediaQualityLabels(MediaItem item, {int versionIndex = 0}) {
|
|||||||
return labels;
|
return labels;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String? buildMediaSizeLabel(MediaItem item, {int versionIndex = 0}) {
|
||||||
|
final version = _selectedVersion(item.mediaVersions, versionIndex);
|
||||||
|
if (version == null || version.parts.isEmpty) return null;
|
||||||
|
|
||||||
|
var totalBytes = 0;
|
||||||
|
for (final part in version.parts) {
|
||||||
|
final sizeBytes = part.sizeBytes;
|
||||||
|
if (sizeBytes == null || sizeBytes <= 0) return null;
|
||||||
|
totalBytes += sizeBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ByteFormatter.formatBytes(totalBytes);
|
||||||
|
}
|
||||||
|
|
||||||
String _formatDolbyVision(MediaStream stream) {
|
String _formatDolbyVision(MediaStream stream) {
|
||||||
final profile = stream.dolbyVisionProfile;
|
final profile = stream.dolbyVisionProfile;
|
||||||
return profile == null || profile <= 0 ? 'DV' : 'DV P$profile';
|
return profile == null || profile <= 0 ? 'DV' : 'DV P$profile';
|
||||||
@@ -33,7 +48,9 @@ String _formatDolbyVision(MediaStream stream) {
|
|||||||
|
|
||||||
MediaVersion? _selectedVersion(List<MediaVersion>? versions, int versionIndex) {
|
MediaVersion? _selectedVersion(List<MediaVersion>? versions, int versionIndex) {
|
||||||
if (versions == null || versions.isEmpty) return null;
|
if (versions == null || versions.isEmpty) return null;
|
||||||
if (versionIndex >= 0 && versionIndex < versions.length) return versions[versionIndex];
|
if (versionIndex >= 0 && versionIndex < versions.length) {
|
||||||
|
return versions[versionIndex];
|
||||||
|
}
|
||||||
return versions.first;
|
return versions.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
|
|||||||
Widget _buildContent(BuildContext context, {required bool hideSpoilers}) {
|
Widget _buildContent(BuildContext context, {required bool hideSpoilers}) {
|
||||||
final episode = _effectiveEpisode(context);
|
final episode = _effectiveEpisode(context);
|
||||||
final shouldBlur = hideSpoilers && episode.shouldHideSpoiler;
|
final shouldBlur = hideSpoilers && episode.shouldHideSpoiler;
|
||||||
final qualityLabels = buildMediaQualityLabels(episode);
|
final qualityLabels = [...buildMediaQualityLabels(episode), ?buildMediaSizeLabel(episode)];
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
|||||||
@@ -214,6 +214,59 @@ void main() {
|
|||||||
expect(buildMediaQualityLabels(_episodeWithVersion(null)), isEmpty);
|
expect(buildMediaQualityLabels(_episodeWithVersion(null)), isEmpty);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
group('buildMediaSizeLabel', () {
|
||||||
|
test('formats the complete size of a multi-part version', () {
|
||||||
|
final item = _episodeWithVersion(
|
||||||
|
const MediaVersion(
|
||||||
|
id: '1',
|
||||||
|
parts: [
|
||||||
|
MediaPart(id: 'part-1', sizeBytes: 512 * 1024 * 1024),
|
||||||
|
MediaPart(id: 'part-2', sizeBytes: 1024 * 1024 * 1024),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(buildMediaSizeLabel(item), '1.50 GB');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('omits the size when any part is missing a valid size', () {
|
||||||
|
for (final invalidSize in <int?>[null, 0, -1]) {
|
||||||
|
final item = _episodeWithVersion(
|
||||||
|
MediaVersion(
|
||||||
|
id: '1',
|
||||||
|
parts: [
|
||||||
|
const MediaPart(id: 'known', sizeBytes: 1024),
|
||||||
|
MediaPart(id: 'unknown', sizeBytes: invalidSize),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(buildMediaSizeLabel(item), isNull);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('uses the same requested version index as quality labels', () {
|
||||||
|
final item = testMediaItem(
|
||||||
|
id: 'episode-1',
|
||||||
|
backend: MediaBackend.plex,
|
||||||
|
kind: MediaKind.episode,
|
||||||
|
title: 'Episode',
|
||||||
|
mediaVersions: const [
|
||||||
|
MediaVersion(
|
||||||
|
id: 'small',
|
||||||
|
parts: [MediaPart(id: 'small-part', sizeBytes: 1024 * 1024 * 1024)],
|
||||||
|
),
|
||||||
|
MediaVersion(
|
||||||
|
id: 'large',
|
||||||
|
parts: [MediaPart(id: 'large-part', sizeBytes: 2 * 1024 * 1024 * 1024)],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(buildMediaSizeLabel(item), '1.00 GB');
|
||||||
|
expect(buildMediaSizeLabel(item, versionIndex: 1), '2.00 GB');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaItem _episodeWithVersion(MediaVersion? version) {
|
MediaItem _episodeWithVersion(MediaVersion? version) {
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import 'package:plezy/database/app_database.dart';
|
|||||||
import 'package:plezy/i18n/strings.g.dart';
|
import 'package:plezy/i18n/strings.g.dart';
|
||||||
import 'package:plezy/media/media_backend.dart';
|
import 'package:plezy/media/media_backend.dart';
|
||||||
import 'package:plezy/media/media_kind.dart';
|
import 'package:plezy/media/media_kind.dart';
|
||||||
|
import 'package:plezy/media/media_item.dart';
|
||||||
|
import 'package:plezy/media/media_part.dart';
|
||||||
|
import 'package:plezy/media/media_stream.dart';
|
||||||
|
import 'package:plezy/media/media_version.dart';
|
||||||
import 'package:plezy/providers/download_provider.dart';
|
import 'package:plezy/providers/download_provider.dart';
|
||||||
import 'package:plezy/services/download_manager_service.dart';
|
import 'package:plezy/services/download_manager_service.dart';
|
||||||
import 'package:plezy/services/download_storage_service.dart';
|
import 'package:plezy/services/download_storage_service.dart';
|
||||||
@@ -50,6 +54,65 @@ void main() {
|
|||||||
durationMs: 42 * 60 * 1000,
|
durationMs: 42 * 60 * 1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await _pumpEpisodeCard(tester, episode);
|
||||||
|
|
||||||
|
final summaryText = tester.widget<Text>(
|
||||||
|
find.descendant(of: find.byType(CollapsibleText), matching: find.byType(Text)).first,
|
||||||
|
);
|
||||||
|
expect(summaryText.textSpan, isNotNull);
|
||||||
|
expect(summaryText.textSpan!.toPlainText(), isNot(summary));
|
||||||
|
|
||||||
|
final semanticNodes = <SemanticsNode>[];
|
||||||
|
void collectSemantics(SemanticsNode node) {
|
||||||
|
semanticNodes.add(node);
|
||||||
|
node.visitChildren((child) {
|
||||||
|
collectSemantics(child);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
collectSemantics(tester.binding.renderViews.single.owner!.semanticsOwner!.rootSemanticsNode!);
|
||||||
|
final cardSemantics = semanticNodes.singleWhere((node) => node.label.contains('A Difficult Crossing'));
|
||||||
|
expect(cardSemantics.label, contains('The expedition follows a careful team'));
|
||||||
|
expect(cardSemantics.label, isNot(contains('Expand')));
|
||||||
|
expect(cardSemantics.getSemanticsData().hasAction(SemanticsAction.tap), isTrue);
|
||||||
|
semantics.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows file size alongside media quality labels', (tester) async {
|
||||||
|
final episode = testMediaItem(
|
||||||
|
id: 'sized_episode',
|
||||||
|
backend: MediaBackend.plex,
|
||||||
|
kind: MediaKind.episode,
|
||||||
|
title: 'A Large Episode',
|
||||||
|
index: 4,
|
||||||
|
durationMs: 52 * 60 * 1000,
|
||||||
|
mediaVersions: const [
|
||||||
|
MediaVersion(
|
||||||
|
id: 'source',
|
||||||
|
videoResolution: '1080',
|
||||||
|
parts: [
|
||||||
|
MediaPart(
|
||||||
|
id: 'part-1',
|
||||||
|
sizeBytes: 1536 * 1024 * 1024,
|
||||||
|
streams: [
|
||||||
|
MediaStream(id: 'audio', kind: MediaStreamKind.audio, codec: 'eac3', channels: 6, selected: true),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await _pumpEpisodeCard(tester, episode);
|
||||||
|
|
||||||
|
final metadataWrap = find.ancestor(of: find.text('1.50 GB'), matching: find.byType(Wrap));
|
||||||
|
expect(metadataWrap, findsOneWidget);
|
||||||
|
expect(find.descendant(of: metadataWrap, matching: find.text('EAC3 5.1')), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pumpEpisodeCard(WidgetTester tester, MediaItem episode) async {
|
||||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
PlexApiCache.initialize(db);
|
PlexApiCache.initialize(db);
|
||||||
JellyfinApiCache.initialize(db);
|
JellyfinApiCache.initialize(db);
|
||||||
@@ -84,27 +147,4 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|
||||||
final summaryText = tester.widget<Text>(
|
|
||||||
find.descendant(of: find.byType(CollapsibleText), matching: find.byType(Text)).first,
|
|
||||||
);
|
|
||||||
expect(summaryText.textSpan, isNotNull);
|
|
||||||
expect(summaryText.textSpan!.toPlainText(), isNot(summary));
|
|
||||||
|
|
||||||
final semanticNodes = <SemanticsNode>[];
|
|
||||||
void collectSemantics(SemanticsNode node) {
|
|
||||||
semanticNodes.add(node);
|
|
||||||
node.visitChildren((child) {
|
|
||||||
collectSemantics(child);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
collectSemantics(tester.binding.renderViews.single.owner!.semanticsOwner!.rootSemanticsNode!);
|
|
||||||
final cardSemantics = semanticNodes.singleWhere((node) => node.label.contains('A Difficult Crossing'));
|
|
||||||
expect(cardSemantics.label, contains('The expedition follows a careful team'));
|
|
||||||
expect(cardSemantics.label, isNot(contains('Expand')));
|
|
||||||
expect(cardSemantics.getSemanticsData().hasAction(SemanticsAction.tap), isTrue);
|
|
||||||
semantics.dispose();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user