fix(i18n): translate the player, downloads and server-setup text left in English
A Portuguese user reported "Skip Intro" rendering in English on Android TV.
The locale files were not the problem - all 22 were structurally complete.
skip_marker_button.dart simply never imported strings.g.dart and assigned
'Skip Intro' / 'Skip Credits' / 'Next Episode' as plain literals. An audit of
lib/ found ~120 more sites in the same state, in four shapes that need
different fixes:
A literal in a file that never imported the i18n layer is the easy one -
skip_marker_button, performance_stats, track_label_builder and codec_utils all
render text with no `t` in the file at all. TrackLabelBuilder._compose now takes
a fallbackLabel builder instead of an English fallbackPrefix, so the caller
supplies t.audioTracks.track / t.videoControls.subtitleTrack and every unnamed
audio and subtitle row in the track menus is localized.
English reaching the user through an exception message is the widest one, and
it needs care: MediaServerException.message feeds both toString() - logs and
Sentry grouping - and verbatim UI display. Localizing it in place would make
bug-report logs follow the user's locale and split one Sentry issue into 22.
The MediaServer and Seerr families instead gain a nullable `display` alongside
the English `message`, and the six screens that print these errors read
`display ?? message`. PlaybackException keeps the opposite rule, because it
already carries a PlaybackFailureReason for logic and classifyPlaybackFailure
already builds it from t.messages: its stragglers are localized at the throw
site. That also removes the literal "Exception: " prefix Live TV users saw on
a tune failure, since PlaybackException.toString() returns the bare message.
Localized parts hand-concatenated with bare English are the shape no search for
Text('...') can find: '${t.common.pause} auto-scroll' on the home carousel,
'${day} at ${time}' on the Live TV schedule row, and an actor-screen count that
hand-rolled its plural as `n == 1 ? 'title' : 'titles'` - wrong for ru and pl
regardless of translation, now a real Slang plural.
Finally a literal assigned to provider state that a widget renders later:
DownloadProgress.errorMessage, and the four background_downloader notification
bodies, which sit inside a plugin config call where no widget-shaped search
reaches them.
Two things surfaced while converting. track_chapter_controls compared a track
label against 'Audio Track N' to swap in a localized version; once the builder
localized its own fallback that branch became unreachable, so it and the
orphaned _joinTrackLabel are gone. And discovery_view's PeerError fallback arm
looks like a leak but is not - its producers already localize, and a test says
so - so it stays as it is.
All 21 non-base locales are translated, including the 21 keys left empty by
earlier commits that were falling back to English. No locale has an empty value.
scripts/check_hardcoded_strings.py guards the three shapes a structural check
can see, and runs in ci_checks.sh after translation hygiene. Its first draft
passed its own tests while missing this very bug, because 'Skip Intro' is bound
to a local rather than handed to Text(); the name-bound rule that closes that
gap is restricted to phrase-shaped literals, or it cannot tell copy from the
identifiers this codebase binds constantly ('cast_row', 'auto', 'liveTv'). It
cannot see English inside a throw or assigned to a provider field - neither is
distinguishable from a log message without dataflow analysis - and the docstring
says so. label: and actionLabel: are deliberately unscanned: here they name a
diagnostic operation, and a check that is chronically red is a check that gets
switched off.
One commit rather than one per area: the keys, the 22 locale files and the
generated output are a single unit, and any partial split fails the repo's own
unused-key scan on the way through.
close #1856
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
import 'overlay_sheet.dart';
|
||||
|
||||
@@ -103,6 +104,7 @@ class BottomSheetHeader extends StatelessWidget {
|
||||
ExcludeFocusTraversal(
|
||||
child: IconButton(
|
||||
focusNode: closeFocusNode,
|
||||
tooltip: t.common.close,
|
||||
icon: AppIcon(Symbols.close_rounded, fill: 1, color: iconColor),
|
||||
onPressed: onClose ?? () => OverlaySheetController.closeAdaptive(context),
|
||||
),
|
||||
|
||||
@@ -171,7 +171,7 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
|
||||
// Get show metadata from first episode
|
||||
final firstEpisode = widget.metadata[episodes.first.key];
|
||||
final showTitle = firstEpisode?.grandparentTitle ?? 'Unknown Show';
|
||||
final showTitle = firstEpisode?.grandparentTitle ?? t.downloads.unknownShow;
|
||||
|
||||
// Group episodes by season
|
||||
final Map<String, List<MapEntry<String, DownloadProgress>>> seasonGroups = {};
|
||||
@@ -199,7 +199,7 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
? firstEpisode!.parentTitle!
|
||||
: seasonNumber != null
|
||||
? t.common.seasonNumber(number: seasonNumber)
|
||||
: 'Unknown Season';
|
||||
: t.downloads.unknownSeason;
|
||||
|
||||
// Build episode nodes
|
||||
final List<DownloadTreeNode> episodeNodes = [];
|
||||
@@ -289,7 +289,7 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
|
||||
// Album/artist names from any track's parent fields
|
||||
final firstTrack = widget.metadata[tracks.first.key];
|
||||
final albumTitle = firstTrack?.albumTitle ?? 'Unknown Album';
|
||||
final albumTitle = firstTrack?.albumTitle ?? t.downloads.unknownAlbum;
|
||||
final artistTitle = firstTrack?.albumArtistTitle;
|
||||
final albumNodeTitle = artistTitle != null && artistTitle.isNotEmpty ? '$artistTitle - $albumTitle' : albumTitle;
|
||||
|
||||
@@ -779,7 +779,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
String _getNodeSummary() {
|
||||
final total = widget.node.children.length;
|
||||
final completed = widget.node.completedChildrenCount;
|
||||
return '$completed/$total completed';
|
||||
return t.downloads.completedOfTotal(completed: completed, total: total);
|
||||
}
|
||||
|
||||
/// The actions this row offers, in render order. Single source of truth:
|
||||
|
||||
@@ -144,7 +144,7 @@ class _TvColorPickerState extends State<TvColorPicker> with ControllerDisposerMi
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_channelRow(
|
||||
label: 'H',
|
||||
label: t.accessibility.hueShort,
|
||||
semanticLabel: Translations.of(context).accessibility.hue,
|
||||
value: _hue,
|
||||
max: 360,
|
||||
@@ -157,7 +157,7 @@ class _TvColorPickerState extends State<TvColorPicker> with ControllerDisposerMi
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_channelRow(
|
||||
label: 'S',
|
||||
label: t.accessibility.saturationShort,
|
||||
semanticLabel: Translations.of(context).accessibility.saturation,
|
||||
value: _saturation,
|
||||
max: 100,
|
||||
@@ -169,7 +169,7 @@ class _TvColorPickerState extends State<TvColorPicker> with ControllerDisposerMi
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_channelRow(
|
||||
label: 'V',
|
||||
label: t.accessibility.valueShort,
|
||||
semanticLabel: Translations.of(context).accessibility.brightness,
|
||||
value: _value,
|
||||
max: 100,
|
||||
|
||||
@@ -267,7 +267,7 @@ class _TvVirtualKeyboardDialogState extends State<_TvVirtualKeyboardDialog> {
|
||||
List<List<_TvKey>> _buildSymbolRows() {
|
||||
return [
|
||||
[
|
||||
const _TvKey.action('ABC', _TvKeyType.symbols),
|
||||
_TvKey.action(t.common.letterKeys, _TvKeyType.symbols),
|
||||
..._symbols(['!', '?', r'$', '%', '^', '*', '+', '=', '~']),
|
||||
const _TvKey.action('Del', _TvKeyType.backspace, icon: Symbols.backspace_rounded),
|
||||
const _TvKey.spacer(),
|
||||
|
||||
@@ -602,7 +602,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
case _SettingsView.shader:
|
||||
return t.shaders.title;
|
||||
case _SettingsView.dvConversion:
|
||||
return 'DV Conversion Mode';
|
||||
return t.settings.dvConversionMode;
|
||||
case _SettingsView.hdrToneMapping:
|
||||
return t.videoSettings.hdrToneMapping;
|
||||
}
|
||||
|
||||
@@ -52,9 +52,9 @@ class _PlayerPerformanceOverlayState extends State<PlayerPerformanceOverlay> {
|
||||
|
||||
final sections = <Widget>[
|
||||
_buildSection(Symbols.videocam_rounded, t.fileInfo.video, [
|
||||
_metric(t.fileInfo.codec, _stats.videoCodec ?? 'N/A'),
|
||||
_metric(t.fileInfo.codec, _stats.videoCodec ?? t.common.notAvailable),
|
||||
_metric(t.fileInfo.resolution, _stats.resolution),
|
||||
if (_stats.hasValidVideoFps) _metric('FPS', _stats.videoFpsFormatted),
|
||||
if (_stats.hasValidVideoFps) _metric(t.performanceOverlay.fps, _stats.videoFpsFormatted),
|
||||
if (_stats.hasValidVideoBitrate) _metric(t.fileInfo.bitrate, _stats.videoBitrateFormatted),
|
||||
_metric(t.performanceOverlay.decoder, _stats.hwdecFormatted),
|
||||
if (!isMpv && _stats.videoDecoderName != null) _metric(t.performanceOverlay.rawDecoder, _stats.videoDecoderRaw),
|
||||
@@ -69,19 +69,19 @@ class _PlayerPerformanceOverlayState extends State<PlayerPerformanceOverlay> {
|
||||
_buildSection(Symbols.volume_up_rounded, t.fileInfo.audio, [
|
||||
if (_stats.audioCodec != null) _metric(t.fileInfo.codec, _stats.audioCodec!),
|
||||
_metric(t.performanceOverlay.sampleRate, _stats.sampleRateFormatted),
|
||||
_metric(t.fileInfo.channels, _stats.audioChannels ?? 'N/A'),
|
||||
_metric(t.fileInfo.channels, _stats.audioChannels ?? t.common.notAvailable),
|
||||
if (_stats.hasValidAudioBitrate) _metric(t.fileInfo.bitrate, _stats.audioBitrateFormatted),
|
||||
if (!isMpv && _stats.audioDecoderName != null)
|
||||
_metric(t.performanceOverlay.decoder, _stats.audioDecoderFormatted),
|
||||
]),
|
||||
if (isMpv)
|
||||
_buildSection(Symbols.palette_rounded, t.performanceOverlay.color, [
|
||||
_metric(t.performanceOverlay.pixelFormat, _stats.pixelformat ?? 'N/A'),
|
||||
_metric(t.performanceOverlay.pixelFormat, _stats.pixelformat ?? t.common.notAvailable),
|
||||
if (_stats.hwPixelformat != null && _stats.hwPixelformat != _stats.pixelformat)
|
||||
_metric(t.performanceOverlay.hwFormat, _stats.hwPixelformat!),
|
||||
_metric(t.performanceOverlay.matrix, _stats.colormatrix ?? 'N/A'),
|
||||
_metric(t.performanceOverlay.primaries, _stats.primaries ?? 'N/A'),
|
||||
_metric(t.performanceOverlay.transfer, _stats.gamma ?? 'N/A'),
|
||||
_metric(t.performanceOverlay.matrix, _stats.colormatrix ?? t.common.notAvailable),
|
||||
_metric(t.performanceOverlay.primaries, _stats.primaries ?? t.common.notAvailable),
|
||||
_metric(t.performanceOverlay.transfer, _stats.gamma ?? t.common.notAvailable),
|
||||
]),
|
||||
_buildSection(Symbols.speed_rounded, t.performanceOverlay.performance, [
|
||||
if (isMpv) _metric(t.performanceOverlay.renderFps, _stats.actualFpsFormatted),
|
||||
@@ -94,7 +94,7 @@ class _PlayerPerformanceOverlayState extends State<PlayerPerformanceOverlay> {
|
||||
_metric(t.performanceOverlay.dvSampleAverage, _stats.dvAvgSampleProcessingFormatted),
|
||||
]),
|
||||
if (_stats.hasHdrMetadata)
|
||||
_buildSection(Symbols.hdr_on_rounded, 'HDR', [
|
||||
_buildSection(Symbols.hdr_on_rounded, t.videoSettings.hdr, [
|
||||
if (_stats.maxLuma != null) _metric(t.performanceOverlay.maxLuma, _stats.maxLumaFormatted),
|
||||
if (_stats.minLuma != null) _metric(t.performanceOverlay.minLuma, _stats.minLumaFormatted),
|
||||
if (_stats.maxCll != null) _metric(t.performanceOverlay.maxCll, _stats.maxCllFormatted),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import '../../../../i18n/strings.g.dart';
|
||||
|
||||
/// Data model for video player performance statistics.
|
||||
///
|
||||
/// Contains metrics queried from the video player (MPV or ExoPlayer)
|
||||
@@ -178,13 +180,13 @@ class PerformanceStats {
|
||||
|
||||
/// Format video resolution as "WxH".
|
||||
String get resolution {
|
||||
if (videoWidth == null || videoHeight == null) return 'N/A';
|
||||
if (videoWidth == null || videoHeight == null) return t.common.notAvailable;
|
||||
return '${videoWidth}x$videoHeight';
|
||||
}
|
||||
|
||||
/// Format video bitrate in Mbps.
|
||||
String get videoBitrateFormatted {
|
||||
if (videoBitrate == null || videoBitrate == 0) return 'N/A';
|
||||
if (videoBitrate == null || videoBitrate == 0) return t.common.notAvailable;
|
||||
final mbps = videoBitrate! / 1_000_000;
|
||||
return '${mbps.toStringAsFixed(1)} Mbps';
|
||||
}
|
||||
@@ -193,7 +195,7 @@ class PerformanceStats {
|
||||
/// The smaller one binds, so showing both explains a duration setting that
|
||||
/// appears to have no effect on high-bitrate media.
|
||||
String get bufferLimitsFormatted {
|
||||
if (bufferMaxMs == null || bufferMaxMs! <= 0) return 'N/A';
|
||||
if (bufferMaxMs == null || bufferMaxMs! <= 0) return t.common.notAvailable;
|
||||
final duration = '${bufferMaxMs! ~/ 1000}s';
|
||||
if (bufferTargetBytes == null) return duration;
|
||||
final targetBufferMb = bufferTargetBytes! ~/ (1024 * 1024);
|
||||
@@ -202,67 +204,67 @@ class PerformanceStats {
|
||||
|
||||
/// Format audio bitrate in kbps.
|
||||
String get audioBitrateFormatted {
|
||||
if (audioBitrate == null || audioBitrate == 0) return 'N/A';
|
||||
if (audioBitrate == null || audioBitrate == 0) return t.common.notAvailable;
|
||||
final kbps = audioBitrate! / 1000;
|
||||
return '${kbps.toStringAsFixed(0)} kbps';
|
||||
}
|
||||
|
||||
/// Format audio sample rate in kHz.
|
||||
String get sampleRateFormatted {
|
||||
if (audioSamplerate == null) return 'N/A';
|
||||
if (audioSamplerate == null) return t.common.notAvailable;
|
||||
final khz = audioSamplerate! / 1000;
|
||||
return '${khz.toStringAsFixed(1)} kHz';
|
||||
}
|
||||
|
||||
/// Format FPS with 2 decimal places.
|
||||
String get actualFpsFormatted {
|
||||
if (actualFps == null) return 'N/A';
|
||||
if (actualFps == null) return t.common.notAvailable;
|
||||
return actualFps!.toStringAsFixed(2);
|
||||
}
|
||||
|
||||
/// Format source FPS with 2 decimal places.
|
||||
String get videoFpsFormatted {
|
||||
if (videoFps == null) return 'N/A';
|
||||
if (videoFps == null) return t.common.notAvailable;
|
||||
return videoFps!.toStringAsFixed(2);
|
||||
}
|
||||
|
||||
/// Format A/V sync in milliseconds.
|
||||
String get avsyncFormatted {
|
||||
if (avsyncChange == null) return 'N/A';
|
||||
if (avsyncChange == null) return t.common.notAvailable;
|
||||
final ms = (avsyncChange! * 1000).round();
|
||||
return '${ms > 0 ? '+' : ''}${ms}ms';
|
||||
}
|
||||
|
||||
/// Format cache used in MB.
|
||||
String get cacheUsedFormatted {
|
||||
if (cacheUsed == null) return 'N/A';
|
||||
if (cacheUsed == null) return t.common.notAvailable;
|
||||
final mb = cacheUsed! / (1024 * 1024);
|
||||
return '${mb.toStringAsFixed(1)} MB';
|
||||
}
|
||||
|
||||
/// Format cache limit in MB.
|
||||
String get cacheLimitFormatted {
|
||||
if (cacheLimit == null || cacheLimit! <= 0) return 'N/A';
|
||||
if (cacheLimit == null || cacheLimit! <= 0) return t.common.notAvailable;
|
||||
final mb = cacheLimit! / (1024 * 1024);
|
||||
return '${mb.toStringAsFixed(1)} MB';
|
||||
}
|
||||
|
||||
/// Format cache speed in MB/s.
|
||||
String get cacheSpeedFormatted {
|
||||
if (cacheSpeed == null) return 'N/A';
|
||||
if (cacheSpeed == null) return t.common.notAvailable;
|
||||
final mbps = cacheSpeed! / (1024 * 1024);
|
||||
return '${mbps.toStringAsFixed(1)} MB/s';
|
||||
}
|
||||
|
||||
/// Format cache duration in seconds.
|
||||
String get cacheDurationFormatted {
|
||||
if (cacheDuration == null) return 'N/A';
|
||||
if (cacheDuration == null) return t.common.notAvailable;
|
||||
return '${cacheDuration!.toStringAsFixed(1)}s';
|
||||
}
|
||||
|
||||
/// Format display FPS.
|
||||
String get displayFpsFormatted {
|
||||
if (displayFps == null) return 'N/A';
|
||||
if (displayFps == null) return t.common.notAvailable;
|
||||
return displayFps!.toStringAsFixed(0);
|
||||
}
|
||||
|
||||
@@ -280,102 +282,111 @@ class PerformanceStats {
|
||||
final decoder = videoDecoderName!;
|
||||
if (decoder.contains('c2.') || decoder.contains('OMX.') || decoder.contains('.hw.')) {
|
||||
// Extract a cleaner name
|
||||
if (decoder.contains('c2.android.')) return 'Android HW';
|
||||
if (decoder.contains('c2.nvidia')) return 'NVIDIA HW';
|
||||
if (decoder.contains('c2.qti') || decoder.contains('c2.qcom')) return 'Qualcomm HW';
|
||||
if (decoder.contains('c2.mtk') || decoder.contains('c2.mediatek')) return 'MediaTek HW';
|
||||
if (decoder.contains('c2.exynos') || decoder.contains('c2.samsung')) return 'Exynos HW';
|
||||
if (decoder.contains('OMX.google')) return 'Software';
|
||||
return 'Hardware';
|
||||
if (decoder.contains('c2.android.')) return t.performanceOverlay.decoderAndroidHw;
|
||||
if (decoder.contains('c2.nvidia')) return t.performanceOverlay.decoderNvidiaHw;
|
||||
if (decoder.contains('c2.qti') || decoder.contains('c2.qcom')) {
|
||||
return t.performanceOverlay.decoderQualcommHw;
|
||||
}
|
||||
if (decoder.contains('c2.mtk') || decoder.contains('c2.mediatek')) {
|
||||
return t.performanceOverlay.decoderMediatekHw;
|
||||
}
|
||||
if (decoder.contains('c2.exynos') || decoder.contains('c2.samsung')) {
|
||||
return t.performanceOverlay.decoderExynosHw;
|
||||
}
|
||||
if (decoder.contains('OMX.google')) return t.performanceOverlay.decoderSoftware;
|
||||
return t.performanceOverlay.decoderHardware;
|
||||
}
|
||||
return 'Software';
|
||||
return t.performanceOverlay.decoderSoftware;
|
||||
}
|
||||
// For MPV, use hwdec-current property
|
||||
if (hwdecCurrent == null || hwdecCurrent!.isEmpty || hwdecCurrent == 'no') {
|
||||
return 'Software';
|
||||
return t.performanceOverlay.decoderSoftware;
|
||||
}
|
||||
return hwdecCurrent!;
|
||||
}
|
||||
|
||||
/// Raw video decoder name (e.g. c2.qti.video.decoder.hevc).
|
||||
String get videoDecoderRaw => videoDecoderName ?? 'N/A';
|
||||
String get videoDecoderRaw => videoDecoderName ?? t.common.notAvailable;
|
||||
|
||||
/// Format audio decoder name for display.
|
||||
String get audioDecoderFormatted => audioDecoderName ?? 'N/A';
|
||||
String get audioDecoderFormatted => audioDecoderName ?? t.common.notAvailable;
|
||||
|
||||
/// Format tunneled playback status with reason.
|
||||
String get tunneledPlaybackFormatted => tunnelingStatus ?? (tunneledPlayback ? 'Active' : 'Off');
|
||||
String get tunneledPlaybackFormatted =>
|
||||
tunnelingStatus ?? (tunneledPlayback ? t.performanceOverlay.tunnelingActive : t.common.off);
|
||||
|
||||
/// Format DV conversion mode for display.
|
||||
String get dvConversionFormatted => dvConversionMode == 'DV81' ? '7→8.1' : '7→HEVC';
|
||||
|
||||
/// Format Dolby Vision source profile.
|
||||
String get dvSourceProfileFormatted => dvSourceProfile == null ? 'N/A' : 'P$dvSourceProfile';
|
||||
String get dvSourceProfileFormatted => dvSourceProfile == null ? t.common.notAvailable : 'P$dvSourceProfile';
|
||||
|
||||
/// Format Dolby Vision playback path.
|
||||
String get dvPlaybackPathFormatted => dvPlaybackPath ?? 'N/A';
|
||||
String get dvPlaybackPathFormatted => dvPlaybackPath ?? t.common.notAvailable;
|
||||
|
||||
/// Format DV RPU conversion totals.
|
||||
String get dvRpuCountFormatted {
|
||||
final converted = dvConvertedRpus ?? 0;
|
||||
final failures = dvRpuConversionFailures ?? 0;
|
||||
return failures > 0 ? '$converted ($failures failed)' : converted.toString();
|
||||
return failures > 0
|
||||
? t.performanceOverlay.dvRpuFailed(converted: converted, failures: failures)
|
||||
: converted.toString();
|
||||
}
|
||||
|
||||
/// Format DV conversion timing in microseconds.
|
||||
String get dvAvgRpuConversionFormatted {
|
||||
final us = dvAvgRpuConversionUs;
|
||||
if (us == null || us <= 0) return 'N/A';
|
||||
if (us == null || us <= 0) return t.common.notAvailable;
|
||||
return '${us}us';
|
||||
}
|
||||
|
||||
/// Format DV sample processing timing in microseconds.
|
||||
String get dvAvgSampleProcessingFormatted {
|
||||
final us = dvAvgSampleProcessingUs;
|
||||
if (us == null || us <= 0) return 'N/A';
|
||||
if (us == null || us <= 0) return t.common.notAvailable;
|
||||
return '${us}us';
|
||||
}
|
||||
|
||||
/// Format app memory usage in MB.
|
||||
String get appMemoryFormatted {
|
||||
if (appMemoryBytes == null) return 'N/A';
|
||||
if (appMemoryBytes == null) return t.common.notAvailable;
|
||||
final mb = appMemoryBytes! / (1024 * 1024);
|
||||
return '${mb.toStringAsFixed(1)} MB';
|
||||
}
|
||||
|
||||
/// Format UI FPS with 1 decimal place.
|
||||
String get uiFpsFormatted {
|
||||
if (uiFps == null) return 'N/A';
|
||||
if (uiFps == null) return t.common.notAvailable;
|
||||
return uiFps!.toStringAsFixed(1);
|
||||
}
|
||||
|
||||
/// Format rotation in degrees.
|
||||
String get rotateFormatted {
|
||||
if (rotate == null || rotate == 0) return 'N/A';
|
||||
if (rotate == null || rotate == 0) return t.common.notAvailable;
|
||||
return '$rotate°';
|
||||
}
|
||||
|
||||
/// Format luminance value in cd/m².
|
||||
String get maxLumaFormatted {
|
||||
if (maxLuma == null) return 'N/A';
|
||||
if (maxLuma == null) return t.common.notAvailable;
|
||||
return '${maxLuma!.toStringAsFixed(0)} cd/m²';
|
||||
}
|
||||
|
||||
/// Format minimum luminance value in cd/m².
|
||||
String get minLumaFormatted {
|
||||
if (minLuma == null) return 'N/A';
|
||||
if (minLuma == null) return t.common.notAvailable;
|
||||
return '${minLuma!.toStringAsFixed(4)} cd/m²';
|
||||
}
|
||||
|
||||
/// Format MaxCLL value in cd/m².
|
||||
String get maxCllFormatted {
|
||||
if (maxCll == null) return 'N/A';
|
||||
if (maxCll == null) return t.common.notAvailable;
|
||||
return '${maxCll!.toStringAsFixed(0)} cd/m²';
|
||||
}
|
||||
|
||||
/// Format MaxFALL value in cd/m².
|
||||
String get maxFallFormatted {
|
||||
if (maxFall == null) return 'N/A';
|
||||
if (maxFall == null) return t.common.notAvailable;
|
||||
return '${maxFall!.toStringAsFixed(0)} cd/m²';
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/services.dart' show KeyDownEvent, LogicalKeyboardKey;
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../../../focus/focusable_wrapper.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../../media/media_source_info.dart';
|
||||
import '../../../theme/mono_tokens.dart';
|
||||
import '../../app_icon.dart';
|
||||
@@ -41,11 +42,11 @@ class SkipMarkerButton extends StatelessWidget {
|
||||
final showNextEpisode = creditsAtEnd && hasNextEpisode;
|
||||
String baseButtonText;
|
||||
if (showNextEpisode) {
|
||||
baseButtonText = 'Next Episode';
|
||||
baseButtonText = t.videoControls.nextEpisode;
|
||||
} else if (isCredits) {
|
||||
baseButtonText = 'Skip Credits';
|
||||
baseButtonText = t.videoControls.skipCredits;
|
||||
} else {
|
||||
baseButtonText = 'Skip Intro';
|
||||
baseButtonText = t.videoControls.skipIntro;
|
||||
}
|
||||
|
||||
final remainingSeconds = isAutoSkipActive && shouldShowAutoSkip
|
||||
|
||||
@@ -429,12 +429,7 @@ class TrackChapterControls extends StatelessWidget {
|
||||
channels: audio.channelsCount,
|
||||
index: visibleIndex,
|
||||
);
|
||||
final fallback = 'Audio Track ${visibleIndex + 1}';
|
||||
values.add(
|
||||
label.primary == fallback
|
||||
? _joinTrackLabel(t.audioTracks.track(n: visibleIndex + 1), label.secondary)
|
||||
: label.joined,
|
||||
);
|
||||
values.add(label.joined);
|
||||
}
|
||||
|
||||
final subtitle = selection.subtitle;
|
||||
@@ -454,10 +449,6 @@ class TrackChapterControls extends StatelessWidget {
|
||||
return values.isEmpty ? null : values.join(', ');
|
||||
}
|
||||
|
||||
String _joinTrackLabel(String primary, String? secondary) {
|
||||
return secondary == null ? primary : '$primary · $secondary';
|
||||
}
|
||||
|
||||
/// Calculate total button count for navigation
|
||||
int _getButtonCount(bool isMobile, bool isDesktop) {
|
||||
final state = trackControlsState;
|
||||
|
||||
Reference in New Issue
Block a user