Emby is Jellyfin's upstream ancestor and speaks a near-identical MediaBrowser
API, so the existing Jellyfin stack is parameterised by a `MediaBrowserDialect`
rather than forked. `JellyfinClient`, its auth service, endpoint discovery, LAN
discovery, and the add/edit connection screens all take the dialect and keep one
implementation; `MediaBackend.emby` and `ConnectionKind.emby` carry it through
the neutral models, the Drift `kind` discriminator, downloads, and caches.
Every divergence below was measured against a live Emby 4.9.5 server, not
inferred from documentation, and each is documented at its capability getter.
Jellyfin's request strings stay byte-identical so nothing about its behaviour
changes.
Routes and auth
- Emby only accepts the pre-10.9 user-scoped item routes (`/Users/{id}/Items/…`,
`/Users/{id}/PlayedItems/…`, `/Users/{id}/FavoriteItems/…`); the unprefixed
forms Jellyfin 10.11 added return 404.
- The API is also served under a legacy `/emby` prefix, and both dialects accept
the token as `X-Emby-Token` or `api_key=`.
- Emby answers only its own LAN discovery datagram ("who is EmbyServer?") and
ignores Jellyfin's; its default HTTPS port is 8920.
- No `/QuickConnect` route exists, so Quick Connect stays Jellyfin-only.
Row fields Emby withholds
- `ProductionYear`, `OfficialRating`, `PremiereDate` and `DateCreated` are absent
from list rows unless named in `Fields`, which would otherwise strip the year
and age-rating badge from every card in the app.
- `UserData.LastPlayedDate` never appears on a list row under `Fields=UserData`,
`EnableUserData=true` or the user-scoped `Ids=` form — only on the single-item
detail route, or when the Emby-specific `UserDataLastPlayedDate` token is
requested. Without it every recency-ordered surface silently degrades to
library-add time, and `JellyfinApiCache.applyWatchState` stamps
`DateTime.now()` on watched rows, so an offline watch-state pull would rewrite
the cached play time of everything it walked.
Continue Watching and Next Up
- Emby computes Next Up per series only: the library-wide `/Shows/NextUp` query
returns nothing under every parameter combination tried. The shelf is
therefore reconstructed from a played-episode recency scan plus one
`/Shows/NextUp?SeriesId=` per distinct series, bounded by a shared wall clock
that covers the scan as well — per-request timeouts cannot bound the pass
because `MediaServerHttpClient` times the connect and receive phases
independently. Rows are stamped with their series' newest play from the same
response that ordered them, so no per-series enrichment request is needed.
- `/Shows/NextUp` ignores `NextUpDateCutoff`, and no server-side played-date
filter exists to delegate to (`MinDatePlayed` and `MinDateLastPlayed` are
ignored; `MinDateLastSaved`, `MinDateCreated` and `MinPremiereDate` filter
unrelated dates), so the 365-day window is applied to the scanned dates.
- The resume route returns items with no saved position, including plain next
episodes, so the Emby resume leg reads from `/Items?Filters=IsResumable`.
- Emby is ahead of Jellyfin in one place: `/Users/{id}/Items/{id}/HideFromResume`
makes Continue Watching removal a real capability.
Everything else
- `/Sessions/Playing` and `/Sessions/Playing/Progress` reject a body with no
`PlaySessionId` (HTTP 400), so playback reporting always sends one.
- Passing any `MediaTypes` value to the playlist query returns an empty list.
- There is no aggregate `/Items/Filters` route; the four filter facets are
reassembled from `/Genres`, `/OfficialRatings`, `/Studios` and `/Tags`.
- Metadata writes take name-pair lists (`Genres: [{'Name': 'Action'}]`); the
plain string array is accepted and then silently discarded.
- Custom artwork uploads must be base64 text, not raw bytes — which was broken
for Jellyfin too and is fixed for both.
- Trickplay, media segments and lyrics 404 on Emby, so scrub previews are absent
and intro/credit markers fall back to chapter names.
Verified against a local Emby 4.9.5 and a Jellyfin 10.11.11 control server:
onboarding, browse, detail, playable stream URLs serving real bytes, subtitle
sidecars, watch-state write and restore, hubs, cross-server aggregation and
search across both backends simultaneously.
580 lines
21 KiB
Dart
580 lines
21 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 '../connection/plex_account_setup.dart';
|
|
import '../database/app_database.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 '../profiles/profile_connection_registry.dart';
|
|
import '../profiles/profile_selection_policy.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 '../media/media_browser_dialect.dart';
|
|
import '../navigation/profile_session_screen.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 'profile/profile_switch_screen.dart';
|
|
import 'settings/add_jellyfin_screen.dart';
|
|
|
|
class AuthScreen extends StatefulWidget {
|
|
const AuthScreen({
|
|
super.key,
|
|
this.initialErrorMessage,
|
|
this.initializeServices = true,
|
|
this.databaseRecoveryRequired = false,
|
|
});
|
|
|
|
final String? initialErrorMessage;
|
|
final bool initializeServices;
|
|
final bool databaseRecoveryRequired;
|
|
@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;
|
|
Future<void>? _recoveryAcknowledgement;
|
|
bool _recoveryAcknowledged = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_errorMessage = widget.initialErrorMessage;
|
|
// Debug-token verification only — release builds must not hold an idle
|
|
// auth service (and its HTTP client) for a dialog that can't open.
|
|
if (kDebugMode && widget.initializeServices) 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);
|
|
}
|
|
|
|
Future<bool> _prepareDatabaseRecoveryForSignIn() async {
|
|
if (!widget.databaseRecoveryRequired || _recoveryAcknowledged) return true;
|
|
final acknowledgement = _recoveryAcknowledgement ??= context
|
|
.read<AppDatabase>()
|
|
.acknowledgeTvosDatabaseRecoveryRequired();
|
|
try {
|
|
await acknowledgement;
|
|
_recoveryAcknowledged = true;
|
|
if (mounted) setState(() => _errorMessage = null);
|
|
return mounted;
|
|
} catch (_) {
|
|
_recoveryAcknowledgement = null;
|
|
if (mounted) setState(() => _errorMessage = t.auth.localDataRecoveryRequired);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> _startPlexAfterRecovery(VoidCallback start) async {
|
|
if (await _prepareDatabaseRecoveryForSignIn()) start();
|
|
}
|
|
|
|
/// 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 (!await _prepareDatabaseRecoveryForSignIn()) return;
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_isAuthenticating = true;
|
|
_errorMessage = null;
|
|
});
|
|
|
|
final connectionRegistry = context.read<ConnectionRegistry>();
|
|
final profileConnections = context.read<ProfileConnectionRegistry>();
|
|
final plexHome = context.read<PlexHomeService>();
|
|
|
|
try {
|
|
final storage = await StorageService.getInstance();
|
|
final registration = await registerPlexAccountFromToken(
|
|
token: plexToken,
|
|
connections: connectionRegistry,
|
|
profileConnections: profileConnections,
|
|
storage: storage,
|
|
plexHome: plexHome,
|
|
);
|
|
final accountConnection = registration.connection;
|
|
|
|
if (accountConnection.servers.isEmpty) {
|
|
// Nothing to roll back — the registered account row is exactly what
|
|
// a retry will upsert over; wiping global credentials here would
|
|
// also nuke the device identity and every server-endpoint cache.
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isAuthenticating = false;
|
|
_errorMessage = t.serverSelection.noServersFoundForAccount(
|
|
username: registration.username,
|
|
email: registration.email,
|
|
);
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!registration.homeUsersFetched && plexHome.current[accountConnection.id] == null) {
|
|
// A failed home-user fetch is NOT "multiple users, let them pick":
|
|
// with no home users and no locals there is no profile to select,
|
|
// and navigating lands in a dead session. Surface a retry instead.
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_isAuthenticating = false;
|
|
_errorMessage = t.profiles.failedToLoadHomeUsers;
|
|
});
|
|
return;
|
|
}
|
|
|
|
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: activeProfiles.requiresSelectionOnOpen(settings),
|
|
);
|
|
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(ProfileSessionScreen(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);
|
|
});
|
|
}
|
|
}
|
|
|
|
void _handleDebugTap() {
|
|
if (!kDebugMode) return;
|
|
_showDebugTokenDialog();
|
|
}
|
|
|
|
Future<void> _connectToMediaBrowser(MediaBrowserDialect dialect) async {
|
|
if (!await _prepareDatabaseRecoveryForSignIn()) return;
|
|
if (!mounted) return;
|
|
final added = await Navigator.push<bool>(
|
|
context,
|
|
MaterialPageRoute(builder: (_) => AddJellyfinScreen(dialect: dialect)),
|
|
);
|
|
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 ProfileSessionScreen())));
|
|
}
|
|
|
|
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: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom),
|
|
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: Theme.of(
|
|
context,
|
|
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
return PlexPinAuthFlow(
|
|
onTokenReceived: _connectToAllServersAndNavigate,
|
|
autoStartQrOnTV: false,
|
|
initializeService: widget.initializeServices,
|
|
initialButtonsBuilder: _buildInitialButtons,
|
|
);
|
|
}
|
|
|
|
Widget _buildInitialButtons(BuildContext context, VoidCallback startBrowser, VoidCallback startQr, bool busy) {
|
|
final isTV = PlatformDetector.isTV();
|
|
final isAppleTV = PlatformDetector.isAppleTV();
|
|
void startBrowserAfterRecovery() => unawaited(_startPlexAfterRecovery(startBrowser));
|
|
void startQrAfterRecovery() => unawaited(_startPlexAfterRecovery(startQr));
|
|
const jellyfinDialect = MediaBrowserDialect.jellyfin;
|
|
const embyDialect = MediaBrowserDialect.emby;
|
|
void connectToJellyfin() => unawaited(_connectToMediaBrowser(jellyfinDialect));
|
|
void connectToEmby() => unawaited(_connectToMediaBrowser(embyDialect));
|
|
return Column(
|
|
mainAxisSize: .min,
|
|
crossAxisAlignment: .stretch,
|
|
children: [
|
|
if (isTV) ...[
|
|
FocusableButton(
|
|
autofocus: true,
|
|
onPressed: busy ? null : startQrAfterRecovery,
|
|
useBackgroundFocus: true,
|
|
child: ElevatedButton(
|
|
onPressed: busy ? null : startQrAfterRecovery,
|
|
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 : startBrowserAfterRecovery,
|
|
child: OutlinedButton(
|
|
onPressed: busy ? null : startBrowserAfterRecovery,
|
|
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
|
child: Text(t.auth.useBrowser),
|
|
),
|
|
),
|
|
],
|
|
] else ...[
|
|
FocusableButton(
|
|
onPressed: busy ? null : startBrowserAfterRecovery,
|
|
useBackgroundFocus: true,
|
|
child: ElevatedButton.icon(
|
|
onPressed: busy ? null : startBrowserAfterRecovery,
|
|
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 : startQrAfterRecovery,
|
|
child: OutlinedButton(
|
|
onPressed: busy ? null : startQrAfterRecovery,
|
|
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),
|
|
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.connectToMediaBrowser(product: jellyfinDialect.productName)),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
FocusableButton(
|
|
onPressed: connectToEmby,
|
|
child: OutlinedButton.icon(
|
|
onPressed: connectToEmby,
|
|
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
|
|
icon: const BackendBadge(backend: MediaBackend.emby, size: 18),
|
|
label: Text(t.auth.connectToMediaBrowser(product: embyDialect.productName)),
|
|
),
|
|
),
|
|
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 ? null : () => Navigator.of(context).pop(), label: t.common.cancel),
|
|
DialogActionButton(onPressed: _busy ? null : _submit, label: t.auth.authenticate, isPrimary: true),
|
|
],
|
|
);
|
|
}
|
|
}
|