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 '../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<String> 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 = <String>[];
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.
+2 -2
View File
@@ -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));
}
}
+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.
static String formatAudioCodec(String codec) {
final lower = codec.toLowerCase();
+1 -16
View File
@@ -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<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 '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<String> extraParts = const [],
required int index,
String fallbackPrefix = 'Track',
}) {
final parts = <String>[];
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 = <String>[];
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 = <String>[];
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 = <String>[];
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 → `'<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),
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<FileInfoBottomSheet> {
_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),
],
@@ -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<T>({
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,
@@ -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<AudioTrack>(
@@ -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,
);
@@ -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<void> _fetchMpvStats() async {
// Fetch core properties in parallel
+62 -5
View File
@@ -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'));
});
});
}
+23
View File
@@ -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);
});
});
}
+156 -79
View File
@@ -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 "<prefix> <index+1>" 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'),
);
});
});
+51
View File
@@ -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<void> _pumpTrackSheet(
}) async {
await tester.pumpWidget(
MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: Scaffold(
body: SizedBox(
width: 700,