@@ -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.
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+2
-13
@@ -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
|
||||||
|
|||||||
@@ -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'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user