217 lines
9.1 KiB
Dart
217 lines
9.1 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:plezy/widgets/app_icon.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../connection/connection.dart';
|
|
import '../../connection/connection_registry.dart';
|
|
import '../../focus/focusable_button.dart';
|
|
import '../../i18n/strings.g.dart';
|
|
import '../../profiles/active_profile_binder.dart';
|
|
import '../../profiles/active_profile_provider.dart';
|
|
import '../../profiles/plex_home_service.dart';
|
|
import '../../profiles/profile.dart';
|
|
import '../../profiles/profile_connection_registry.dart';
|
|
import '../../services/plex_auth_service.dart';
|
|
import '../../utils/app_logger.dart';
|
|
import '../../media/media_backend.dart';
|
|
import '../../widgets/backend_badge.dart';
|
|
import '../../widgets/focused_scroll_scaffold.dart';
|
|
import '../auth/plex_pin_auth_flow.dart';
|
|
import '../profile/borrow_connection_screen.dart';
|
|
import 'async_form_state_mixin.dart';
|
|
import 'connection_persistence.dart';
|
|
|
|
/// Add a Plex account to the [ConnectionRegistry].
|
|
///
|
|
/// Hands off the PIN/QR/polling UI to [PlexPinAuthFlow]; this screen owns
|
|
/// the post-token-received flow: build the [PlexAccountConnection], guard
|
|
/// against duplicates, register with the live [MultiServerManager], and
|
|
/// either pop with success or route into [BorrowConnectionScreen] for the
|
|
/// passed-in profile.
|
|
///
|
|
/// When [targetProfile] is provided, after a successful sign-in the user
|
|
/// is routed into [BorrowConnectionScreen] for that target so they can
|
|
/// pick which Home user from the new account to attach to the profile.
|
|
class AddPlexAccountScreen extends StatefulWidget {
|
|
/// When set, after sign-in route into the borrow flow for this profile.
|
|
/// The new account's Home users surface globally either way; the borrow
|
|
/// step is what creates the [ProfileConnection] row that grants this
|
|
/// profile access to one of them.
|
|
final Profile? targetProfile;
|
|
|
|
const AddPlexAccountScreen({super.key, this.targetProfile});
|
|
|
|
@override
|
|
State<AddPlexAccountScreen> createState() => _AddPlexAccountScreenState();
|
|
}
|
|
|
|
class _AddPlexAccountScreenState extends State<AddPlexAccountScreen> with AsyncFormStateMixin {
|
|
Future<void> _onTokenReceived(String token) async {
|
|
final completed = await runAsync<bool>(
|
|
() async {
|
|
// Pull the account label first so the row is human-readable. Falls
|
|
// back to "Plex" when the user info call fails (rare; e.g. token
|
|
// works but plex.tv is rate-limiting).
|
|
String accountLabel = 'Plex';
|
|
// Account UUID from plex.tv — this is what makes multi-account work.
|
|
// The clientIdentifier is per-device (same for every Plex account on
|
|
// this install), so keying connection.id off it would collapse two
|
|
// different Plex accounts into the same row. Falls back to the
|
|
// client identifier only if the user-info call fails outright;
|
|
// re-signing into the same account will then upsert the legacy row.
|
|
String accountUuid = '';
|
|
final auth = await PlexAuthService.create();
|
|
try {
|
|
try {
|
|
final info = await auth.getUserInfo(token);
|
|
accountLabel = (info['username'] as String?) ?? (info['email'] as String?) ?? 'Plex';
|
|
final uuid = (info['uuid'] as String?)?.trim();
|
|
if (uuid != null && uuid.isNotEmpty) accountUuid = uuid;
|
|
} catch (e) {
|
|
appLogger.d('getUserInfo after add-account failed (using fallback): $e');
|
|
}
|
|
|
|
final servers = await auth.fetchServers(token);
|
|
if (!mounted) return false;
|
|
|
|
final connection = PlexAccountConnection(
|
|
id: 'plex.${accountUuid.isNotEmpty ? accountUuid : auth.clientIdentifier}',
|
|
accountToken: token,
|
|
clientIdentifier: auth.clientIdentifier,
|
|
accountLabel: accountLabel,
|
|
servers: servers,
|
|
createdAt: DateTime.now(),
|
|
lastAuthenticatedAt: DateTime.now(),
|
|
);
|
|
|
|
if (!mounted) return false;
|
|
// Persist the registry row. Binding is deliberately left to
|
|
// ActiveProfileBinder below (global reauth) or the borrow flow
|
|
// (profile-scoped add) so we never put the raw account token into
|
|
// the active runtime session.
|
|
final target = widget.targetProfile;
|
|
await persistAndBindConnection(
|
|
context: context,
|
|
connection: connection,
|
|
bindToProfile: null,
|
|
addToManager: null,
|
|
);
|
|
|
|
if (!mounted) return false;
|
|
// Live-fetch the new account's Home users into [PlexHomeService]'s
|
|
// cache so the picker immediately surfaces them as virtual profiles.
|
|
// Must be awaited before pushing the borrow screen — that screen
|
|
// reads `activeProvider.profiles` once in initState (no reactive
|
|
// subscription), so navigating before the home users land yields
|
|
// an empty candidate list. Errors are swallowed inside
|
|
// `_fetchAndCache`; await is safe.
|
|
await context.read<PlexHomeService>().refresh(connection);
|
|
|
|
if (!mounted) return false;
|
|
if (target != null) {
|
|
final borrowed = await Navigator.of(context).push<bool>(
|
|
MaterialPageRoute(builder: (_) => BorrowConnectionScreen(targetProfile: target, popOnSuccess: true)),
|
|
);
|
|
if (!mounted) return borrowed == true;
|
|
if (borrowed == true) {
|
|
Navigator.of(context).pop(true);
|
|
return true;
|
|
}
|
|
throw StateError(t.addServer.failedToRegisterAccount(error: 'Connection was not borrowed'));
|
|
}
|
|
await _rebindActiveIfUses(connection.id);
|
|
if (!mounted) return false;
|
|
Navigator.of(context).pop(true);
|
|
return true;
|
|
} finally {
|
|
auth.dispose();
|
|
}
|
|
},
|
|
errorMapper: (e) {
|
|
appLogger.e('Failed to register Plex account', error: e);
|
|
return t.addServer.failedToRegisterAccount(error: e.toString());
|
|
},
|
|
);
|
|
if (mounted && completed != true) {
|
|
throw StateError(errorText ?? t.addServer.failedToRegisterAccount(error: 'Unknown error'));
|
|
}
|
|
}
|
|
|
|
Future<void> _rebindActiveIfUses(String connectionId) async {
|
|
final activeProvider = context.read<ActiveProfileProvider>();
|
|
await activeProvider.initialize();
|
|
if (!mounted) return;
|
|
|
|
final active = activeProvider.active;
|
|
if (active == null) return;
|
|
var usesConnection = active.parentConnectionId == connectionId;
|
|
if (!usesConnection) {
|
|
final pcs = await context.read<ProfileConnectionRegistry>().listForProfile(active.id);
|
|
usesConnection = pcs.any((pc) => pc.connectionId == connectionId);
|
|
}
|
|
if (!mounted || !usesConnection) return;
|
|
await context.read<ActiveProfileBinder>().rebindActive();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return FocusedScrollScaffold(
|
|
title: Text(t.addServer.addPlexTitle),
|
|
slivers: [
|
|
SliverPadding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
|
sliver: SliverToBoxAdapter(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(t.addServer.plexAuthIntro, style: theme.textTheme.bodyMedium),
|
|
const SizedBox(height: 24),
|
|
PlexPinAuthFlow(
|
|
onTokenReceived: _onTokenReceived,
|
|
initialButtonsBuilder: (context, browser, qr, busy) => Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
FocusableButton(
|
|
useBackgroundFocus: true,
|
|
onPressed: busy || this.busy ? null : browser,
|
|
child: FilledButton.icon(
|
|
onPressed: busy || this.busy ? null : browser,
|
|
icon: const BackendBadge(backend: MediaBackend.plex, size: 18),
|
|
label: Text(t.auth.signInWithPlex),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
FocusableButton(
|
|
useBackgroundFocus: true,
|
|
onPressed: busy || this.busy ? null : qr,
|
|
child: OutlinedButton.icon(
|
|
onPressed: busy || this.busy ? null : qr,
|
|
icon: const AppIcon(Symbols.qr_code_rounded, fill: 1),
|
|
label: Text(t.auth.showQRCode),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (errorText != null) ...[
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
errorText!,
|
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|