@@ -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,
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user