feat(media): show video and audio quality labels

close #1115
This commit is contained in:
edde746
2026-06-04 08:20:12 +02:00
parent f3e108e867
commit 0e7ff609b6
9 changed files with 631 additions and 41 deletions
+6
View File
@@ -19,6 +19,9 @@ class MediaStream {
// Video
final double? frameRate;
final bool hdr;
final bool dolbyVision;
final int? dolbyVisionProfile;
// Subtitle
final bool forced;
@@ -40,6 +43,9 @@ class MediaStream {
this.selected = false,
this.channels,
this.frameRate,
this.hdr = false,
this.dolbyVision = false,
this.dolbyVisionProfile,
this.forced = false,
this.sidecarPath,
});
+4
View File
@@ -33,6 +33,7 @@ import '../widgets/media_card.dart';
import '../i18n/strings.g.dart';
import '../widgets/optimized_media_image.dart';
import '../utils/media_image_helper.dart';
import '../utils/media_quality_labels.dart';
import '../services/plex_client.dart';
import '../media/media_server_client.dart';
import '../services/media_list_playback_launcher.dart';
@@ -3347,6 +3348,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
Widget _buildTvDetailMetadataLine(BuildContext context, MediaItem metadata, double scale) {
final lineMetadata = _tvDetailFocusedEpisode ?? metadata;
final episodeLabel = formatSeasonEpisodeLabel(lineMetadata.parentIndex, lineMetadata.index);
final qualityLabels = buildMediaQualityLabels(lineMetadata);
final parts = [
if (lineMetadata.isEpisode && episodeLabel != null) episodeLabel,
if (lineMetadata.isMovie) t.discover.movie else if (lineMetadata.isShow) t.discover.tvShow,
@@ -3357,6 +3359,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
formatFullDate(lineMetadata.originallyAvailableAt!)
else if (lineMetadata.year != null)
lineMetadata.year.toString(),
...qualityLabels,
];
return Text(
@@ -3749,6 +3752,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (metadata case PlexMediaItem(:final editionTitle?)) _buildMetadataChip(editionTitle),
if (metadata.contentRating != null) _buildMetadataChip(formatContentRating(metadata.contentRating!)),
if (metadata.durationMs != null) _buildMetadataChip(formatDurationTextual(metadata.durationMs!)),
for (final label in buildMediaQualityLabels(metadata)) _buildMetadataChip(label),
..._buildRatingChips(metadata),
];
+25 -6
View File
@@ -955,8 +955,13 @@ class PlexClient
AbortController? abort,
int? librarySectionID,
String? librarySectionTitle,
Map<String, dynamic>? queryParameters,
}) async {
final response = await _getWithFailover(path, queryParameters: _buildPaginationParams(start, size), abort: abort);
final response = await _getWithFailover(
path,
queryParameters: {...?queryParameters, ..._buildPaginationParams(start, size)},
abort: abort,
);
return _extractLibraryContentResult(
response,
librarySectionID: librarySectionID,
@@ -1037,7 +1042,13 @@ class PlexClient
cacheKey: cacheKey,
networkCall: () => _http.get(
'/library/metadata/$ratingKey',
queryParameters: {'includeChapters': 1, 'includeMarkers': 1, 'includeOnDeck': 1},
queryParameters: {
'includeChapters': 1,
'includeMarkers': 1,
'includeOnDeck': 1,
'checkFiles': 1,
'includeStreams': 1,
},
),
parseCache: (cachedData) {
final metadata = _parseMetadataWithImagesFromCachedResponse(cachedData);
@@ -1092,8 +1103,10 @@ class PlexClient
return fetchWithCacheFallback<PlexMetadataDto>(
cacheKey: cacheKey,
networkCall: () =>
_http.get('/library/metadata/$ratingKey', queryParameters: {'includeChapters': 1, 'includeMarkers': 1}),
networkCall: () => _http.get(
'/library/metadata/$ratingKey',
queryParameters: {'includeChapters': 1, 'includeMarkers': 1, 'checkFiles': 1, 'includeStreams': 1},
),
parseCache: (cachedData) => _parseMetadataWithImagesFromCachedResponse(cachedData),
parseResponse: (response) {
final container = _getMediaContainer(response);
@@ -1406,7 +1419,7 @@ class PlexClient
return await fetchWithCacheFallback<List<PlexMetadataDto>>(
cacheKey: endpoint,
networkCall: () => _http.get(endpoint),
networkCall: () => _http.get(endpoint, queryParameters: {'includeStreams': 1}),
parseCache: (cachedData) => _parseMetadataListFromCachedResponse(cachedData),
parseResponse: (response) => _extractMetadataList(response),
) ??
@@ -1421,7 +1434,13 @@ class PlexClient
int? start,
int? size,
AbortController? abort,
}) => _fetchPaginatedList('/library/metadata/$ratingKey/grandchildren', start: start, size: size, abort: abort);
}) => _fetchPaginatedList(
'/library/metadata/$ratingKey/grandchildren',
start: start,
size: size,
abort: abort,
queryParameters: {'includeStreams': 1},
);
/// Get extras for a metadata item (trailers, behind-the-scenes, etc.)
/// Uses cache when offline or as fallback on network error
+151 -4
View File
@@ -25,12 +25,14 @@ import '../media/media_part.dart';
import '../media/media_playlist.dart';
import '../media/media_role.dart';
import '../media/media_source_info.dart';
import '../media/media_stream.dart';
import '../media/media_version.dart';
import '../utils/app_logger.dart';
import '../utils/global_key_utils.dart';
import '../utils/json_utils.dart';
import '../utils/obfuscation_utils.dart';
import 'file_info_parser.dart';
import 'plex_constants.dart';
part 'plex_mappers.g.dart';
@@ -68,7 +70,12 @@ bool? _partAccessibleFromJson(Object? raw) => flexibleBoolNullable(_firstPartMap
bool? _partExistsFromJson(Object? raw) => flexibleBoolNullable(_firstPartMap(raw)?['exists']);
MediaPart _mediaPartFromMap(Map json, {required String fallbackId, String? fallbackContainer}) {
MediaPart _mediaPartFromMap(
Map json, {
required String fallbackId,
String? fallbackContainer,
Map<String, dynamic>? media,
}) {
return MediaPart(
id: (json['id'] ?? fallbackId).toString(),
streamPath: json['key']?.toString(),
@@ -77,10 +84,16 @@ MediaPart _mediaPartFromMap(Map json, {required String fallbackId, String? fallb
durationMs: flexibleInt(json['duration']),
accessible: flexibleBoolNullable(json['accessible']),
exists: flexibleBoolNullable(json['exists']),
streams: _mediaStreamsFromPlexPart(json['Stream'], media: media, part: json),
);
}
List<MediaPart> _mediaPartsFromJson(Object? raw, {required String fallbackId, String? fallbackContainer}) {
List<MediaPart> _mediaPartsFromJson(
Object? raw, {
required String fallbackId,
String? fallbackContainer,
Map<String, dynamic>? media,
}) {
final partMaps = _partMaps(raw);
if (partMaps.isEmpty) return const [];
return [
@@ -89,16 +102,141 @@ List<MediaPart> _mediaPartsFromJson(Object? raw, {required String fallbackId, St
partMaps[i],
fallbackId: i == 0 ? fallbackId : '$fallbackId:$i',
fallbackContainer: fallbackContainer,
media: media,
),
];
}
List<MediaStream> _mediaStreamsFromPlexPart(Object? raw, {Map<String, dynamic>? media, Map? part}) {
final streams = flexibleList(raw);
if (streams == null || streams.isEmpty) return _fallbackStreamsFromPlexMedia(media, part);
final result = <MediaStream>[];
for (final rawStream in streams) {
if (rawStream is! Map) continue;
final stream = Map<String, dynamic>.from(rawStream);
final kind = _mediaStreamKindFromPlexType(stream['streamType']);
if (kind == MediaStreamKind.unknown) continue;
final criteria = kind == MediaStreamKind.video ? PlexMappers.displayCriteriaFromJson(media, stream) : null;
final isDolbyVision = kind == MediaStreamKind.video && _isDolbyVisionStream(stream);
final isHdr = criteria?.isHdr == true || isDolbyVision;
final doviProfile = isDolbyVision ? flexibleInt(stream['DOVIProfile']) : null;
result.add(
MediaStream(
id: (stream['id'] ?? result.length).toString(),
kind: kind,
index: flexibleInt(stream['index']),
codec: stream['codec']?.toString(),
language: stream['language']?.toString(),
languageCode: stream['languageCode']?.toString(),
title: stream['title']?.toString(),
displayTitle: stream['displayTitle']?.toString() ?? stream['extendedDisplayTitle']?.toString(),
selected: flexibleBool(stream['selected']) || flexibleBool(stream['default']),
channels: flexibleInt(stream['channels']),
frameRate: flexibleDouble(stream['frameRate']),
hdr: isHdr,
dolbyVision: isDolbyVision,
dolbyVisionProfile: doviProfile,
forced: flexibleBool(stream['forced']),
sidecarPath: kind == MediaStreamKind.subtitle ? stream['key']?.toString() : null,
),
);
}
final fallback = _fallbackStreamsFromPlexMedia(media, part);
for (final stream in fallback) {
if (!result.any((parsed) => parsed.kind == stream.kind)) result.add(stream);
}
return result;
}
List<MediaStream> _fallbackStreamsFromPlexMedia(Map<String, dynamic>? media, Map? part) {
if (media == null) return const [];
final result = <MediaStream>[];
final displayHints = _mediaDisplayHintTokens(media, part);
final hasDolbyVision =
displayHints.contains('dv') || displayHints.contains('dovi') || displayHints.contains('dolbyvision');
final hasHdr =
hasDolbyVision ||
displayHints.contains('hdr') ||
displayHints.contains('hdr10') ||
displayHints.contains('hdr10plus') ||
displayHints.contains('hlg');
if (hasHdr || hasDolbyVision) {
result.add(
MediaStream(
id: '${media['id'] ?? 'video'}:video',
kind: MediaStreamKind.video,
codec: media['videoCodec']?.toString(),
hdr: hasHdr,
dolbyVision: hasDolbyVision,
),
);
}
final audioCodec = media['audioCodec']?.toString();
final audioChannels = flexibleInt(media['audioChannels']);
if ((audioCodec != null && audioCodec.isNotEmpty) || audioChannels != null) {
result.add(
MediaStream(
id: '${media['id'] ?? 'audio'}:audio',
kind: MediaStreamKind.audio,
codec: audioCodec,
channels: audioChannels,
selected: true,
),
);
}
return result;
}
Set<String> _mediaDisplayHintTokens(Map<String, dynamic> media, Map? part) {
final text = [
media['videoProfile'],
media['displayTitle'],
media['extendedDisplayTitle'],
part?['file'],
].whereType<Object>().map((value) => value.toString()).join(' ');
return RegExp(r'[a-z0-9]+').allMatches(text.toLowerCase()).map((match) => match.group(0)!).toSet();
}
MediaStreamKind _mediaStreamKindFromPlexType(Object? raw) {
return switch (flexibleInt(raw)) {
PlexStreamType.video => MediaStreamKind.video,
PlexStreamType.audio => MediaStreamKind.audio,
PlexStreamType.subtitle => MediaStreamKind.subtitle,
_ => MediaStreamKind.unknown,
};
}
bool _isDolbyVisionStream(Map<String, dynamic> stream) {
return (flexibleInt(stream['DOVIProfile']) ?? 0) > 0 || flexibleBool(stream['DOVIPresent']);
}
Object? _readPartKey(Map json, String _) => _partKeyFromJson(json['Part']);
Object? _readPartAccessible(Map json, String _) => _partAccessibleFromJson(json['Part']);
Object? _readPartExists(Map json, String _) => _partExistsFromJson(json['Part']);
Object? _readMediaParts(Map json, String _) {
return _mediaPartsFromJson(
json['Part'],
fallbackId: (json['id'] ?? '').toString(),
fallbackContainer: json['container']?.toString(),
media: Map<String, dynamic>.from(json),
);
}
List<MediaPart> _mediaPartsFromReadValue(Object? raw) {
if (raw is List<MediaPart>) return raw;
return const [];
}
String _hubTitleFromJson(Object? raw) {
final title = raw as String? ?? 'Unknown';
return kBlurArtwork ? obfuscateText(title) : title;
@@ -188,6 +326,8 @@ class PlexMediaVersionDto {
final bool? accessible;
@JsonKey(readValue: _readPartExists)
final bool? exists;
@JsonKey(readValue: _readMediaParts, fromJson: _mediaPartsFromReadValue)
final List<MediaPart> parts;
const PlexMediaVersionDto({
required this.id,
@@ -200,6 +340,7 @@ class PlexMediaVersionDto {
required this.partKey,
this.accessible,
this.exists,
this.parts = const [],
});
factory PlexMediaVersionDto.fromJson(Map<String, dynamic> json) => _$PlexMediaVersionDtoFromJson(json);
@@ -905,6 +1046,7 @@ class PlexMappers {
accessible: dto.accessible,
exists: dto.exists,
);
final parts = dto.parts.isEmpty ? [part] : dto.parts;
return MediaVersion(
id: dto.id.toString(),
width: dto.width,
@@ -913,14 +1055,19 @@ class PlexMappers {
videoCodec: dto.videoCodec,
bitrate: dto.bitrate,
container: dto.container,
parts: [part],
parts: parts,
);
}
/// Map a Plex Media JSON entry directly into a [MediaVersion].
static MediaVersion mediaVersionFromJson(Map<String, dynamic> json) {
final dto = PlexMediaVersionDto.fromJson(json);
final parts = _mediaPartsFromJson(json['Part'], fallbackId: dto.id.toString(), fallbackContainer: dto.container);
final parts = _mediaPartsFromJson(
json['Part'],
fallbackId: dto.id.toString(),
fallbackContainer: dto.container,
media: json,
);
if (parts.isEmpty) return mediaVersion(dto);
return MediaVersion(
id: dto.id.toString(),
+3
View File
@@ -28,6 +28,9 @@ PlexMediaVersionDto _$PlexMediaVersionDtoFromJson(Map<String, dynamic> json) =>
partKey: _readPartKey(json, 'partKey') as String,
accessible: _readPartAccessible(json, 'accessible') as bool?,
exists: _readPartExists(json, 'exists') as bool?,
parts: _readMediaParts(json, 'parts') == null
? const []
: _mediaPartsFromReadValue(_readMediaParts(json, 'parts')),
);
PlexLibraryDto _$PlexLibraryDtoFromJson(Map<String, dynamic> json) =>
+130
View File
@@ -0,0 +1,130 @@
import '../media/media_item.dart';
import '../media/media_stream.dart';
import '../media/media_version.dart';
import 'codec_utils.dart';
import 'resolution_label.dart';
List<String> buildMediaQualityLabels(MediaItem item, {int versionIndex = 0}) {
final version = _selectedVersion(item.mediaVersions, versionIndex);
if (version == null) return const [];
final labels = <String>[];
final resolution = _formatResolution(version);
if (resolution != null) labels.add(resolution);
final video = _firstStreamOfKind(version, MediaStreamKind.video);
if (video?.dolbyVision == true) {
labels.add(_formatDolbyVision(video!));
} else if (video?.hdr == true) {
labels.add('HDR');
}
final audio = _selectedAudioStream(version);
final audioLabel = _formatAudio(audio);
if (audioLabel != null) labels.add(audioLabel);
return labels;
}
String _formatDolbyVision(MediaStream stream) {
final profile = stream.dolbyVisionProfile;
return profile == null || profile <= 0 ? 'DV' : 'DV P$profile';
}
MediaVersion? _selectedVersion(List<MediaVersion>? versions, int versionIndex) {
if (versions == null || versions.isEmpty) return null;
if (versionIndex >= 0 && versionIndex < versions.length) return versions[versionIndex];
return versions.first;
}
String? _formatResolution(MediaVersion version) {
final raw = version.videoResolution?.trim();
if (raw != null && raw.isNotEmpty) return _formatResolutionValue(raw);
final fallback = resolutionLabelFromDimensions(version.width, version.height);
return fallback == null ? null : _formatResolutionValue(fallback);
}
String _formatResolutionValue(String value) {
final normalized = value.trim().toLowerCase();
if (normalized == '4k' || normalized == 'uhd') return '4K';
if (normalized == 'sd') return 'SD';
final numeric = RegExp(r'^(\d+)(?:p)?$').firstMatch(normalized);
if (numeric != null) {
final height = int.tryParse(numeric.group(1)!);
if (height != null && height >= 2160) return '4K';
return '${numeric.group(1)}p';
}
return value.toUpperCase();
}
MediaStream? _firstStreamOfKind(MediaVersion version, MediaStreamKind kind) {
for (final part in version.parts) {
for (final stream in part.streams) {
if (stream.kind == kind) return stream;
}
}
return null;
}
MediaStream? _selectedAudioStream(MediaVersion version) {
MediaStream? first;
for (final part in version.parts) {
for (final stream in part.streams) {
if (stream.kind != MediaStreamKind.audio) continue;
first ??= stream;
if (stream.selected) return stream;
}
}
return first;
}
String? _formatAudio(MediaStream? stream) {
if (stream == null) return null;
final parts = <String>[];
final codec = stream.codec?.trim();
if (codec != null && codec.isNotEmpty) parts.add(_formatAudioCodec(codec));
if (_isAtmos(stream)) {
parts.add('Atmos');
} else {
final channels = _formatAudioChannels(stream.channels);
if (channels != null) parts.add(channels);
}
return parts.isEmpty ? null : parts.join(' ');
}
String _formatAudioCodec(String codec) {
return switch (codec.toLowerCase()) {
'eac3' || 'ec3' => 'EAC3',
'ac3' => 'AC3',
_ => CodecUtils.formatAudioCodec(codec),
};
}
bool _isAtmos(MediaStream stream) {
return [
stream.codec,
stream.title,
stream.displayTitle,
].whereType<String>().any((value) => value.toLowerCase().contains('atmos'));
}
String? _formatAudioChannels(int? channels) {
if (channels == null || channels <= 0) return null;
return switch (channels) {
1 => 'Mono',
2 => 'Stereo',
3 => '3.0',
4 => '4.0',
5 => '4.1',
6 => '5.1',
7 => '6.1',
8 => '7.1',
_ => '${channels}ch',
};
}
+53 -30
View File
@@ -20,6 +20,7 @@ import '../widgets/download_status_icon.dart';
import '../widgets/optimized_media_image.dart';
import '../utils/platform_detector.dart';
import '../utils/formatters.dart';
import '../utils/media_quality_labels.dart';
import '../widgets/media_context_menu.dart';
import '../widgets/placeholder_container.dart';
import '../theme/mono_tokens.dart';
@@ -68,36 +69,57 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
}
}
Widget _buildEpisodeMetaRow(BuildContext context, MediaItem episode) {
Widget _buildEpisodeMetaRow(BuildContext context, MediaItem episode, List<String> qualityLabels) {
final mutedStyle = Theme.of(context).textTheme.bodySmall?.copyWith(color: tokens(context).textMuted, fontSize: 12);
final dot = Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text('', style: mutedStyle),
);
return Row(
children: [
if (episode.durationMs != null)
Text(formatDurationTimestamp(Duration(milliseconds: episode.durationMs!)), style: mutedStyle),
if (episode.originallyAvailableAt != null) ...[
dot,
Text(formatFullDate(episode.originallyAvailableAt!), style: mutedStyle),
],
if (episode.userRating != null && episode.userRating! > 0) ...[
dot,
const Padding(
padding: .only(top: 2),
child: Icon(Symbols.star_rounded, size: 12, fill: 1, color: Colors.amber),
),
const SizedBox(width: 2),
Text(
(episode.userRating! / 2) == (episode.userRating! / 2).truncateToDouble()
? '${(episode.userRating! / 2).toInt()}'
: formatRating(episode.userRating! / 2),
style: mutedStyle,
),
],
],
);
final children = <Widget>[];
void addSeparator() {
if (children.isEmpty) return;
children.add(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text('', style: mutedStyle),
),
);
}
if (episode.durationMs != null) {
children.add(Text(formatDurationTimestamp(Duration(milliseconds: episode.durationMs!)), style: mutedStyle));
}
if (episode.originallyAvailableAt != null) {
addSeparator();
children.add(Text(formatFullDate(episode.originallyAvailableAt!), style: mutedStyle));
}
if (episode.userRating != null && episode.userRating! > 0) {
addSeparator();
children.add(
Row(
mainAxisSize: .min,
children: [
const Padding(
padding: .only(top: 2),
child: Icon(Symbols.star_rounded, size: 12, fill: 1, color: Colors.amber),
),
const SizedBox(width: 2),
Text(
(episode.userRating! / 2) == (episode.userRating! / 2).truncateToDouble()
? '${(episode.userRating! / 2).toInt()}'
: formatRating(episode.userRating! / 2),
style: mutedStyle,
),
],
),
);
}
for (final label in qualityLabels) {
addSeparator();
children.add(Text(label, style: mutedStyle));
}
return Wrap(crossAxisAlignment: .center, runSpacing: 4, children: children);
}
@override
@@ -111,6 +133,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);
// Hide progress when offline (not tracked)
final hasProgress =
@@ -326,7 +349,7 @@ class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<Epis
],
const SizedBox(height: 8),
_buildEpisodeMetaRow(context, episode),
_buildEpisodeMetaRow(context, episode, qualityLabels),
],
),
),
+69 -1
View File
@@ -2,6 +2,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_stream.dart';
import 'package:plezy/services/plex_mappers.dart';
const _serverId = 'plex-machine-1';
@@ -280,7 +281,24 @@ void main() {
'height': 1080,
'container': 'mkv',
'Part': [
{'key': '/library/parts/1/file.mkv'},
{
'key': '/library/parts/1/file.mkv',
'Stream': [
{
'id': 10,
'streamType': 1,
'codec': 'hevc',
'frameRate': 23.976,
'DOVIProfile': 8,
'DOVIPresent': 1,
'DOVIBLCompatID': 1,
'colorTrc': 'smpte2084',
'colorPrimaries': 'bt2020',
'colorSpace': 'bt2020nc',
},
{'id': 11, 'streamType': 2, 'codec': 'eac3', 'channels': 6, 'languageCode': 'eng', 'selected': true},
],
},
],
},
],
@@ -297,6 +315,56 @@ void main() {
expect(v.height, 1080);
expect(v.container, 'mkv');
expect(v.parts.single.streamPath, '/library/parts/1/file.mkv');
final video = v.parts.single.streams.firstWhere((stream) => stream.kind == MediaStreamKind.video);
expect(video.codec, 'hevc');
expect(video.frameRate, closeTo(23.976, 0.001));
expect(video.hdr, isTrue);
expect(video.dolbyVision, isTrue);
expect(video.dolbyVisionProfile, 8);
final audio = v.parts.single.streams.firstWhere((stream) => stream.kind == MediaStreamKind.audio);
expect(audio.codec, 'eac3');
expect(audio.channels, 6);
expect(audio.selected, isTrue);
});
test('Media-level audio and file hints backfill streams when Part.Stream is absent', () {
final json = {
'ratingKey': '6048',
'type': 'episode',
'title': 'Hello, Ms. Cobel',
'Media': [
{
'id': '6136',
'videoResolution': '4k',
'videoCodec': 'hevc',
'audioCodec': 'eac3',
'audioChannels': '6',
'width': '3840',
'height': '1606',
'container': 'mkv',
'Part': [
{
'id': '6154',
'key': '/library/parts/6154/file.mkv',
'file': '/tv/Severance.S02.Hybrid.MULTI.2160p.WEB-DL.DV.HDR.H265-AOC/S02/S02E01.mkv',
},
],
},
],
};
final item = PlexMappers.mediaItemFromJson(json, serverId: ServerId(_serverId));
final part = item.mediaVersions!.single.parts.single;
final video = part.streams.firstWhere((stream) => stream.kind == MediaStreamKind.video);
final audio = part.streams.firstWhere((stream) => stream.kind == MediaStreamKind.audio);
expect(video.codec, 'hevc');
expect(video.hdr, isTrue);
expect(video.dolbyVision, isTrue);
expect(video.dolbyVisionProfile, isNull);
expect(audio.codec, 'eac3');
expect(audio.channels, 6);
expect(audio.selected, isTrue);
});
test('Image array (clearLogo, backgroundSquare) is hoisted onto top-level fields', () {
+190
View File
@@ -0,0 +1,190 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.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/services/plex_mappers.dart';
import 'package:plezy/utils/media_quality_labels.dart';
void main() {
group('buildMediaQualityLabels', () {
test('formats resolution, Dolby Vision, and Atmos audio', () {
final item = _episodeWithVersion(
MediaVersion(
id: '1',
videoResolution: '4k',
parts: const [
MediaPart(
id: 'part-1',
streams: [
MediaStream(id: 'video', kind: MediaStreamKind.video, hdr: true, dolbyVision: true),
MediaStream(
id: 'audio',
kind: MediaStreamKind.audio,
codec: 'truehd',
displayTitle: 'English (TrueHD Atmos 7.1)',
channels: 8,
selected: true,
),
],
),
],
),
);
expect(buildMediaQualityLabels(item), ['4K', 'DV', 'TrueHD Atmos']);
});
test('formats Dolby Vision profile when stream metadata includes it', () {
final item = _episodeWithVersion(
MediaVersion(
id: '1',
videoResolution: '4k',
parts: const [
MediaPart(
id: 'part-1',
streams: [
MediaStream(
id: 'video',
kind: MediaStreamKind.video,
hdr: true,
dolbyVision: true,
dolbyVisionProfile: 8,
),
MediaStream(id: 'audio', kind: MediaStreamKind.audio, codec: 'eac3', channels: 6),
],
),
],
),
);
expect(buildMediaQualityLabels(item), ['4K', 'DV P8', 'EAC3 5.1']);
});
test('formats HDR and surround channel count', () {
final item = _episodeWithVersion(
MediaVersion(
id: '1',
videoResolution: '1080',
parts: const [
MediaPart(
id: 'part-1',
streams: [
MediaStream(id: 'video', kind: MediaStreamKind.video, hdr: true),
MediaStream(id: 'audio', kind: MediaStreamKind.audio, codec: 'eac3', channels: 6),
],
),
],
),
);
expect(buildMediaQualityLabels(item), ['1080p', 'HDR', 'EAC3 5.1']);
});
test('formats Plex season child fallback metadata', () {
final item = PlexMappers.mediaItemFromJson({
'ratingKey': '6048',
'type': 'episode',
'title': 'Hello, Ms. Cobel',
'Media': [
{
'id': '6136',
'audioChannels': '6',
'audioCodec': 'eac3',
'videoCodec': 'hevc',
'videoResolution': '4k',
'width': '3840',
'height': '1606',
'Part': [
{
'id': '6154',
'key': '/library/parts/6154/file.mkv',
'file': '/tv/Severance.S02.Hybrid.MULTI.2160p.WEB-DL.DV.HDR.H265-AOC/S02/S02E01.mkv',
},
],
},
],
}, serverId: ServerId('plex'));
expect(buildMediaQualityLabels(item), ['4K', 'DV', 'EAC3 5.1']);
});
test('formats Plex movie full-detail stream metadata', () {
final item = PlexMappers.mediaItemFromJson({
'ratingKey': 'movie-1',
'type': 'movie',
'title': 'Movie',
'Media': [
{
'id': 'media-1',
'audioChannels': 6,
'audioCodec': 'eac3',
'videoCodec': 'hevc',
'videoResolution': '4k',
'Part': [
{
'id': 'part-1',
'key': '/library/parts/part-1/file.mkv',
'Stream': [
{
'id': 1,
'streamType': 1,
'codec': 'hevc',
'DOVIProfile': 8,
'DOVIPresent': 1,
'DOVIBLCompatID': 1,
'colorTrc': 'smpte2084',
'colorPrimaries': 'bt2020',
'colorSpace': 'bt2020nc',
},
{'id': 2, 'streamType': 2, 'codec': 'eac3', 'channels': 6, 'selected': 1},
],
},
],
},
],
}, serverId: ServerId('plex'));
expect(buildMediaQualityLabels(item), ['4K', 'DV P8', 'EAC3 5.1']);
});
test('uses selected audio stream and stereo label', () {
final item = _episodeWithVersion(
MediaVersion(
id: '1',
width: 1280,
height: 720,
parts: const [
MediaPart(
id: 'part-1',
streams: [
MediaStream(id: 'video', kind: MediaStreamKind.video),
MediaStream(id: 'audio-1', kind: MediaStreamKind.audio, codec: 'ac3', channels: 6),
MediaStream(id: 'audio-2', kind: MediaStreamKind.audio, codec: 'aac', channels: 2, selected: true),
],
),
],
),
);
expect(buildMediaQualityLabels(item), ['720p', 'AAC Stereo']);
});
test('returns empty labels when no media versions exist', () {
expect(buildMediaQualityLabels(_episodeWithVersion(null)), isEmpty);
});
});
}
MediaItem _episodeWithVersion(MediaVersion? version) {
return MediaItem(
id: 'episode-1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode',
mediaVersions: version == null ? null : [version],
);
}