feat(player): language-first audio & subtitle track labels

close #1307
This commit is contained in:
edde746
2026-06-12 04:16:39 +02:00
parent 133db721a2
commit ad3cc6b986
13 changed files with 497 additions and 198 deletions
+23 -38
View File
@@ -1,6 +1,5 @@
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../utils/codec_utils.dart'; import '../utils/track_label_builder.dart' show TrackLabel, TrackLabelBuilder;
import '../utils/track_label_builder.dart' show TrackLabelBuilder, buildTrackLabel;
import 'media_display_criteria.dart'; import 'media_display_criteria.dart';
class MediaSourceInfo { class MediaSourceInfo {
@@ -67,21 +66,15 @@ class TrickplayInfo {
}); });
} }
/// Mixin for building track labels with a consistent pattern. /// Shared fallback-index math for [MediaAudioTrack] and [MediaSubtitleTrack]
/// /// labels; the label content itself is built by [TrackLabelBuilder].
/// Used by [MediaAudioTrack] and [MediaSubtitleTrack] to provide a [buildLabel]
/// method that delegates to the shared [buildTrackLabel] function.
mixin _TrackLabelMixin { mixin _TrackLabelMixin {
int get id; int get id;
int? get index; int? get index;
String? get displayTitle;
String? get language;
String buildLabel(List<String> additionalParts) { int get _fallbackLabelIndex {
if (displayTitle != null && displayTitle!.isNotEmpty) { final streamIndex = index ?? id;
return displayTitle!; return streamIndex > 0 ? streamIndex - 1 : 0;
}
return buildTrackLabel(language: language, extraParts: additionalParts, index: (index ?? id) - 1);
} }
} }
@@ -91,11 +84,9 @@ class MediaAudioTrack with _TrackLabelMixin {
@override @override
final int? index; final int? index;
final String? codec; final String? codec;
@override
final String? language; final String? language;
final String? languageCode; final String? languageCode;
final String? title; final String? title;
@override
final String? displayTitle; final String? displayTitle;
final int? channels; final int? channels;
final bool selected; final bool selected;
@@ -116,11 +107,16 @@ class MediaAudioTrack with _TrackLabelMixin {
bool get isExternal => external; bool get isExternal => external;
String get label { TrackLabel get label {
final additionalParts = <String>[]; return TrackLabelBuilder.audioLabel(
if (codec != null) additionalParts.add(CodecUtils.formatAudioCodec(codec!)); title: title,
if (channels != null) additionalParts.add('${channels!}ch'); language: language,
return buildLabel(additionalParts); languageCode: languageCode,
codec: codec,
channels: channels,
displayTitle: displayTitle,
index: _fallbackLabelIndex,
);
} }
} }
@@ -130,11 +126,9 @@ class MediaSubtitleTrack with _TrackLabelMixin {
@override @override
final int? index; final int? index;
final String? codec; final String? codec;
@override
final String? language; final String? language;
final String? languageCode; final String? languageCode;
final String? title; final String? title;
@override
final String? displayTitle; final String? displayTitle;
final bool selected; final bool selected;
final bool forced; final bool forced;
@@ -157,31 +151,22 @@ class MediaSubtitleTrack with _TrackLabelMixin {
this.usesExternalDelivery = false, this.usesExternalDelivery = false,
}); });
String get label { TrackLabel get label {
return labelForIndex(_fallbackLabelIndex); return labelForIndex(_fallbackLabelIndex);
} }
String labelForIndex(int visibleIndex) { TrackLabel labelForIndex(int visibleIndex) {
return TrackLabelBuilder.buildSubtitleLabel( return TrackLabelBuilder.subtitleLabel(
title: _labelTitle, title: title,
language: languageCode ?? language, language: language,
languageCode: languageCode,
codec: codec, codec: codec,
forced: forced, forced: forced,
displayTitle: displayTitle,
index: visibleIndex, 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). /// Returns true if this subtitle track is an external file (sidecar subtitle).
/// Some backends provide a direct key/URL, others require constructing one /// Some backends provide a direct key/URL, others require constructing one
/// from stream metadata. /// from stream metadata.
+2 -2
View File
@@ -271,7 +271,7 @@ class TrackManager {
if (isActive()) { if (isActive()) {
final label = next.id == 'no' final label = next.id == 'no'
? 'Subtitles: Off' ? '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)); showMessage?.call(label, duration: const Duration(seconds: 1));
} }
} }
@@ -290,7 +290,7 @@ class TrackManager {
if (isActive()) { if (isActive()) {
final label = 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)); showMessage?.call(label, duration: const Duration(seconds: 1));
} }
} }
+17
View File
@@ -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. /// Formats an audio codec name to a user-friendly display format.
static String formatAudioCodec(String codec) { static String formatAudioCodec(String codec) {
final lower = codec.toLowerCase(); final lower = codec.toLowerCase();
+1 -16
View File
@@ -91,7 +91,7 @@ String? _formatAudio(MediaStream? stream) {
if (_isAtmos(stream)) { if (_isAtmos(stream)) {
parts.add('Atmos'); parts.add('Atmos');
} else { } else {
final channels = _formatAudioChannels(stream.channels); final channels = CodecUtils.formatAudioChannels(stream.channels);
if (channels != null) parts.add(channels); if (channels != null) parts.add(channels);
} }
@@ -113,18 +113,3 @@ bool _isAtmos(MediaStream stream) {
stream.displayTitle, stream.displayTitle,
].whereType<String>().any((value) => value.toLowerCase().contains('atmos')); ].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',
};
}
+130 -37
View File
@@ -1,24 +1,60 @@
import 'codec_utils.dart'; 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. /// Sheet rows render the parts on two lines; single-line contexts (track
/// If [title] is non-empty it is added first, then [language], then [extraParts]. /// cycling toasts) use [joined].
/// Falls back to `'$fallbackPrefix ${index + 1}'` when no parts are available. class TrackLabel {
String buildTrackLabel({ final String primary;
String? title,
String? language, /// Technical detail line. Null when there is none — never an empty string.
List<String> extraParts = const [], final String? secondary;
required int index,
String fallbackPrefix = 'Track', const TrackLabel(this.primary, [this.secondary]);
}) {
final parts = <String>[]; String get joined => secondary == null ? primary : '$primary · $secondary';
if (title != null && title.isNotEmpty) parts.add(title);
if (language != null && language.isNotEmpty) parts.add(language); @override
parts.addAll(extraParts); bool operator ==(Object other) => other is TrackLabel && other.primary == primary && other.secondary == secondary;
return parts.isEmpty ? '$fallbackPrefix ${index + 1}' : parts.join(' · ');
@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) { String? cleanTrackMetadataValue(String? value) {
if (value == null) return null; if (value == null) return null;
var cleaned = value.trim(); var cleaned = value.trim();
@@ -82,43 +118,100 @@ String _metadataToken(String value) => value.trim().toUpperCase().replaceAll(Reg
class TrackLabelBuilder { class TrackLabelBuilder {
TrackLabelBuilder._(); TrackLabelBuilder._();
static String buildAudioLabel({ static TrackLabel audioLabel({
String? title, String? title,
String? language, String? language,
String? languageCode,
String? codec, String? codec,
int? channelsCount, int? channels,
String? displayTitle,
required int index, required int index,
}) { }) {
final extraParts = <String>[]; final tech = <String>[];
if (codec != null && codec.isNotEmpty) { if (codec != null && codec.isNotEmpty) tech.add(CodecUtils.formatAudioCodec(codec));
extraParts.add(CodecUtils.formatAudioCodec(codec)); final channelsLabel = CodecUtils.formatAudioChannels(channels);
} if (channelsLabel != null) tech.add(channelsLabel);
if (channelsCount != null) {
extraParts.add('${channelsCount}ch'); return _compose(
} languageDisplay: resolveTrackLanguageDisplay(language: language, languageCode: languageCode),
return buildTrackLabel( title: cleanTrackMetadataValue(title),
title: title, displayTitle: cleanTrackMetadataValue(displayTitle),
language: language?.toUpperCase(), rawLanguageValues: [language, languageCode],
extraParts: extraParts, techParts: tech,
index: index,
fallbackPrefix: 'Audio Track', fallbackPrefix: 'Audio Track',
index: index,
); );
} }
static String buildSubtitleLabel({ static TrackLabel subtitleLabel({
String? title, String? title,
String? language, String? language,
String? languageCode,
String? codec, String? codec,
bool forced = false, bool forced = false,
String? displayTitle,
required int index, required int index,
}) { }) {
final cleanedTitle = cleanSubtitleTitle(title, codec: codec); final cleanedTitle = cleanSubtitleTitle(title, codec: codec);
final cleanedLanguage = cleanTrackMetadataValue(language)?.toUpperCase(); return _compose(
final extraParts = <String>[]; languageDisplay: resolveTrackLanguageDisplay(language: language, languageCode: languageCode),
if (forced && !_metadataToken(cleanedTitle ?? '').split('_').contains('FORCED')) extraParts.add('Forced'); title: cleanedTitle,
if (codec != null && codec.isNotEmpty) { displayTitle: cleanSubtitleTitle(displayTitle, codec: codec),
extraParts.add(CodecUtils.formatSubtitleCodec(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 → `'<prefix> 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<String?> rawLanguageValues,
required List<String> 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<String?> 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;
} }
} }
+3 -2
View File
@@ -64,7 +64,8 @@ class _FileInfoBottomSheetState extends State<FileInfoBottomSheet> {
_buildSectionHeader(t.fileInfo.audio), _buildSectionHeader(t.fileInfo.audio),
const SizedBox(height: 8), const SizedBox(height: 8),
if (info.audioTracks.isNotEmpty) 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.audioTracks.isEmpty) ...[
if (info.audioCodec != null) _buildInfoRow(t.fileInfo.codec, info.audioCodec!), if (info.audioCodec != null) _buildInfoRow(t.fileInfo.codec, info.audioCodec!),
if (info.audioChannelsFormatted != null) if (info.audioChannelsFormatted != null)
@@ -77,7 +78,7 @@ class _FileInfoBottomSheetState extends State<FileInfoBottomSheet> {
_buildSectionHeader(t.fileInfo.subtitles), _buildSectionHeader(t.fileInfo.subtitles),
const SizedBox(height: 8), const SizedBox(height: 8),
for (int i = 0; i < info.subtitleTracks.length; i++) 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), const SizedBox(height: 20),
], ],
@@ -3,6 +3,8 @@ import 'package:plezy/widgets/app_icon.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../i18n/strings.g.dart'; import '../../../i18n/strings.g.dart';
import '../../../mpv/mpv.dart'; import '../../../mpv/mpv.dart';
import '../../../theme/mono_tokens.dart';
import '../../../utils/track_label_builder.dart';
import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/focusable_list_tile.dart';
class TrackSelectionHelper { class TrackSelectionHelper {
@@ -59,7 +61,7 @@ class TrackSelectionHelper {
static Widget buildTrackTile<T>({ static Widget buildTrackTile<T>({
required BuildContext context, required BuildContext context,
required String label, required TrackLabel label,
required bool isSelected, required bool isSelected,
required VoidCallback onTap, required VoidCallback onTap,
Key? key, Key? key,
@@ -71,7 +73,8 @@ class TrackSelectionHelper {
return _buildSelectableTile( return _buildSelectableTile(
context: context, context: context,
key: key, key: key,
label: label, label: label.primary,
secondaryLabel: label.secondary,
isSelected: isSelected, isSelected: isSelected,
onTap: onTap, onTap: onTap,
focusNode: focusNode, focusNode: focusNode,
@@ -101,6 +104,7 @@ class TrackSelectionHelper {
required String label, required String label,
required bool isSelected, required bool isSelected,
required VoidCallback onTap, required VoidCallback onTap,
String? secondaryLabel,
Key? key, Key? key,
FocusNode? focusNode, FocusNode? focusNode,
VoidCallback? onLongPress, VoidCallback? onLongPress,
@@ -118,7 +122,23 @@ class TrackSelectionHelper {
Widget tile = FocusableListTile( Widget tile = FocusableListTile(
key: key, key: key,
focusNode: focusNode, 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, trailing: trailing,
onTap: onTap, onTap: onTap,
onLongPress: onLongPress, onLongPress: onLongPress,
@@ -316,11 +316,11 @@ class _AudioColumnState extends State<_AudioColumn> {
itemCount: widget.tracks.length, itemCount: widget.tracks.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final track = widget.tracks[index]; final track = widget.tracks[index];
final label = TrackLabelBuilder.buildAudioLabel( final label = TrackLabelBuilder.audioLabel(
title: track.title, title: track.title,
language: track.language, language: track.language,
codec: track.codec, codec: track.codec,
channelsCount: track.channelsCount, channels: track.channelsCount,
index: index, index: index,
); );
return TrackSelectionHelper.buildTrackTile<AudioTrack>( return TrackSelectionHelper.buildTrackTile<AudioTrack>(
@@ -426,10 +426,11 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
final track = widget.tracks[index - 1]; final track = widget.tracks[index - 1];
final isPrimary = !isOffSelected && track.id == selectedSub.id; final isPrimary = !isOffSelected && track.id == selectedSub.id;
final isSecondary = hasSecondary && track.id == secondarySub.id; final isSecondary = hasSecondary && track.id == secondarySub.id;
final label = TrackLabelBuilder.buildSubtitleLabel( final label = TrackLabelBuilder.subtitleLabel(
title: track.title, title: track.title,
language: track.language, language: track.language,
codec: track.codec, codec: track.codec,
forced: track.isForced,
index: index - 1, index: index - 1,
); );
@@ -5,6 +5,7 @@ import 'package:flutter/scheduler.dart';
import '../../../../mpv/mpv.dart'; import '../../../../mpv/mpv.dart';
import '../../../../utils/app_logger.dart'; import '../../../../utils/app_logger.dart';
import '../../../../utils/codec_utils.dart';
import 'performance_stats.dart'; import 'performance_stats.dart';
/// Service that polls player properties and provides performance stats via a stream. /// Service that polls player properties and provides performance stats via a stream.
@@ -188,7 +189,7 @@ class PerformanceStatsService {
// Audio metrics // Audio metrics
audioCodec: _formatCodecName(statsMap['audioCodec'] as String?), audioCodec: _formatCodecName(statsMap['audioCodec'] as String?),
audioSamplerate: statsMap['audioSampleRate'] as int?, audioSamplerate: statsMap['audioSampleRate'] as int?,
audioChannels: _formatChannels(statsMap['audioChannels'] as int?), audioChannels: CodecUtils.formatAudioChannels(statsMap['audioChannels'] as int?),
audioBitrate: statsMap['audioBitrate'] as int?, audioBitrate: statsMap['audioBitrate'] as int?,
audioDecoderName: statsMap['audioDecoderName'] as String?, audioDecoderName: statsMap['audioDecoderName'] as String?,
// Tunneling // 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. /// Fetch stats from MPV via property queries.
Future<void> _fetchMpvStats() async { Future<void> _fetchMpvStats() async {
// Fetch core properties in parallel // Fetch core properties in parallel
+62 -5
View File
@@ -1,9 +1,10 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/utils/track_label_builder.dart';
void main() { void main() {
group('MediaSubtitleTrack label', () { 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( final track = MediaSubtitleTrack(
id: 401, id: 401,
index: 0, index: 0,
@@ -15,11 +16,11 @@ void main() {
forced: true, forced: true,
); );
expect(track.labelForIndex(0), 'Forced · ENG · SRT'); expect(track.labelForIndex(0), const TrackLabel('English (Forced)', 'SRT'));
expect(track.label, 'Forced · ENG · 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( final track = MediaSubtitleTrack(
id: 402, id: 402,
index: 1, index: 1,
@@ -31,7 +32,63 @@ void main() {
forced: false, 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'));
}); });
}); });
} }
+23
View File
@@ -133,4 +133,27 @@ void main() {
expect(CodecUtils.formatAudioCodec('weird'), 'WEIRD'); 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);
});
});
} }
+156 -79
View File
@@ -2,126 +2,203 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/utils/track_label_builder.dart'; import 'package:plezy/utils/track_label_builder.dart';
void main() { void main() {
group('buildTrackLabel', () { group('TrackLabel', () {
test('joins title, language, and extra parts with " · "', () { test('joined concatenates primary and secondary with " · "', () {
expect( expect(const TrackLabel('Tamil', 'E-AC3 · 5.1').joined, 'Tamil · E-AC3 · 5.1');
buildTrackLabel(title: 'Director Cut', language: 'EN', extraParts: const ['AAC', '2ch'], index: 0),
'Director Cut · EN · AAC · 2ch',
);
}); });
test('drops null/empty title and language', () { test('joined is just primary when secondary is null', () {
expect(buildTrackLabel(title: null, language: null, extraParts: const ['AAC'], index: 0), 'AAC'); expect(const TrackLabel('Tamil').joined, 'Tamil');
expect(buildTrackLabel(title: '', language: '', extraParts: const ['AAC'], index: 0), 'AAC');
}); });
test('falls back to "<prefix> <index+1>" when no parts', () { test('equality compares both parts', () {
expect(buildTrackLabel(index: 0), 'Track 1'); expect(const TrackLabel('A', 'B'), const TrackLabel('A', 'B'));
expect(buildTrackLabel(index: 4), 'Track 5'); expect(const TrackLabel('A'), isNot(const TrackLabel('A', 'B')));
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');
}); });
}); });
group('TrackLabelBuilder.buildAudioLabel', () { group('resolveTrackLanguageDisplay', () {
test('combines title, uppercased language, codec, channels', () { test('resolves 2-letter, 3-letter, and bibliographic codes', () {
expect( expect(resolveTrackLanguageDisplay(language: 'en'), 'English');
TrackLabelBuilder.buildAudioLabel(title: 'Main', language: 'en', codec: 'aac', channelsCount: 2, index: 0), expect(resolveTrackLanguageDisplay(language: 'eng'), 'English');
'Main · EN · AAC · 2ch', expect(resolveTrackLanguageDisplay(language: 'ta'), 'Tamil');
); expect(resolveTrackLanguageDisplay(language: 'tam'), 'Tamil');
expect(resolveTrackLanguageDisplay(language: 'ger'), 'German');
}); });
test('uppercases language', () { test('resolves region-qualified codes', () {
expect( expect(resolveTrackLanguageDisplay(language: 'en-AU'), 'English (Australia)');
TrackLabelBuilder.buildAudioLabel(language: 'fr', codec: 'ac3', channelsCount: 6, index: 0), expect(resolveTrackLanguageDisplay(language: 'en-US'), 'English');
'FR · AC3 · 6ch', expect(resolveTrackLanguageDisplay(language: 'pt_BR'), 'Portuguese (Brazil)');
);
}); });
test('formats codec via CodecUtils (e.g. eac3 -> E-AC3)', () { test('prefers a mappable languageCode over the language field', () {
final label = TrackLabelBuilder.buildAudioLabel(codec: 'eac3', index: 0); expect(resolveTrackLanguageDisplay(language: 'Englisch', languageCode: 'eng'), 'English');
expect(label, 'E-AC3');
}); });
test('omits codec when null/empty', () { test('unknown bare codes keep the legacy uppercase rendering', () {
expect(TrackLabelBuilder.buildAudioLabel(language: 'en', codec: null, index: 0), 'EN'); expect(resolveTrackLanguageDisplay(language: 'und'), 'UND');
expect(TrackLabelBuilder.buildAudioLabel(language: 'en', codec: '', index: 0), 'EN'); expect(resolveTrackLanguageDisplay(languageCode: 'zxx'), 'ZXX');
}); });
test('omits channels when null', () { test('server display names pass through unchanged', () {
expect(TrackLabelBuilder.buildAudioLabel(language: 'en', codec: 'aac', index: 0), 'EN · AAC'); 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', () { test('cleans lang= metadata prefixes before resolving', () {
expect(TrackLabelBuilder.buildAudioLabel(index: 0), 'Audio Track 1'); expect(resolveTrackLanguageDisplay(language: 'LANG=DEU'), 'German');
expect(TrackLabelBuilder.buildAudioLabel(index: 3), 'Audio Track 4');
}); });
test('zero channel count is still rendered (caller decides validity)', () { test('returns null when nothing usable is provided', () {
// Behavior check: 0 is non-null, so it appears as 0ch. expect(resolveTrackLanguageDisplay(), null);
expect(TrackLabelBuilder.buildAudioLabel(channelsCount: 0, index: 0), '0ch'); expect(resolveTrackLanguageDisplay(language: '', languageCode: ' '), null);
}); });
}); });
group('TrackLabelBuilder.buildSubtitleLabel', () { group('TrackLabelBuilder.audioLabel', () {
test('combines title, uppercased language, friendly codec', () { test('language leads, title and tech detail go to the secondary line', () {
expect( expect(
TrackLabelBuilder.buildSubtitleLabel(title: 'Forced', language: 'en', codec: 'subrip', index: 0), TrackLabelBuilder.audioLabel(
'Forced · EN · SRT', 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', () { test('language with tech detail only', () {
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', () {
expect( expect(
TrackLabelBuilder.buildSubtitleLabel(title: 'Forced', language: 'en', codec: 'subrip', forced: true, index: 0), TrackLabelBuilder.audioLabel(language: 'fr', codec: 'ac3', channels: 6, index: 0),
'Forced · EN · SRT', const TrackLabel('French', 'AC3 · 5.1'),
); );
}); });
test('falls back to "Track N" with default prefix', () { test('drops a title that restates the language', () {
expect(TrackLabelBuilder.buildSubtitleLabel(index: 0), 'Track 1'); expect(
expect(TrackLabelBuilder.buildSubtitleLabel(index: 7), 'Track 8'); 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', () { test('cleans raw Jellyfin/ExoPlayer subtitle metadata prefixes', () {
expect( expect(
TrackLabelBuilder.buildSubtitleLabel( TrackLabelBuilder.subtitleLabel(title: 'title=German - SUBRIP', language: 'LANG=DEU', codec: 'srt', index: 0),
title: 'title=German - SUBRIP', const TrackLabel('German', 'SRT'),
language: 'LANG=DEU',
codec: 'srt',
index: 0,
),
'German · DEU · SRT',
); );
expect( expect(
TrackLabelBuilder.buildSubtitleLabel( TrackLabelBuilder.subtitleLabel(
title: 'title=English - Default - SUBRIP', title: 'title=English - Default - SUBRIP',
language: 'LANG=ENG', language: 'LANG=ENG',
codec: 'subrip', codec: 'subrip',
index: 1, index: 1,
), ),
'English - Default · ENG · SRT', const TrackLabel('English', 'English - Default · SRT'),
); );
}); });
}); });
+51
View File
@@ -3,9 +3,25 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/mpv/mpv.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/models/track_controls_state.dart';
import 'package:plezy/widgets/video_controls/sheets/track_sheet.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() { void main() {
TestWidgetsFlutterBinding.ensureInitialized(); 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', () { group('TrackControlsState.hasSubtitleControls', () {
test('counts source subtitles only when source switching is available', () { test('counts source subtitles only when source switching is available', () {
final sourceSubtitle = MediaSubtitleTrack(id: 1, selected: false, forced: false); final sourceSubtitle = MediaSubtitleTrack(id: 1, selected: false, forced: false);
@@ -106,6 +156,7 @@ Future<void> _pumpTrackSheet(
}) async { }) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: Scaffold( home: Scaffold(
body: SizedBox( body: SizedBox(
width: 700, width: 700,