Files
plezy/lib/screens/video_player/widgets/player_prompt_overlays.dart
T
edde746 369c6279d6 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
2026-08-10 15:32:43 +02:00

581 lines
18 KiB
Dart

import 'package:flutter/foundation.dart' show ValueListenable, listEquals;
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart';
import '../../../focus/focusable_button.dart';
import '../../../i18n/strings.g.dart';
import '../../../media/media_item.dart';
import '../../../providers/playback_state_provider.dart';
import '../../../services/pip_service.dart';
import '../../../utils/platform_detector.dart';
import '../../../watch_together/providers/watch_together_provider.dart';
import '../../../watch_together/widgets/watch_together_overlay.dart';
import '../../../widgets/app_icon.dart';
import '../../../widgets/video_controls/player_chrome_controller.dart';
class VideoPlayerMacPipPlaceholder extends StatelessWidget {
const VideoPlayerMacPipPlaceholder({super.key});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: PipService().isPipActive,
builder: (context, isInPip, child) {
if (!isInPip) return const SizedBox.shrink();
return Positioned.fill(
child: Container(
color: Colors.black,
child: Center(
child: Column(
mainAxisSize: .min,
children: [
AppIcon(Symbols.picture_in_picture_alt_rounded, size: 48, color: Colors.white.withValues(alpha: 0.5)),
const SizedBox(height: 12),
Text(
t.videoControls.pipActive,
style: TextStyle(color: Colors.white.withValues(alpha: 0.5), fontSize: 14),
),
],
),
),
),
);
},
);
}
}
/// The player's loading spinner.
///
/// Labelled rather than silent: a bare [CircularProgressIndicator] contributes
/// no semantics at all, so a screen reader announced nothing while the picture
/// was still coming up. The label also makes "the first frame has not rendered
/// yet" an observable state instead of something inferred from the chrome,
/// which a television no longer raises on startup (#1765).
class PlayerLoadingIndicator extends StatelessWidget {
final double strokeWidth;
const PlayerLoadingIndicator({super.key, this.strokeWidth = 4});
@override
Widget build(BuildContext context) {
return CircularProgressIndicator(
color: Colors.white,
strokeWidth: strokeWidth,
semanticsLabel: t.videoControls.loadingVideo,
);
}
}
class VideoPlayerBufferingOverlay extends StatelessWidget {
final ValueListenable<bool> isBuffering;
final ValueListenable<bool> hasFirstFrame;
final ValueListenable<bool> isExiting;
const VideoPlayerBufferingOverlay({
super.key,
required this.isBuffering,
required this.hasFirstFrame,
required this.isExiting,
});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: PipService().isPipActive,
builder: (context, isInPip, child) {
if (isInPip) return const SizedBox.shrink();
return ValueListenableBuilder<bool>(
valueListenable: isBuffering,
builder: (context, buffering, child) {
return ValueListenableBuilder<bool>(
valueListenable: hasFirstFrame,
builder: (context, hasFrame, child) {
if ((!buffering && hasFrame) || isExiting.value) return const SizedBox.shrink();
return Positioned.fill(
child: IgnorePointer(
child: Center(
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), shape: BoxShape.circle),
child: const PlayerLoadingIndicator(strokeWidth: 3),
),
),
),
);
},
);
},
);
},
);
}
}
class VideoPlayerWatchTogetherOverlays extends StatelessWidget {
const VideoPlayerWatchTogetherOverlays({super.key});
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: Stack(
fit: StackFit.expand,
children: [
Selector<WatchTogetherProvider, bool>(
selector: (_, provider) => provider.isWaitingForHostReconnect,
builder: (context, isWaiting, child) {
if (!isWaiting) return const SizedBox.shrink();
return Positioned(
bottom: 120,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: const BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.all(Radius.circular(20)),
),
child: Row(
mainAxisSize: .min,
children: [
if (PlatformDetector.isTV())
const AppIcon(Symbols.sync_rounded, size: 14, color: Colors.white)
else
const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
),
const SizedBox(width: 8),
Text(
t.watchTogether.reconnectingToHost,
style: const TextStyle(color: Colors.white, fontSize: 12),
),
],
),
),
),
);
},
),
const ParticipantNotificationOverlay(),
const WaitingForParticipantsIndicator(),
const SyncingIndicator(),
],
),
);
}
}
class VideoPlayerExitOverlay extends StatelessWidget {
final ValueListenable<bool> isExiting;
const VideoPlayerExitOverlay({super.key, required this.isExiting});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: isExiting,
builder: (context, exiting, child) {
if (!exiting) return const SizedBox.shrink();
return const Positioned.fill(child: ColoredBox(color: Colors.black));
},
);
}
}
class VideoPlayerPlayNextOverlay extends StatelessWidget {
final bool visible;
final MediaItem? nextEpisode;
final int autoPlayCountdown;
final FocusNode cancelFocusNode;
final FocusNode confirmFocusNode;
final PlayerChromeController chromeController;
final VoidCallback onCancel;
final VoidCallback onPlayNext;
const VideoPlayerPlayNextOverlay({
super.key,
required this.visible,
required this.nextEpisode,
required this.autoPlayCountdown,
required this.cancelFocusNode,
required this.confirmFocusNode,
required this.chromeController,
required this.onCancel,
required this.onPlayNext,
});
@override
Widget build(BuildContext context) {
final episode = nextEpisode;
if (episode == null) return const SizedBox.shrink();
return _VideoPlayerPromptShell(
visible: visible,
chromeController: chromeController,
focusNodes: [cancelFocusNode, confirmFocusNode],
children: [
_PlayNextEpisodeHeader(episode: episode),
const SizedBox(height: 12),
_VideoPlayerPromptActions(
cancelLabel: t.common.cancel,
cancelFocusNode: cancelFocusNode,
onCancel: onCancel,
confirmFocusNode: confirmFocusNode,
onConfirm: onPlayNext,
confirmChildren: [
if (autoPlayCountdown > 0) ...[
Text('$autoPlayCountdown'),
const SizedBox(width: 4),
const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18),
] else
Text(t.videoControls.playNext),
],
),
],
);
}
}
class _PlayNextEpisodeHeader extends StatelessWidget {
final MediaItem episode;
const _PlayNextEpisodeHeader({required this.episode});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: .start,
children: [
Consumer<PlaybackStateProvider>(
builder: (context, playbackState, child) {
final isShuffleActive = playbackState.isShuffleActive;
return Row(
children: [
Text(
t.videoControls.nextEpisode,
style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 12, fontWeight: .w500),
),
if (isShuffleActive) ...[
const SizedBox(width: 4),
AppIcon(Symbols.shuffle_rounded, fill: 1, size: 12, color: Colors.white.withValues(alpha: 0.7)),
],
],
);
},
),
const SizedBox(height: 4),
if (episode.parentIndex != null && episode.index != null)
Text(
'S${episode.parentIndex} E${episode.index} · ${episode.title}',
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600),
maxLines: 2,
overflow: .ellipsis,
)
else
Text(
episode.title!,
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600),
maxLines: 2,
overflow: .ellipsis,
),
],
),
),
],
);
}
}
class VideoPlayerStillWatchingOverlay extends StatelessWidget {
final bool visible;
final int countdown;
final FocusNode pauseFocusNode;
final FocusNode continueFocusNode;
final PlayerChromeController chromeController;
final VoidCallback onPause;
final VoidCallback onContinue;
const VideoPlayerStillWatchingOverlay({
super.key,
required this.visible,
required this.countdown,
required this.pauseFocusNode,
required this.continueFocusNode,
required this.chromeController,
required this.onPause,
required this.onContinue,
});
@override
Widget build(BuildContext context) {
return _VideoPlayerPromptShell(
visible: visible,
chromeController: chromeController,
focusNodes: [pauseFocusNode, continueFocusNode],
children: [
Text(
t.videoControls.stillWatching,
style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 12, fontWeight: .w500),
),
const SizedBox(height: 4),
Text(
t.videoControls.pausingIn(seconds: '$countdown'),
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600),
),
const SizedBox(height: 12),
_VideoPlayerPromptActions(
cancelLabel: t.videoControls.pauseButton,
cancelFocusNode: pauseFocusNode,
onCancel: onPause,
confirmFocusNode: continueFocusNode,
onConfirm: onContinue,
confirmChildren: [Text('$countdown'), const SizedBox(width: 4), Text(t.videoControls.continueWatching)],
),
],
);
}
}
class _VideoPlayerPromptShell extends StatelessWidget {
final bool visible;
final PlayerChromeController chromeController;
final List<FocusNode> focusNodes;
final List<Widget> children;
const _VideoPlayerPromptShell({
required this.visible,
required this.chromeController,
required this.focusNodes,
required this.children,
});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: PipService().isPipActive,
builder: (context, isInPip, child) {
if (isInPip || !visible) {
return const SizedBox.shrink();
}
return _VideoPlayerPromptPosition(
chromeController: chromeController,
child: _VideoPlayerPromptInteractionHold(
chromeController: chromeController,
focusNodes: focusNodes,
child: _VideoPlayerPromptCard(
child: Column(mainAxisSize: .min, crossAxisAlignment: .start, children: children),
),
),
);
},
);
}
}
class _VideoPlayerPromptActions extends StatelessWidget {
final String cancelLabel;
final FocusNode cancelFocusNode;
final VoidCallback onCancel;
final FocusNode confirmFocusNode;
final VoidCallback onConfirm;
final List<Widget> confirmChildren;
const _VideoPlayerPromptActions({
required this.cancelLabel,
required this.cancelFocusNode,
required this.onCancel,
required this.confirmFocusNode,
required this.onConfirm,
required this.confirmChildren,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: FocusableButton(
focusNode: cancelFocusNode,
onPressed: onCancel,
autoScroll: false,
onNavigateRight: () => confirmFocusNode.requestFocus(),
onNavigateUp: () {},
onNavigateDown: () {},
child: OutlinedButton(
onPressed: onCancel,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.white,
side: BorderSide(color: Colors.white.withValues(alpha: 0.5)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: Text(cancelLabel),
),
),
),
const SizedBox(width: 8),
Expanded(
child: FocusableButton(
focusNode: confirmFocusNode,
onPressed: onConfirm,
autoScroll: false,
onNavigateLeft: () => cancelFocusNode.requestFocus(),
onNavigateUp: () {},
onNavigateDown: () {},
useBackgroundFocus: true,
child: FilledButton(
onPressed: onConfirm,
style: FilledButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: Row(mainAxisAlignment: .center, children: confirmChildren),
),
),
),
],
);
}
}
class _VideoPlayerPromptPosition extends StatelessWidget {
final PlayerChromeController chromeController;
final Widget child;
const _VideoPlayerPromptPosition({required this.chromeController, required this.child});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: chromeController,
builder: (context, controlsShown, child) {
return AnimatedPositioned(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
right: 24,
bottom: controlsShown ? 100 : 24,
child: child!,
);
},
child: child,
);
}
}
class _VideoPlayerPromptCard extends StatelessWidget {
final Widget child;
const _VideoPlayerPromptCard({required this.child});
@override
Widget build(BuildContext context) {
return Container(
width: 320,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.9),
borderRadius: const BorderRadius.all(Radius.circular(12)),
),
child: child,
);
}
}
class _VideoPlayerPromptInteractionHold extends StatefulWidget {
final PlayerChromeController chromeController;
final List<FocusNode> focusNodes;
final Widget child;
const _VideoPlayerPromptInteractionHold({
required this.chromeController,
required this.focusNodes,
required this.child,
});
@override
State<_VideoPlayerPromptInteractionHold> createState() => _VideoPlayerPromptInteractionHoldState();
}
class _VideoPlayerPromptInteractionHoldState extends State<_VideoPlayerPromptInteractionHold> {
bool _hovered = false;
bool _focused = false;
@override
void initState() {
super.initState();
_addFocusListeners(widget.focusNodes);
_syncFocusState();
}
@override
void didUpdateWidget(_VideoPlayerPromptInteractionHold oldWidget) {
super.didUpdateWidget(oldWidget);
if (!listEquals(oldWidget.focusNodes, widget.focusNodes)) {
_removeFocusListeners(oldWidget.focusNodes);
_addFocusListeners(widget.focusNodes);
_syncFocusState();
}
if (oldWidget.chromeController != widget.chromeController) {
oldWidget.chromeController.release(PlayerChromeHold.promptInteraction);
_syncHold();
}
}
@override
void dispose() {
_removeFocusListeners(widget.focusNodes);
widget.chromeController.release(PlayerChromeHold.promptInteraction, notify: false, restartAutoHide: false);
super.dispose();
}
void _addFocusListeners(List<FocusNode> nodes) {
for (final node in nodes) {
node.addListener(_syncFocusState);
}
}
void _removeFocusListeners(List<FocusNode> nodes) {
for (final node in nodes) {
node.removeListener(_syncFocusState);
}
}
void _syncFocusState() {
final focused = widget.focusNodes.any((node) => node.hasFocus);
if (_focused == focused) return;
_focused = focused;
_syncHold();
}
void _setHovered(bool hovered) {
if (_hovered == hovered) return;
_hovered = hovered;
if (hovered) widget.chromeController.recordPointerActivity();
_syncHold();
}
void _syncHold() {
if (_hovered || _focused) {
widget.chromeController.hold(PlayerChromeHold.promptInteraction);
} else {
widget.chromeController.release(PlayerChromeHold.promptInteraction);
}
}
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) => _setHovered(true),
onHover: (_) => widget.chromeController.recordPointerActivity(),
onExit: (_) => _setHovered(false),
child: widget.child,
);
}
}