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
355 lines
12 KiB
Dart
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,
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|