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