Files
plezy/lib/screens/auth/plex_pin_auth_flow.dart
T
edde746 f63a4b039a fix(auth): show the Plex sign-in QR in the app on a car
Signing in opened plex.tv in a browser, and a head unit has none: the user was
left staring at a launcher error with no way to link the account. The QR code and
the linking code are now rendered in the app on a car, so the pairing happens on
a phone while the vehicle shows what to scan.
2026-08-06 03:45:09 +02:00

354 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.message;
if (error is MediaServerHttpException) {
return 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,
),
],
],
);
}
}