feat(detail): show episode file sizes

This commit is contained in:
edde746
2026-07-18 13:32:40 +02:00
parent a1b6a89714
commit 46dfc7f992
4 changed files with 146 additions and 36 deletions
+18 -1
View File
@@ -3,6 +3,7 @@ import '../media/media_stream.dart';
import '../media/media_version.dart';
import 'codec_utils.dart';
import 'resolution_label.dart';
import 'formatters.dart';
List<String> buildMediaQualityLabels(MediaItem item, {int versionIndex = 0}) {
final version = _selectedVersion(item.mediaVersions, versionIndex);
@@ -26,6 +27,20 @@ List<String> buildMediaQualityLabels(MediaItem item, {int versionIndex = 0}) {
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) {
final profile = stream.dolbyVisionProfile;
return profile == null || profile <= 0 ? 'DV' : 'DV P$profile';
@@ -33,7 +48,9 @@ String _formatDolbyVision(MediaStream stream) {
MediaVersion? _selectedVersion(List<MediaVersion>? versions, int versionIndex) {
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;
}
+1 -1
View File
@@ -125,7 +125,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
Widget _buildContent(BuildContext context, {required bool hideSpoilers}) {
final episode = _effectiveEpisode(context);
final shouldBlur = hideSpoilers && episode.shouldHideSpoiler;
final qualityLabels = buildMediaQualityLabels(episode);
final qualityLabels = [...buildMediaQualityLabels(episode), ?buildMediaSizeLabel(episode)];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
+53
View File
@@ -214,6 +214,59 @@ void main() {
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) {
+63 -23
View File
@@ -6,6 +6,10 @@ import 'package:plezy/database/app_database.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_backend.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/services/download_manager_service.dart';
import 'package:plezy/services/download_storage_service.dart';
@@ -50,6 +54,65 @@ void main() {
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());
PlexApiCache.initialize(db);
JellyfinApiCache.initialize(db);
@@ -84,27 +147,4 @@ void main() {
),
);
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();
});
}