Files
plezy/lib/screens/auth/plex_pin_auth_flow.dart
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

355 lines
12 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../exceptions/media_server_exceptions.dart';
import '../../i18n/strings.g.dart';
import '../../services/plex_auth_service.dart';
import '../../focus/focusable_button.dart';
import '../../theme/mono_tokens.dart';
import '../../utils/app_logger.dart';
import '../../utils/platform_detector.dart';
/// Whether the Plex PIN hand-off must stay in-app as a QR code instead of
/// opening the auth URL.
///
/// Android Automotive OS head units ship no browser: the system hands an
/// app-launched `https` URL to the car link viewer, which only renders it as
/// a QR code of its own while this flow sits on a "sign in from your browser"
/// spinner until the PIN expires two minutes later. Owning the QR keeps the
/// scan target, the retry action and the error copy inside the app.
bool plexSignInRequiresInAppQr({required bool isAutomotive}) => isAutomotive;
/// Self-contained Plex PIN/QR auth flow.
///
/// Renders the polling UI (QR code or browser-waiting spinner) once an
/// auth attempt is started via [PlexPinAuthFlowController.startBrowser] /
/// [PlexPinAuthFlowController.startQr]. When polling resolves successfully
/// it invokes [onTokenReceived(token)]; the parent decides what to do next
/// (the legacy [AuthScreen] connects to all servers + navigates to
/// MainScreen, while [AddPlexAccountScreen] pops with success or routes
/// into the borrow flow).
///
/// Both screens previously implemented this flow inline — same `PlexAuthService`
/// orchestration, same QR widget, same browser-waiting state. Extracting
/// here removes ~300 lines of duplicate UI code and centralises the
/// poll-cancel-retry plumbing.
class PlexPinAuthFlow extends StatefulWidget {
/// Fires when the user successfully claims the PIN. The token is the raw
/// `X-Plex-Token` value — parent code is responsible for exchanging it
/// for a [PlexAccountConnection] (account label + servers list).
final Future<void> Function(String token) onTokenReceived;
/// QR size on mobile / narrow layouts.
final double mobileQrSize;
/// QR size on desktop / wide layouts (where the auth screen has more
/// horizontal room). The two-column login screen uses 300; the bottom-sheet
/// add-account screen uses 200.
final double desktopQrSize;
/// When `true` and running on TV, auto-start the QR flow on first build so
/// the user doesn't have to navigate to the QR button with the remote.
final bool autoStartQrOnTV;
/// Test seam for rendering the initial actions without platform services.
final bool initializeService;
/// Override the QR-vs-browser default before any user interaction. Useful
/// for callers that want to force one mode (the add-account screen
/// auto-starts QR on TV; the legacy login screen offers both).
final bool? initialUseQr;
/// Optional builder for the initial action buttons. The default (`null`)
/// shows two buttons — "Sign in with Plex" (browser) and "Show QR Code".
/// Pass a custom builder when the parent wants to integrate the buttons
/// into a richer layout (extra Jellyfin button, debug button, branding).
final Widget Function(BuildContext context, VoidCallback startBrowser, VoidCallback startQr, bool busy)?
initialButtonsBuilder;
/// Test seam: builds the auth service this flow drives. Defaults to the
/// production [PlexAuthService.create].
final Future<PlexAuthService> Function()? serviceFactory;
const PlexPinAuthFlow({
super.key,
required this.onTokenReceived,
this.mobileQrSize = 200,
this.desktopQrSize = 300,
this.autoStartQrOnTV = true,
this.initializeService = true,
this.initialUseQr,
this.initialButtonsBuilder,
this.serviceFactory,
});
@override
State<PlexPinAuthFlow> createState() => _PlexPinAuthFlowState();
}
class _PlexPinAuthFlowState extends State<PlexPinAuthFlow> {
PlexAuthService? _authService;
bool _isPolling = false;
bool _useQr = false;
String? _qrAuthUrl;
int _attemptId = 0;
String? _errorMessage;
@override
void initState() {
super.initState();
_useQr =
widget.initialUseQr ??
(PlatformDetector.isTV() || plexSignInRequiresInAppQr(isAutomotive: PlatformDetector.isAutomotive()));
if (widget.initializeService) unawaited(_initService());
}
Future<void> _initService() async {
final svc = await (widget.serviceFactory?.call() ?? PlexAuthService.create());
if (!mounted) {
svc.dispose();
return;
}
setState(() {
_authService = svc;
});
if (widget.autoStartQrOnTV && PlatformDetector.isTV()) {
unawaited(_start(useQr: true));
}
}
@override
void dispose() {
_attemptId++;
_authService?.dispose();
super.dispose();
}
Future<void> _start({required bool useQr}) async {
final svc = _authService;
if (svc == null) return;
// A car has no browser to hand the PIN URL to, so the browser action
// resolves to the in-app QR there instead of a spinner that can only time
// out. Every other platform honours what the user pressed.
final resolvedUseQr = useQr || plexSignInRequiresInAppQr(isAutomotive: PlatformDetector.isAutomotive());
final attemptId = ++_attemptId;
setState(() {
_useQr = resolvedUseQr;
_isPolling = true;
_errorMessage = null;
_qrAuthUrl = null;
});
try {
final pinData = await svc.createPin();
if (!_isCurrentAttempt(attemptId)) return;
final pinId = pinData['id'] as int;
final pinCode = pinData['code'] as String;
final url = svc.getAuthUrl(pinCode);
if (!_isCurrentAttempt(attemptId)) return;
if (resolvedUseQr) {
setState(() => _qrAuthUrl = url);
} else {
final uri = Uri.parse(url);
try {
final mode = PlatformDetector.isTV() ? LaunchMode.inAppWebView : LaunchMode.inAppBrowserView;
await launchUrl(uri, mode: mode);
} catch (_) {
// Chrome Custom Tabs may not be available — fall back to default
// external browser.
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
final token = await svc.pollPinUntilClaimed(pinId, shouldCancel: () => attemptId != _attemptId);
if (!_isCurrentAttempt(attemptId)) return;
if (token == null) {
setState(() {
_isPolling = false;
_qrAuthUrl = null;
_errorMessage = t.auth.authenticationTimeout;
});
return;
}
// Auto-close the in-app browser on mobile (no-op on desktop / when
// already closed).
if (!resolvedUseQr) {
try {
await closeInAppWebView();
} catch (_) {}
}
if (!_isCurrentAttempt(attemptId)) return;
setState(() {
_qrAuthUrl = null;
});
await widget.onTokenReceived(token);
if (!_isCurrentAttempt(attemptId)) return;
setState(() {
_isPolling = false;
});
} catch (e) {
appLogger.w('Plex PIN auth failed', error: e);
if (!_isCurrentAttempt(attemptId)) return;
setState(() {
_isPolling = false;
_qrAuthUrl = null;
_errorMessage = _authErrorMessage(e);
});
}
}
String _authErrorMessage(Object error) {
if (error is MediaServerPinExpiredException) return t.addServer.pinExpired;
if (error is MediaServerAuthException) return error.display ?? error.message;
if (error is MediaServerHttpException) {
return error.display ??
t.addServer.couldNotReachServer(error: error.message.isEmpty ? error.toString() : error.message);
}
return error.toString();
}
bool _isCurrentAttempt(int attemptId) => mounted && attemptId == _attemptId;
void _retry() {
final useQr = _useQr;
_attemptId++;
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) unawaited(_start(useQr: useQr));
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (_isPolling) {
final isDesktop = MediaQuery.sizeOf(context).width > 700;
if (_useQr && _qrAuthUrl != null) {
return _buildQr(theme, isDesktop ? widget.desktopQrSize : widget.mobileQrSize);
}
return _buildBrowserWaiting(theme);
}
final builder = widget.initialButtonsBuilder ?? _defaultInitialButtons;
return Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
builder(context, () => _start(useQr: false), () => _start(useQr: true), _authService == null),
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Text(
_errorMessage!,
style: TextStyle(color: theme.colorScheme.error),
textAlign: TextAlign.center,
),
],
],
);
}
Widget _defaultInitialButtons(BuildContext context, VoidCallback browser, VoidCallback qr, bool busy) {
return Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
FocusableButton(
onPressed: busy ? null : browser,
child: FilledButton(onPressed: busy ? null : browser, child: Text(t.auth.signInWithPlex)),
),
const SizedBox(height: 12),
FocusableButton(
onPressed: busy ? null : qr,
child: OutlinedButton(onPressed: busy ? null : qr, child: Text(t.auth.showQRCode)),
),
],
);
}
Widget _buildQr(ThemeData theme, double qrSize) {
return Column(
mainAxisSize: .min,
children: [
Text(
t.auth.scanQRToSignIn,
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.7)),
),
const SizedBox(height: 24),
Center(
// Tight SizedBox so ancestors that measure intrinsics (e.g.
// SliverFillRemaining with hasScrollBody: false) never recurse into
// QrImageView's internal LayoutBuilder, which doesn't support them.
child: SizedBox.square(
dimension: qrSize,
child: ClipRRect(
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
child: QrImageView(
data: _qrAuthUrl!,
size: qrSize,
version: QrVersions.auto,
backgroundColor: Colors.white,
),
),
),
),
const SizedBox(height: 24),
FocusableButton(
onPressed: _retry,
child: OutlinedButton(
onPressed: _retry,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)),
child: Text(t.common.retry),
),
),
if (_errorMessage != null) ...[
const SizedBox(height: 12),
Text(
_errorMessage!,
style: TextStyle(color: theme.colorScheme.error),
textAlign: TextAlign.center,
),
],
],
);
}
Widget _buildBrowserWaiting(ThemeData theme) {
return Column(
mainAxisSize: .min,
children: [
const Center(child: CircularProgressIndicator()),
const SizedBox(height: 16),
Text(
t.auth.waitingForAuth,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.7)),
),
const SizedBox(height: 16),
FocusableButton(
onPressed: _retry,
child: OutlinedButton(
onPressed: _retry,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 24)),
child: Text(t.common.retry),
),
),
if (_errorMessage != null) ...[
const SizedBox(height: 12),
Text(
_errorMessage!,
style: TextStyle(color: theme.colorScheme.error),
textAlign: TextAlign.center,
),
],
],
);
}
}