From ad3cc6b986fee6877d815a5e94b1ee480b873a66 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 12 Jun 2026 03:25:34 +0200 Subject: [PATCH] feat(player): language-first audio & subtitle track labels close #1307 --- lib/media/media_source_info.dart | 61 ++--- lib/services/track_manager.dart | 4 +- lib/utils/codec_utils.dart | 17 ++ lib/utils/media_quality_labels.dart | 17 +- lib/utils/track_label_builder.dart | 167 ++++++++++--- lib/widgets/file_info_bottom_sheet.dart | 5 +- .../helpers/track_selection_helper.dart | 26 +- .../video_controls/sheets/track_sheet.dart | 7 +- .../performance_stats_service.dart | 15 +- test/media/media_source_info_test.dart | 67 ++++- test/utils/codec_utils_test.dart | 23 ++ test/utils/track_label_builder_test.dart | 235 ++++++++++++------ test/widgets/track_sheet_test.dart | 51 ++++ 13 files changed, 497 insertions(+), 198 deletions(-) diff --git a/lib/media/media_source_info.dart b/lib/media/media_source_info.dart index 252a6f2d..d709336c 100644 --- a/lib/media/media_source_info.dart +++ b/lib/media/media_source_info.dart @@ -1,6 +1,5 @@ import '../i18n/strings.g.dart'; -import '../utils/codec_utils.dart'; -import '../utils/track_label_builder.dart' show TrackLabelBuilder, buildTrackLabel; +import '../utils/track_label_builder.dart' show TrackLabel, TrackLabelBuilder; import 'media_display_criteria.dart'; class MediaSourceInfo { @@ -67,21 +66,15 @@ class TrickplayInfo { }); } -/// Mixin for building track labels with a consistent pattern. -/// -/// Used by [MediaAudioTrack] and [MediaSubtitleTrack] to provide a [buildLabel] -/// method that delegates to the shared [buildTrackLabel] function. +/// Shared fallback-index math for [MediaAudioTrack] and [MediaSubtitleTrack] +/// labels; the label content itself is built by [TrackLabelBuilder]. mixin _TrackLabelMixin { int get id; int? get index; - String? get displayTitle; - String? get language; - String buildLabel(List additionalParts) { - if (displayTitle != null && displayTitle!.isNotEmpty) { - return displayTitle!; - } - return buildTrackLabel(language: language, extraParts: additionalParts, index: (index ?? id) - 1); + int get _fallbackLabelIndex { + final streamIndex = index ?? id; + return streamIndex > 0 ? streamIndex - 1 : 0; } } @@ -91,11 +84,9 @@ class MediaAudioTrack with _TrackLabelMixin { @override final int? index; final String? codec; - @override final String? language; final String? languageCode; final String? title; - @override final String? displayTitle; final int? channels; final bool selected; @@ -116,11 +107,16 @@ class MediaAudioTrack with _TrackLabelMixin { bool get isExternal => external; - String get label { - final additionalParts = []; - if (codec != null) additionalParts.add(CodecUtils.formatAudioCodec(codec!)); - if (channels != null) additionalParts.add('${channels!}ch'); - return buildLabel(additionalParts); + TrackLabel get label { + return TrackLabelBuilder.audioLabel( + title: title, + language: language, + languageCode: languageCode, + codec: codec, + channels: channels, + displayTitle: displayTitle, + index: _fallbackLabelIndex, + ); } } @@ -130,11 +126,9 @@ class MediaSubtitleTrack with _TrackLabelMixin { @override final int? index; final String? codec; - @override final String? language; final String? languageCode; final String? title; - @override final String? displayTitle; final bool selected; final bool forced; @@ -157,31 +151,22 @@ class MediaSubtitleTrack with _TrackLabelMixin { this.usesExternalDelivery = false, }); - String get label { + TrackLabel get label { return labelForIndex(_fallbackLabelIndex); } - String labelForIndex(int visibleIndex) { - return TrackLabelBuilder.buildSubtitleLabel( - title: _labelTitle, - language: languageCode ?? language, + TrackLabel labelForIndex(int visibleIndex) { + return TrackLabelBuilder.subtitleLabel( + title: title, + language: language, + languageCode: languageCode, codec: codec, forced: forced, + displayTitle: displayTitle, index: visibleIndex, ); } - String? get _labelTitle { - final explicitTitle = title; - if (explicitTitle != null && explicitTitle.trim().isNotEmpty) return explicitTitle; - return displayTitle; - } - - int get _fallbackLabelIndex { - final streamIndex = index ?? id; - return streamIndex > 0 ? streamIndex - 1 : 0; - } - /// Returns true if this subtitle track is an external file (sidecar subtitle). /// Some backends provide a direct key/URL, others require constructing one /// from stream metadata. diff --git a/lib/services/track_manager.dart b/lib/services/track_manager.dart index c84d64c8..533db3af 100644 --- a/lib/services/track_manager.dart +++ b/lib/services/track_manager.dart @@ -271,7 +271,7 @@ class TrackManager { if (isActive()) { final label = next.id == 'no' ? 'Subtitles: Off' - : 'Subtitles: ${TrackLabelBuilder.buildSubtitleLabel(title: next.title, language: next.language, codec: next.codec, index: nextIndex)}'; + : 'Subtitles: ${TrackLabelBuilder.subtitleLabel(title: next.title, language: next.language, codec: next.codec, forced: next.isForced, index: nextIndex).joined}'; showMessage?.call(label, duration: const Duration(seconds: 1)); } } @@ -290,7 +290,7 @@ class TrackManager { if (isActive()) { final label = - 'Audio: ${TrackLabelBuilder.buildAudioLabel(title: next.title, language: next.language, codec: next.codec, channelsCount: next.channelsCount, index: nextIndex)}'; + 'Audio: ${TrackLabelBuilder.audioLabel(title: next.title, language: next.language, codec: next.codec, channels: next.channelsCount, index: nextIndex).joined}'; showMessage?.call(label, duration: const Duration(seconds: 1)); } } diff --git a/lib/utils/codec_utils.dart b/lib/utils/codec_utils.dart index 9027dc33..5db8f758 100644 --- a/lib/utils/codec_utils.dart +++ b/lib/utils/codec_utils.dart @@ -72,6 +72,23 @@ class CodecUtils { }; } + /// Formats an audio channel count as a friendly layout name (2 → 'Stereo', + /// 6 → '5.1'). Returns null when [channels] is null or not positive. + static 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', + }; + } + /// Formats an audio codec name to a user-friendly display format. static String formatAudioCodec(String codec) { final lower = codec.toLowerCase(); diff --git a/lib/utils/media_quality_labels.dart b/lib/utils/media_quality_labels.dart index 2ba5b40f..0c368cae 100644 --- a/lib/utils/media_quality_labels.dart +++ b/lib/utils/media_quality_labels.dart @@ -91,7 +91,7 @@ String? _formatAudio(MediaStream? stream) { if (_isAtmos(stream)) { parts.add('Atmos'); } else { - final channels = _formatAudioChannels(stream.channels); + final channels = CodecUtils.formatAudioChannels(stream.channels); if (channels != null) parts.add(channels); } @@ -113,18 +113,3 @@ bool _isAtmos(MediaStream stream) { stream.displayTitle, ].whereType().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', - }; -} diff --git a/lib/utils/track_label_builder.dart b/lib/utils/track_label_builder.dart index dbbc52be..36cdbdeb 100644 --- a/lib/utils/track_label_builder.dart +++ b/lib/utils/track_label_builder.dart @@ -1,24 +1,60 @@ import 'codec_utils.dart'; +import 'language_codes.dart'; -/// Builds a track label from parts with the standard `' · '` joiner pattern. +/// Two-part track label: [primary] carries the human-readable name (language +/// first when known), [secondary] the de-emphasized technical detail. /// -/// Shared by both Plex track models and MPV track label utilities. -/// If [title] is non-empty it is added first, then [language], then [extraParts]. -/// Falls back to `'$fallbackPrefix ${index + 1}'` when no parts are available. -String buildTrackLabel({ - String? title, - String? language, - List extraParts = const [], - required int index, - String fallbackPrefix = 'Track', -}) { - final parts = []; - if (title != null && title.isNotEmpty) parts.add(title); - if (language != null && language.isNotEmpty) parts.add(language); - parts.addAll(extraParts); - return parts.isEmpty ? '$fallbackPrefix ${index + 1}' : parts.join(' · '); +/// Sheet rows render the parts on two lines; single-line contexts (track +/// cycling toasts) use [joined]. +class TrackLabel { + final String primary; + + /// Technical detail line. Null when there is none — never an empty string. + final String? secondary; + + const TrackLabel(this.primary, [this.secondary]); + + String get joined => secondary == null ? primary : '$primary · $secondary'; + + @override + bool operator ==(Object other) => other is TrackLabel && other.primary == primary && other.secondary == secondary; + + @override + int get hashCode => Object.hash(primary, secondary); + + @override + String toString() => 'TrackLabel($primary, $secondary)'; } +/// Resolves the display name for a track's language. +/// +/// A mappable ISO code wins ([languageCode] is the reliable field on server +/// streams, [language] carries the container code on mpv tracks). When neither +/// maps, a server-provided display name ("Filipino") beats an unmappable code, +/// and bare codes keep the legacy uppercase rendering ("und" → "UND"). +String? resolveTrackLanguageDisplay({String? language, String? languageCode}) { + final code = cleanTrackMetadataValue(languageCode); + final lang = cleanTrackMetadataValue(language); + + final display = _displayNameIfMapped(code) ?? _displayNameIfMapped(lang); + if (display != null) return display; + + final fallback = lang ?? code; + if (fallback == null) return null; + return _looksLikeLanguageCode(fallback) ? fallback.toUpperCase() : fallback; +} + +String? _displayNameIfMapped(String? value) { + if (value == null) return null; + final base = value.split(RegExp('[-_]')).first; + if (LanguageCodes.getLanguageName(base) == null) return null; + return LanguageCodes.getDisplayName(value.replaceAll('_', '-')); +} + +final _languageCodePattern = RegExp(r'^[A-Za-z]{2,3}([-_][A-Za-z0-9]{2,8})?$'); + +bool _looksLikeLanguageCode(String value) => _languageCodePattern.hasMatch(value); + String? cleanTrackMetadataValue(String? value) { if (value == null) return null; var cleaned = value.trim(); @@ -82,43 +118,100 @@ String _metadataToken(String value) => value.trim().toUpperCase().replaceAll(Reg class TrackLabelBuilder { TrackLabelBuilder._(); - static String buildAudioLabel({ + static TrackLabel audioLabel({ String? title, String? language, + String? languageCode, String? codec, - int? channelsCount, + int? channels, + String? displayTitle, required int index, }) { - final extraParts = []; - if (codec != null && codec.isNotEmpty) { - extraParts.add(CodecUtils.formatAudioCodec(codec)); - } - if (channelsCount != null) { - extraParts.add('${channelsCount}ch'); - } - return buildTrackLabel( - title: title, - language: language?.toUpperCase(), - extraParts: extraParts, - index: index, + final tech = []; + if (codec != null && codec.isNotEmpty) tech.add(CodecUtils.formatAudioCodec(codec)); + final channelsLabel = CodecUtils.formatAudioChannels(channels); + if (channelsLabel != null) tech.add(channelsLabel); + + return _compose( + languageDisplay: resolveTrackLanguageDisplay(language: language, languageCode: languageCode), + title: cleanTrackMetadataValue(title), + displayTitle: cleanTrackMetadataValue(displayTitle), + rawLanguageValues: [language, languageCode], + techParts: tech, fallbackPrefix: 'Audio Track', + index: index, ); } - static String buildSubtitleLabel({ + static TrackLabel subtitleLabel({ String? title, String? language, + String? languageCode, String? codec, bool forced = false, + String? displayTitle, required int index, }) { final cleanedTitle = cleanSubtitleTitle(title, codec: codec); - final cleanedLanguage = cleanTrackMetadataValue(language)?.toUpperCase(); - final extraParts = []; - if (forced && !_metadataToken(cleanedTitle ?? '').split('_').contains('FORCED')) extraParts.add('Forced'); - if (codec != null && codec.isNotEmpty) { - extraParts.add(CodecUtils.formatSubtitleCodec(codec)); + return _compose( + languageDisplay: resolveTrackLanguageDisplay(language: language, languageCode: languageCode), + title: cleanedTitle, + displayTitle: cleanSubtitleTitle(displayTitle, codec: codec), + rawLanguageValues: [language, languageCode], + techParts: [if (codec != null && codec.isNotEmpty) CodecUtils.formatSubtitleCodec(codec)], + fallbackPrefix: 'Track', + index: index, + forced: forced || _saysForced(cleanedTitle), + ); + } + + /// Primary ladder: language → title → displayTitle → `' N'`. The + /// title joins the secondary line only when the language took the primary + /// slot and the title says more than the language/forced flag already do. + static TrackLabel _compose({ + required String? languageDisplay, + required String? title, + required String? displayTitle, + required List rawLanguageValues, + required List techParts, + required String fallbackPrefix, + required int index, + bool forced = false, + }) { + String primary; + String? secondaryTitle; + if (languageDisplay != null) { + primary = languageDisplay; + if (title != null && + _metadataToken(title) != 'FORCED' && + !_restatesLanguage(title, languageDisplay, rawLanguageValues)) { + secondaryTitle = title; + } + } else if (title != null) { + primary = title; + } else if (displayTitle != null) { + primary = displayTitle; + } else { + primary = '$fallbackPrefix ${index + 1}'; } - return buildTrackLabel(title: cleanedTitle, language: cleanedLanguage, extraParts: extraParts, index: index); + + if (forced && !_saysForced(primary)) { + primary = '$primary (Forced)'; + } + + final secondaryParts = [?secondaryTitle, ...techParts]; + return TrackLabel(primary, secondaryParts.isEmpty ? null : secondaryParts.join(' · ')); + } + + static bool _saysForced(String? value) => _metadataToken(value ?? '').split('_').contains('FORCED'); + + static bool _restatesLanguage(String title, String languageDisplay, List rawLanguageValues) { + final normalized = title.trim().toLowerCase(); + if (normalized == languageDisplay.trim().toLowerCase()) return true; + for (final raw in rawLanguageValues) { + final cleaned = cleanTrackMetadataValue(raw); + if (cleaned != null && normalized == cleaned.toLowerCase()) return true; + } + return false; } } diff --git a/lib/widgets/file_info_bottom_sheet.dart b/lib/widgets/file_info_bottom_sheet.dart index 55039fcc..7b87953a 100644 --- a/lib/widgets/file_info_bottom_sheet.dart +++ b/lib/widgets/file_info_bottom_sheet.dart @@ -64,7 +64,8 @@ class _FileInfoBottomSheetState extends State { _buildSectionHeader(t.fileInfo.audio), const SizedBox(height: 8), if (info.audioTracks.isNotEmpty) - for (int i = 0; i < info.audioTracks.length; i++) _buildInfoRow('${i + 1}', info.audioTracks[i].label), + for (int i = 0; i < info.audioTracks.length; i++) + _buildInfoRow('${i + 1}', info.audioTracks[i].label.joined), if (info.audioTracks.isEmpty) ...[ if (info.audioCodec != null) _buildInfoRow(t.fileInfo.codec, info.audioCodec!), if (info.audioChannelsFormatted != null) @@ -77,7 +78,7 @@ class _FileInfoBottomSheetState extends State { _buildSectionHeader(t.fileInfo.subtitles), const SizedBox(height: 8), for (int i = 0; i < info.subtitleTracks.length; i++) - _buildInfoRow('${i + 1}', info.subtitleTracks[i].label), + _buildInfoRow('${i + 1}', info.subtitleTracks[i].label.joined), const SizedBox(height: 20), ], diff --git a/lib/widgets/video_controls/helpers/track_selection_helper.dart b/lib/widgets/video_controls/helpers/track_selection_helper.dart index 7ac35e00..1d1e70f9 100644 --- a/lib/widgets/video_controls/helpers/track_selection_helper.dart +++ b/lib/widgets/video_controls/helpers/track_selection_helper.dart @@ -3,6 +3,8 @@ import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../i18n/strings.g.dart'; import '../../../mpv/mpv.dart'; +import '../../../theme/mono_tokens.dart'; +import '../../../utils/track_label_builder.dart'; import '../../../widgets/focusable_list_tile.dart'; class TrackSelectionHelper { @@ -59,7 +61,7 @@ class TrackSelectionHelper { static Widget buildTrackTile({ required BuildContext context, - required String label, + required TrackLabel label, required bool isSelected, required VoidCallback onTap, Key? key, @@ -71,7 +73,8 @@ class TrackSelectionHelper { return _buildSelectableTile( context: context, key: key, - label: label, + label: label.primary, + secondaryLabel: label.secondary, isSelected: isSelected, onTap: onTap, focusNode: focusNode, @@ -101,6 +104,7 @@ class TrackSelectionHelper { required String label, required bool isSelected, required VoidCallback onTap, + String? secondaryLabel, Key? key, FocusNode? focusNode, VoidCallback? onLongPress, @@ -118,7 +122,23 @@ class TrackSelectionHelper { Widget tile = FocusableListTile( key: key, focusNode: focusNode, - title: Text(label, style: TextStyle(color: isSelected ? primaryColor : null)), + title: Text( + label, + style: TextStyle(color: isSelected ? primaryColor : null), + maxLines: 1, + overflow: .ellipsis, + ), + subtitle: secondaryLabel == null + ? null + : Text( + secondaryLabel, + style: TextStyle( + color: isSelected ? primaryColor.withValues(alpha: 0.7) : tokens(context).textMuted, + fontSize: 12, + ), + maxLines: 1, + overflow: .ellipsis, + ), trailing: trailing, onTap: onTap, onLongPress: onLongPress, diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index 88deda8f..1a03176b 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -316,11 +316,11 @@ class _AudioColumnState extends State<_AudioColumn> { itemCount: widget.tracks.length, itemBuilder: (context, index) { final track = widget.tracks[index]; - final label = TrackLabelBuilder.buildAudioLabel( + final label = TrackLabelBuilder.audioLabel( title: track.title, language: track.language, codec: track.codec, - channelsCount: track.channelsCount, + channels: track.channelsCount, index: index, ); return TrackSelectionHelper.buildTrackTile( @@ -426,10 +426,11 @@ class _SubtitleColumnState extends State<_SubtitleColumn> { final track = widget.tracks[index - 1]; final isPrimary = !isOffSelected && track.id == selectedSub.id; final isSecondary = hasSecondary && track.id == secondarySub.id; - final label = TrackLabelBuilder.buildSubtitleLabel( + final label = TrackLabelBuilder.subtitleLabel( title: track.title, language: track.language, codec: track.codec, + forced: track.isForced, index: index - 1, ); diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart index 9cd61abb..3cef3479 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats_service.dart @@ -5,6 +5,7 @@ import 'package:flutter/scheduler.dart'; import '../../../../mpv/mpv.dart'; import '../../../../utils/app_logger.dart'; +import '../../../../utils/codec_utils.dart'; import 'performance_stats.dart'; /// Service that polls player properties and provides performance stats via a stream. @@ -188,7 +189,7 @@ class PerformanceStatsService { // Audio metrics audioCodec: _formatCodecName(statsMap['audioCodec'] as String?), audioSamplerate: statsMap['audioSampleRate'] as int?, - audioChannels: _formatChannels(statsMap['audioChannels'] as int?), + audioChannels: CodecUtils.formatAudioChannels(statsMap['audioChannels'] as int?), audioBitrate: statsMap['audioBitrate'] as int?, audioDecoderName: statsMap['audioDecoderName'] as String?, // Tunneling @@ -217,18 +218,6 @@ class PerformanceStatsService { } } - /// Format channel count to string (e.g., "2" -> "Stereo", "6" -> "5.1") - String? _formatChannels(int? channels) { - if (channels == null) return null; - return switch (channels) { - 1 => 'Mono', - 2 => 'Stereo', - 6 => '5.1', - 8 => '7.1', - _ => '$channels ch', - }; - } - /// Fetch stats from MPV via property queries. Future _fetchMpvStats() async { // Fetch core properties in parallel diff --git a/test/media/media_source_info_test.dart b/test/media/media_source_info_test.dart index 57dd3d2e..d4764f56 100644 --- a/test/media/media_source_info_test.dart +++ b/test/media/media_source_info_test.dart @@ -1,9 +1,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/media_source_info.dart'; +import 'package:plezy/utils/track_label_builder.dart'; void main() { group('MediaSubtitleTrack label', () { - test('prefers explicit source title over generated display title', () { + test('language leads; a bare "Forced" title folds into the suffix', () { final track = MediaSubtitleTrack( id: 401, index: 0, @@ -15,11 +16,11 @@ void main() { forced: true, ); - expect(track.labelForIndex(0), 'Forced · ENG · SRT'); - expect(track.label, 'Forced · ENG · SRT'); + expect(track.labelForIndex(0), const TrackLabel('English (Forced)', 'SRT')); + expect(track.label, const TrackLabel('English (Forced)', 'SRT')); }); - test('falls back to display title when source title is empty', () { + test('resolves the language name even when the source title is blank', () { final track = MediaSubtitleTrack( id: 402, index: 1, @@ -31,7 +32,63 @@ void main() { forced: false, ); - expect(track.labelForIndex(1), 'Japanese Signs/Songs · JPN · ASS'); + expect(track.labelForIndex(1), const TrackLabel('Japanese', 'ASS')); + }); + + test('falls back to display title when nothing else is available', () { + final track = MediaSubtitleTrack( + id: 403, + index: 2, + displayTitle: 'Director Commentary', + selected: false, + forced: false, + ); + + expect(track.labelForIndex(2), const TrackLabel('Director Commentary')); + }); + }); + + group('MediaAudioTrack label', () { + test('builds from stream fields, ignoring the server displayTitle', () { + final track = MediaAudioTrack( + id: 301, + index: 1, + codec: 'eac3', + language: 'English', + languageCode: 'eng', + title: null, + displayTitle: 'English (EAC3 5.1)', + channels: 6, + selected: true, + ); + + expect(track.label, const TrackLabel('English', 'E-AC3 · 5.1')); + }); + + test('server language name wins over an unmappable code', () { + final track = MediaAudioTrack( + id: 302, + index: 2, + codec: 'aac', + language: 'Filipino', + languageCode: 'fil', + channels: 2, + selected: false, + ); + + expect(track.label, const TrackLabel('Filipino', 'AAC · Stereo')); + }); + + test('falls back to displayTitle when stream fields are missing', () { + final track = MediaAudioTrack(id: 303, index: 3, displayTitle: 'Surround (EAC3)', selected: false); + + expect(track.label, const TrackLabel('Surround (EAC3)')); + }); + + test('fallback index is clamped for zero-indexed streams', () { + final track = MediaAudioTrack(id: 0, index: 0, selected: false); + + expect(track.label, const TrackLabel('Audio Track 1')); }); }); } diff --git a/test/utils/codec_utils_test.dart b/test/utils/codec_utils_test.dart index 3f966637..cbb6e290 100644 --- a/test/utils/codec_utils_test.dart +++ b/test/utils/codec_utils_test.dart @@ -133,4 +133,27 @@ void main() { expect(CodecUtils.formatAudioCodec('weird'), 'WEIRD'); }); }); + + group('CodecUtils.formatAudioChannels', () { + test('maps counts to layout names', () { + expect(CodecUtils.formatAudioChannels(1), 'Mono'); + expect(CodecUtils.formatAudioChannels(2), 'Stereo'); + expect(CodecUtils.formatAudioChannels(3), '3.0'); + expect(CodecUtils.formatAudioChannels(4), '4.0'); + expect(CodecUtils.formatAudioChannels(5), '4.1'); + expect(CodecUtils.formatAudioChannels(6), '5.1'); + expect(CodecUtils.formatAudioChannels(7), '6.1'); + expect(CodecUtils.formatAudioChannels(8), '7.1'); + }); + + test('falls back to Nch above 8 channels', () { + expect(CodecUtils.formatAudioChannels(10), '10ch'); + }); + + test('returns null for null and non-positive counts', () { + expect(CodecUtils.formatAudioChannels(null), null); + expect(CodecUtils.formatAudioChannels(0), null); + expect(CodecUtils.formatAudioChannels(-1), null); + }); + }); } diff --git a/test/utils/track_label_builder_test.dart b/test/utils/track_label_builder_test.dart index 602d2a92..a42bec24 100644 --- a/test/utils/track_label_builder_test.dart +++ b/test/utils/track_label_builder_test.dart @@ -2,126 +2,203 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/utils/track_label_builder.dart'; void main() { - group('buildTrackLabel', () { - test('joins title, language, and extra parts with " · "', () { - expect( - buildTrackLabel(title: 'Director Cut', language: 'EN', extraParts: const ['AAC', '2ch'], index: 0), - 'Director Cut · EN · AAC · 2ch', - ); + group('TrackLabel', () { + test('joined concatenates primary and secondary with " · "', () { + expect(const TrackLabel('Tamil', 'E-AC3 · 5.1').joined, 'Tamil · E-AC3 · 5.1'); }); - test('drops null/empty title and language', () { - expect(buildTrackLabel(title: null, language: null, extraParts: const ['AAC'], index: 0), 'AAC'); - expect(buildTrackLabel(title: '', language: '', extraParts: const ['AAC'], index: 0), 'AAC'); + test('joined is just primary when secondary is null', () { + expect(const TrackLabel('Tamil').joined, 'Tamil'); }); - test('falls back to " " when no parts', () { - expect(buildTrackLabel(index: 0), 'Track 1'); - expect(buildTrackLabel(index: 4), 'Track 5'); - expect(buildTrackLabel(index: 2, fallbackPrefix: 'Audio Track'), 'Audio Track 3'); - }); - - test('preserves ordering: title, language, extras', () { - expect(buildTrackLabel(title: 'A', language: 'B', extraParts: const ['C', 'D'], index: 0), 'A · B · C · D'); - }); - - test('only language', () { - expect(buildTrackLabel(language: 'FR', index: 0), 'FR'); - }); - - test('only title', () { - expect(buildTrackLabel(title: 'Commentary', index: 0), 'Commentary'); + test('equality compares both parts', () { + expect(const TrackLabel('A', 'B'), const TrackLabel('A', 'B')); + expect(const TrackLabel('A'), isNot(const TrackLabel('A', 'B'))); }); }); - group('TrackLabelBuilder.buildAudioLabel', () { - test('combines title, uppercased language, codec, channels', () { - expect( - TrackLabelBuilder.buildAudioLabel(title: 'Main', language: 'en', codec: 'aac', channelsCount: 2, index: 0), - 'Main · EN · AAC · 2ch', - ); + group('resolveTrackLanguageDisplay', () { + test('resolves 2-letter, 3-letter, and bibliographic codes', () { + expect(resolveTrackLanguageDisplay(language: 'en'), 'English'); + expect(resolveTrackLanguageDisplay(language: 'eng'), 'English'); + expect(resolveTrackLanguageDisplay(language: 'ta'), 'Tamil'); + expect(resolveTrackLanguageDisplay(language: 'tam'), 'Tamil'); + expect(resolveTrackLanguageDisplay(language: 'ger'), 'German'); }); - test('uppercases language', () { - expect( - TrackLabelBuilder.buildAudioLabel(language: 'fr', codec: 'ac3', channelsCount: 6, index: 0), - 'FR · AC3 · 6ch', - ); + test('resolves region-qualified codes', () { + expect(resolveTrackLanguageDisplay(language: 'en-AU'), 'English (Australia)'); + expect(resolveTrackLanguageDisplay(language: 'en-US'), 'English'); + expect(resolveTrackLanguageDisplay(language: 'pt_BR'), 'Portuguese (Brazil)'); }); - test('formats codec via CodecUtils (e.g. eac3 -> E-AC3)', () { - final label = TrackLabelBuilder.buildAudioLabel(codec: 'eac3', index: 0); - expect(label, 'E-AC3'); + test('prefers a mappable languageCode over the language field', () { + expect(resolveTrackLanguageDisplay(language: 'Englisch', languageCode: 'eng'), 'English'); }); - test('omits codec when null/empty', () { - expect(TrackLabelBuilder.buildAudioLabel(language: 'en', codec: null, index: 0), 'EN'); - expect(TrackLabelBuilder.buildAudioLabel(language: 'en', codec: '', index: 0), 'EN'); + test('unknown bare codes keep the legacy uppercase rendering', () { + expect(resolveTrackLanguageDisplay(language: 'und'), 'UND'); + expect(resolveTrackLanguageDisplay(languageCode: 'zxx'), 'ZXX'); }); - test('omits channels when null', () { - expect(TrackLabelBuilder.buildAudioLabel(language: 'en', codec: 'aac', index: 0), 'EN · AAC'); + test('server display names pass through unchanged', () { + expect(resolveTrackLanguageDisplay(language: 'English'), 'English'); + // 'fil' has no ISO 639-1 entry; the server-provided name must win. + expect(resolveTrackLanguageDisplay(language: 'Filipino', languageCode: 'fil'), 'Filipino'); }); - test('falls back to "Audio Track N" when nothing supplied', () { - expect(TrackLabelBuilder.buildAudioLabel(index: 0), 'Audio Track 1'); - expect(TrackLabelBuilder.buildAudioLabel(index: 3), 'Audio Track 4'); + test('cleans lang= metadata prefixes before resolving', () { + expect(resolveTrackLanguageDisplay(language: 'LANG=DEU'), 'German'); }); - test('zero channel count is still rendered (caller decides validity)', () { - // Behavior check: 0 is non-null, so it appears as 0ch. - expect(TrackLabelBuilder.buildAudioLabel(channelsCount: 0, index: 0), '0ch'); + test('returns null when nothing usable is provided', () { + expect(resolveTrackLanguageDisplay(), null); + expect(resolveTrackLanguageDisplay(language: '', languageCode: ' '), null); }); }); - group('TrackLabelBuilder.buildSubtitleLabel', () { - test('combines title, uppercased language, friendly codec', () { + group('TrackLabelBuilder.audioLabel', () { + test('language leads, title and tech detail go to the secondary line', () { expect( - TrackLabelBuilder.buildSubtitleLabel(title: 'Forced', language: 'en', codec: 'subrip', index: 0), - 'Forced · EN · SRT', + TrackLabelBuilder.audioLabel( + title: 'Dolby Digital Plus 5.1 with Atmos', + language: 'ta', + codec: 'eac3', + channels: 6, + index: 0, + ), + const TrackLabel('Tamil', 'Dolby Digital Plus 5.1 with Atmos · E-AC3 · 5.1'), ); }); - test('uppercases language and formats codec', () { - expect(TrackLabelBuilder.buildSubtitleLabel(language: 'fr', codec: 'webvtt', index: 0), 'FR · VTT'); - expect(TrackLabelBuilder.buildSubtitleLabel(language: 'de', codec: 'hdmv_pgs_subtitle', index: 0), 'DE · PGS'); - }); - - test('omits codec when null/empty', () { - expect(TrackLabelBuilder.buildSubtitleLabel(language: 'en', index: 0), 'EN'); - expect(TrackLabelBuilder.buildSubtitleLabel(language: 'en', codec: '', index: 0), 'EN'); - }); - - test('does not duplicate forced when the title already says forced', () { + test('language with tech detail only', () { expect( - TrackLabelBuilder.buildSubtitleLabel(title: 'Forced', language: 'en', codec: 'subrip', forced: true, index: 0), - 'Forced · EN · SRT', + TrackLabelBuilder.audioLabel(language: 'fr', codec: 'ac3', channels: 6, index: 0), + const TrackLabel('French', 'AC3 · 5.1'), ); }); - test('falls back to "Track N" with default prefix', () { - expect(TrackLabelBuilder.buildSubtitleLabel(index: 0), 'Track 1'); - expect(TrackLabelBuilder.buildSubtitleLabel(index: 7), 'Track 8'); + test('drops a title that restates the language', () { + expect( + TrackLabelBuilder.audioLabel(title: 'English', language: 'en', codec: 'aac', channels: 2, index: 0), + const TrackLabel('English', 'AAC · Stereo'), + ); + expect( + TrackLabelBuilder.audioLabel(title: 'eng', language: 'eng', codec: 'aac', index: 0), + const TrackLabel('English', 'AAC'), + ); + }); + + test('title becomes primary when there is no language', () { + expect( + TrackLabelBuilder.audioLabel(title: 'Commentary', codec: 'aac', channels: 2, index: 0), + const TrackLabel('Commentary', 'AAC · Stereo'), + ); + }); + + test('displayTitle is the last-resort primary before the index fallback', () { + expect( + TrackLabelBuilder.audioLabel(displayTitle: 'English (EAC3 5.1)', codec: 'eac3', index: 0), + const TrackLabel('English (EAC3 5.1)', 'E-AC3'), + ); + }); + + test('falls back to "Audio Track N"', () { + expect(TrackLabelBuilder.audioLabel(index: 0), const TrackLabel('Audio Track 1')); + expect(TrackLabelBuilder.audioLabel(index: 3), const TrackLabel('Audio Track 4')); + expect(TrackLabelBuilder.audioLabel(codec: 'eac3', index: 0), const TrackLabel('Audio Track 1', 'E-AC3')); + }); + + test('channel counts render as layout names and invalid counts are dropped', () { + expect(TrackLabelBuilder.audioLabel(language: 'en', channels: 1, index: 0).secondary, 'Mono'); + expect(TrackLabelBuilder.audioLabel(language: 'en', channels: 2, index: 0).secondary, 'Stereo'); + expect(TrackLabelBuilder.audioLabel(language: 'en', channels: 6, index: 0).secondary, '5.1'); + expect(TrackLabelBuilder.audioLabel(language: 'en', channels: 10, index: 0).secondary, '10ch'); + expect(TrackLabelBuilder.audioLabel(language: 'en', channels: 0, index: 0).secondary, null); + }); + + test('omits codec when null or empty', () { + expect(TrackLabelBuilder.audioLabel(language: 'en', codec: null, index: 0), const TrackLabel('English')); + expect(TrackLabelBuilder.audioLabel(language: 'en', codec: '', index: 0), const TrackLabel('English')); + }); + }); + + group('TrackLabelBuilder.subtitleLabel', () { + test('language leads with the codec on the secondary line', () { + expect( + TrackLabelBuilder.subtitleLabel(language: 'en', codec: 'subrip', index: 0), + const TrackLabel('English', 'SRT'), + ); + expect( + TrackLabelBuilder.subtitleLabel(language: 'de', codec: 'hdmv_pgs_subtitle', index: 0), + const TrackLabel('German', 'PGS'), + ); + }); + + test('forced flag renders as a primary suffix', () { + expect( + TrackLabelBuilder.subtitleLabel(language: 'en', codec: 'subrip', forced: true, index: 0), + const TrackLabel('English (Forced)', 'SRT'), + ); + }); + + test('a bare "Forced" title is folded into the suffix instead of repeating', () { + expect( + TrackLabelBuilder.subtitleLabel(title: 'Forced', language: 'en', codec: 'subrip', forced: true, index: 0), + const TrackLabel('English (Forced)', 'SRT'), + ); + expect( + TrackLabelBuilder.subtitleLabel(title: 'Forced', language: 'en', codec: 'subrip', index: 0), + const TrackLabel('English (Forced)', 'SRT'), + ); + }); + + test('no duplicate suffix when the title-primary already says forced', () { + expect( + TrackLabelBuilder.subtitleLabel(title: 'Signs Forced', codec: 'ass', forced: true, index: 0), + const TrackLabel('Signs Forced', 'ASS'), + ); + }); + + test('descriptive titles stay on the secondary line, even when language-prefixed', () { + expect( + TrackLabelBuilder.subtitleLabel(title: 'English (SDH)', language: 'en', codec: 'subrip', index: 0), + const TrackLabel('English', 'English (SDH) · SRT'), + ); + }); + + test('drops a title that restates the language', () { + expect( + TrackLabelBuilder.subtitleLabel(title: 'English', language: 'en', codec: 'subrip', index: 0), + const TrackLabel('English', 'SRT'), + ); + }); + + test('falls back to "Track N", keeping the forced suffix', () { + expect(TrackLabelBuilder.subtitleLabel(index: 0), const TrackLabel('Track 1')); + expect(TrackLabelBuilder.subtitleLabel(forced: true, index: 1), const TrackLabel('Track 2 (Forced)')); + }); + + test('displayTitle fallback is codec-stripped like a title', () { + expect( + TrackLabelBuilder.subtitleLabel(displayTitle: 'Japanese Signs/Songs - ASS', codec: 'ass', index: 0), + const TrackLabel('Japanese Signs/Songs', 'ASS'), + ); }); test('cleans raw Jellyfin/ExoPlayer subtitle metadata prefixes', () { expect( - TrackLabelBuilder.buildSubtitleLabel( - title: 'title=German - SUBRIP', - language: 'LANG=DEU', - codec: 'srt', - index: 0, - ), - 'German · DEU · SRT', + TrackLabelBuilder.subtitleLabel(title: 'title=German - SUBRIP', language: 'LANG=DEU', codec: 'srt', index: 0), + const TrackLabel('German', 'SRT'), ); expect( - TrackLabelBuilder.buildSubtitleLabel( + TrackLabelBuilder.subtitleLabel( title: 'title=English - Default - SUBRIP', language: 'LANG=ENG', codec: 'subrip', index: 1, ), - 'English - Default · ENG · SRT', + const TrackLabel('English', 'English - Default · SRT'), ); }); }); diff --git a/test/widgets/track_sheet_test.dart b/test/widgets/track_sheet_test.dart index 3e350ccc..40a8f680 100644 --- a/test/widgets/track_sheet_test.dart +++ b/test/widgets/track_sheet_test.dart @@ -3,9 +3,25 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/mpv/mpv.dart'; +import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/widgets/video_controls/models/track_controls_state.dart'; import 'package:plezy/widgets/video_controls/sheets/track_sheet.dart'; +const _testTokens = MonoTokens( + radiusSm: 8, + radiusMd: 12, + space: 8, + fast: Duration(milliseconds: 1), + normal: Duration(milliseconds: 1), + slow: Duration(milliseconds: 1), + bg: Colors.black, + surface: Colors.black, + outline: Colors.white24, + text: Colors.white, + textMuted: Colors.white70, + splashFactory: NoSplash.splashFactory, +); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -69,6 +85,40 @@ void main() { }); }); + group('TrackSheet two-line labels', () { + testWidgets('renders language as the primary line and tech detail below', (tester) async { + final player = _FakeTrackSheetPlayer( + tracks: const Tracks( + audio: [ + AudioTrack(id: 'a1', language: 'eng', codec: 'aac', channels: 2), + AudioTrack( + id: 'a2', + title: 'Dolby Digital Plus 5.1 with Atmos', + language: 'ta', + codec: 'eac3', + channels: 6, + ), + ], + ), + track: const TrackSelection( + audio: AudioTrack(id: 'a1', language: 'eng', codec: 'aac', channels: 2), + subtitle: SubtitleTrack.off, + ), + ); + + await _pumpTrackSheet( + tester, + player: player, + trackControlsState: const TrackControlsState(subtitleSearchSupported: false), + ); + + expect(find.text('English'), findsOneWidget); + expect(find.text('AAC · Stereo'), findsOneWidget); + expect(find.text('Tamil'), findsOneWidget); + expect(find.text('Dolby Digital Plus 5.1 with Atmos · E-AC3 · 5.1'), findsOneWidget); + }); + }); + group('TrackControlsState.hasSubtitleControls', () { test('counts source subtitles only when source switching is available', () { final sourceSubtitle = MediaSubtitleTrack(id: 1, selected: false, forced: false); @@ -106,6 +156,7 @@ Future _pumpTrackSheet( }) async { await tester.pumpWidget( MaterialApp( + theme: ThemeData(extensions: const [_testTokens]), home: Scaffold( body: SizedBox( width: 700,