Files
plezy/lib/screens/auth_screen.dart
T
edde746 3599b0b0e1 fix(startup): no-servers flash on fresh login + faster connect splash
- start binder before navigating from auth, mark isBinding synchronously
- overlap plex.tv resource refresh with optimistic cached-metadata connect,
  reconcile tokens/membership in background once it lands
- cached endpoint probe gets a head start on the race instead of serially
  blocking it
- defer phase-1 HTTPS upgrade off the splash critical path
- per-server connect progress stream for incremental splash checkmarks
- startup timing instrumentation (bind/fetch/race/connect elapsedMs)
2026-06-12 01:46:43 +02:00

523 lines
19 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../connection/connection.dart';
import '../connection/connection_registry.dart';
import '../mixins/controller_disposer_mixin.dart';
import '../profiles/active_profile_binder.dart';
import '../profiles/active_profile_provider.dart';
import '../profiles/plex_home_service.dart';
import '../profiles/profile.dart';
import '../services/plex_auth_service.dart';
import '../services/settings_service.dart';
import '../services/storage_service.dart';
import '../providers/user_profile_provider.dart';
import '../i18n/strings.g.dart';
import '../utils/app_logger.dart';
import '../utils/platform_detector.dart';
import '../focus/focusable_button.dart';
import '../focus/focusable_text_field.dart';
import '../focus/key_event_utils.dart';
import '../media/media_backend.dart';
import '../utils/navigation_transitions.dart';
import '../widgets/backend_badge.dart';
import '../widgets/dialog_action_button.dart';
import 'auth/plex_pin_auth_flow.dart';
import 'main_screen.dart';
import 'profile/profile_switch_screen.dart';
import 'settings/add_jellyfin_screen.dart';
class AuthScreen extends StatefulWidget {
const AuthScreen({super.key});
@override
State<AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<AuthScreen> {
bool _isAuthenticating = false;
String? _errorMessage;
// Reuse a one-shot service for the debug-token verify path; the Plex
// PIN/QR flow inside [PlexPinAuthFlow] owns its own service instance.
PlexAuthService? _verifyOnlyService;
@override
void initState() {
super.initState();
unawaited(_initVerifyService());
}
Future<void> _initVerifyService() async {
final svc = await PlexAuthService.create();
if (!mounted) {
svc.dispose();
return;
}
setState(() => _verifyOnlyService = svc);
}
@override
void dispose() {
_verifyOnlyService?.dispose();
super.dispose();
}
/// Auto-select the active profile after sign-in *only* when there's a
/// single Plex Home user — there's no choice for the user to make. With
/// multiple Home users (the "real" Home case) we leave the active id
/// unset so [MainScreen] forces the picker before the binder runs,
/// avoiding a surprise PIN prompt on whichever user we'd otherwise
/// pre-select.
Future<void> _selectInitialProfile(
PlexHomeService plexHome,
ActiveProfileProvider activeProfiles,
PlexAccountConnection accountConn,
) async {
await activeProfiles.initialize();
final profile = initialPlexHomeProfileFromCache(plexHome, accountConn);
if (profile == null) {
await activeProfiles.clearActiveProfile();
return;
}
await activeProfiles.activate(profile);
}
/// Persist the new Plex account into the connection pipeline, resolve the
/// initial active profile when possible, and navigate to the main screen.
/// The top-level [ActiveProfileBinder] picks up the active profile id and
/// connects servers via [MultiServerManager.refreshTokensForProfile].
Future<void> _connectToAllServersAndNavigate(String plexToken) async {
if (!mounted) return;
setState(() {
_isAuthenticating = true;
_errorMessage = null;
});
final connectionRegistry = context.read<ConnectionRegistry>();
final plexHome = context.read<PlexHomeService>();
final svc = await PlexAuthService.create();
try {
final userInfo = await svc.getUserInfo(plexToken);
final username = userInfo['username'] as String? ?? '';
final email = userInfo['email'] as String? ?? '';
final accountUuid = (userInfo['uuid'] as String?)?.trim() ?? '';
final servers = await svc.fetchServers(plexToken);
final storage = await StorageService.getInstance();
if (servers.isEmpty) {
await storage.clearCredentials();
if (!mounted) return;
setState(() {
_isAuthenticating = false;
_errorMessage = t.serverSelection.noServersFoundForAccount(username: username, email: email);
});
return;
}
final clientId = await storage.getOrCreateClientIdentifier();
final accountConnection = PlexAccountConnection(
// Key the row by the plex.tv account UUID so signing into a second
// Plex account on the same device produces a distinct row. The
// clientIdentifier is per-device and would collide. Falls back to
// clientId only if plex.tv didn't return a uuid (rare).
id: 'plex.${accountUuid.isNotEmpty ? accountUuid : clientId}',
accountToken: plexToken,
clientIdentifier: clientId,
accountLabel: username.isNotEmpty ? username : (email.isNotEmpty ? email : 'Plex'),
servers: servers,
createdAt: DateTime.now(),
lastAuthenticatedAt: DateTime.now(),
);
await connectionRegistry.upsert(accountConnection);
await plexHome.refresh(accountConnection);
if (!mounted) return;
final activeProfiles = context.read<ActiveProfileProvider>();
await _selectInitialProfile(plexHome, activeProfiles, accountConnection);
if (!mounted) return;
// Start the binder before the picker/MainScreen, mirroring the
// cold-start SetupScreen ordering. On a fresh install SetupScreen
// routes here without ever starting it, so without this the profile
// activated above is bound only by MainScreen's post-frame start() —
// Discover renders a "No servers available" flash in the gap, and the
// picker's awaitBindingSettle resolves before anything is bound.
context.read<ActiveProfileBinder>().start();
final settings = await SettingsService.getInstance();
if (!mounted) return;
final promptHandled = shouldPromptForInitialProfileSelection(
activeProfile: activeProfiles.active,
hasProfiles: activeProfiles.profiles.isNotEmpty,
accountHasHomeUsers: plexHome.current[accountConnection.id]?.isNotEmpty == true,
requireProfileSelectionOnOpen:
settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfiles.hasMultipleProfiles,
);
if (promptHandled) {
final selected = await Navigator.of(
context,
).push<bool>(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen(requireSelection: true)));
if (!mounted) return;
if (selected != true || activeProfiles.active == null) {
setState(() => _isAuthenticating = false);
return;
}
}
await context.read<UserProfileProvider>().initialize();
if (!mounted) return;
unawaited(Navigator.pushReplacement(context, fadeRoute(MainScreen(initialPromptHandled: promptHandled))));
} catch (e) {
appLogger.e('Failed to connect to servers', error: e);
if (!mounted) return;
setState(() {
_isAuthenticating = false;
_errorMessage = t.serverSelection.failedToLoadServers(error: e);
});
} finally {
svc.dispose();
}
}
void _handleDebugTap() {
if (!kDebugMode) return;
_showDebugTokenDialog();
}
Future<void> _connectToJellyfin() async {
final added = await Navigator.push<bool>(context, MaterialPageRoute(builder: (_) => const AddJellyfinScreen()));
if (!mounted || added != true) return;
// The connection persisted and the manager registered the client; move
// straight to the main screen. [MainScreen] reads the active client
// from the server provider, so no client argument is needed here.
unawaited(Navigator.pushReplacement(context, fadeRoute(const MainScreen())));
}
void _showDebugTokenDialog() {
showDialog<void>(
context: context,
builder: (BuildContext context) {
return _DebugTokenDialog(verifyService: _verifyOnlyService, onTokenAccepted: _connectToAllServersAndNavigate);
},
);
}
@override
Widget build(BuildContext context) {
// Use two-column layout on desktop, single column on mobile
final isDesktop = MediaQuery.sizeOf(context).width > 700;
return Focus(
canRequestFocus: false,
onKeyEvent: (_, event) => handleBackKeyNavigation(context, event),
child: Scaffold(
body: Center(
child: Container(
constraints: BoxConstraints(maxWidth: isDesktop ? 800 : 400),
padding: const EdgeInsets.all(24),
child: isDesktop
? Row(
crossAxisAlignment: .center,
children: [
Expanded(
child: Column(
mainAxisAlignment: .center,
crossAxisAlignment: .center,
children: [
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: .bold),
textAlign: TextAlign.center,
),
],
),
),
const SizedBox(width: 48),
Expanded(
child: Center(
child: SingleChildScrollView(
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [_buildAuthBody()],
),
),
),
),
],
)
: SingleChildScrollView(
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
Image.asset('assets/plezy.png', width: 120, height: 120),
const SizedBox(height: 24),
Text(
t.app.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(fontWeight: .bold),
textAlign: TextAlign.center,
),
const SizedBox(height: 48),
_buildAuthBody(),
],
),
),
),
),
),
);
}
Widget _buildAuthBody() {
if (_isAuthenticating) {
return Column(
mainAxisSize: .min,
children: [
const Center(child: CircularProgressIndicator()),
const SizedBox(height: 16),
Text(
t.auth.waitingForAuth,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.grey),
),
],
);
}
return PlexPinAuthFlow(
onTokenReceived: _connectToAllServersAndNavigate,
autoStartQrOnTV: false,
initialButtonsBuilder: _buildInitialButtons,
);
}
Widget _buildInitialButtons(BuildContext context, VoidCallback startBrowser, VoidCallback startQr, bool busy) {
final isTV = PlatformDetector.isTV();
final isAppleTV = PlatformDetector.isAppleTV();
return Column(
mainAxisSize: .min,
crossAxisAlignment: .stretch,
children: [
if (isTV) ...[
FocusableButton(
autofocus: true,
onPressed: busy ? null : startQr,
child: ElevatedButton(
onPressed: busy ? null : startQr,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: Row(
mainAxisAlignment: .center,
mainAxisSize: .min,
children: [
const BackendBadge(backend: MediaBackend.plex, size: 18),
const SizedBox(width: 8),
Text(t.auth.signInWithPlex),
],
),
),
),
if (!isAppleTV) ...[
const SizedBox(height: 12),
FocusableButton(
onPressed: busy ? null : startBrowser,
child: OutlinedButton(
onPressed: busy ? null : startBrowser,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: Text(t.auth.useBrowser),
),
),
],
] else ...[
FocusableButton(
onPressed: busy ? null : startBrowser,
child: ElevatedButton.icon(
onPressed: busy ? null : startBrowser,
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
icon: const BackendBadge(backend: MediaBackend.plex, size: 18),
label: Text(t.auth.signInWithPlex),
),
),
const SizedBox(height: 12),
FocusableButton(
onPressed: busy ? null : startQr,
child: OutlinedButton(
onPressed: busy ? null : startQr,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: Text(t.auth.showQRCode),
),
),
],
const SizedBox(height: 24),
Row(
children: [
Expanded(child: Divider(color: Theme.of(context).colorScheme.outlineVariant)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
t.auth.or,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)),
),
),
Expanded(child: Divider(color: Theme.of(context).colorScheme.outlineVariant)),
],
),
const SizedBox(height: 12),
if (isTV)
FocusableButton(
onPressed: _connectToJellyfin,
child: OutlinedButton.icon(
onPressed: _connectToJellyfin,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
icon: const BackendBadge(backend: MediaBackend.jellyfin, size: 18),
label: Text(t.auth.connectToJellyfin),
),
)
else
FocusableButton(
onPressed: _connectToJellyfin,
child: OutlinedButton.icon(
onPressed: _connectToJellyfin,
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
icon: const BackendBadge(backend: MediaBackend.jellyfin, size: 18),
label: Text(t.auth.connectToJellyfin),
),
),
if (kDebugMode) ...[
const SizedBox(height: 12),
FocusableButton(
onPressed: _handleDebugTap,
child: OutlinedButton(
onPressed: _handleDebugTap,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
side: BorderSide(color: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5)),
),
child: const Text('Debug: Enter Plex Token', style: TextStyle(fontSize: 12)),
),
),
],
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Text(
_errorMessage!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
textAlign: TextAlign.center,
),
],
],
);
}
}
@visibleForTesting
Profile? initialPlexHomeProfileFromCache(PlexHomeService plexHome, PlexAccountConnection accountConn) {
final users = plexHome.current[accountConn.id];
if (users == null || users.length != 1) return null;
return Profile.virtualPlexHome(connectionId: accountConn.id, homeUser: users.single);
}
@visibleForTesting
bool shouldPromptForInitialProfileSelection({
required Profile? activeProfile,
required bool hasProfiles,
required bool accountHasHomeUsers,
required bool requireProfileSelectionOnOpen,
}) {
return requireProfileSelectionOnOpen || (activeProfile == null && (hasProfiles || accountHasHomeUsers));
}
/// Stateful so the [TextEditingController] is disposed when the dialog
/// closes — the previous inline `showDialog` builder created the
/// controller in a closure and leaked it on every dismissal.
class _DebugTokenDialog extends StatefulWidget {
final PlexAuthService? verifyService;
final Future<void> Function(String token) onTokenAccepted;
const _DebugTokenDialog({required this.verifyService, required this.onTokenAccepted});
@override
State<_DebugTokenDialog> createState() => _DebugTokenDialogState();
}
class _DebugTokenDialogState extends State<_DebugTokenDialog> with ControllerDisposerMixin {
late final TextEditingController _tokenController = createTextEditingController();
String? _errorMessage;
bool _busy = false;
Future<void> _submit() async {
final token = _tokenController.text.trim();
if (token.isEmpty) {
setState(() => _errorMessage = t.errors.pleaseEnterToken);
return;
}
final svc = widget.verifyService;
if (svc == null) {
setState(() => _errorMessage = 'Auth service not ready');
return;
}
final navigator = Navigator.of(context);
setState(() {
_errorMessage = null;
_busy = true;
});
try {
final isValid = await svc.verifyToken(token);
if (!mounted) return;
if (!isValid) {
setState(() {
_errorMessage = t.errors.invalidToken;
_busy = false;
});
return;
}
navigator.pop();
await widget.onTokenAccepted(token);
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = t.errors.failedToVerifyToken(error: e);
_busy = false;
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Debug: Enter Plex Token'),
content: Column(
mainAxisSize: .min,
children: [
FocusableTextFormField(
controller: _tokenController,
decoration: InputDecoration(
labelText: 'Plex Auth Token',
hintText: 'Enter your Plex.tv token',
errorText: _errorMessage,
border: const OutlineInputBorder(),
),
obscureText: true,
maxLines: 1,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) => _busy ? null : _submit(),
),
],
),
actions: [
DialogActionButton(onPressed: _busy ? () {} : () => Navigator.of(context).pop(), label: t.common.cancel),
DialogActionButton(onPressed: _busy ? () {} : _submit, label: t.auth.authenticate, isPrimary: true),
],
);
}
}