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:
edde746
2026-08-10 15:32:43 +02:00
parent 3177290083
commit 369c6279d6
98 changed files with 8858 additions and 1110 deletions
+2
View File
@@ -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),
),
+4 -4
View File
@@ -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:
+3 -3
View File
@@ -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,
+1 -1
View File
@@ -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;