Files
plezy/lib/screens/settings/add_jellyfin_screen.dart
T
edde746 a9f0532f5f fix(ui): keep pushed screens clear of the Android navigation bar
Plezy is edge-to-edge on Android whether it asks to be or not: targetSdk is
36, Android 15 enforces edge-to-edge for apps targeting 35+, and Android 16
disables the windowOptOutEdgeToEdgeEnforcement escape hatch. The only
SystemUiMode.edgeToEdge call in the app fires on video-player exit, so on
API 35+ the window is edge-to-edge from the first frame and
MediaQuery.padding.bottom is a real ~48dp overlap under 3-button navigation.

MainScreen's phone layout hides that. It supplies a bottomNavigationBar and
never sets extendBody, so Flutter's Scaffold strips padding.bottom from the
body MediaQuery and every tab is already safe. Routes pushed on the profile
navigator are full-screen siblings of MainScreen with no bottom bar, so they
receive the untouched inset and nothing consumes it - the last settings card
and the final log lines render under the back, home, and recents buttons.

Three shared hosts own most of those routes, so the inset is consumed there:
FocusedScrollScaffold (25 screens, counting the SettingsPage wrapper) and
FocusableDetailScreenMixin.buildDetailScaffold (4) now append a trailing
SliverSystemBottomInset, and the four screens that build their own Scaffold
around a CustomScrollView append it directly.

The new widget codifies the convention this repository had already written
down but open-coded - insets baked into the scroll content rather than a
SafeArea around the scroll view - so content still paints under the bar while
the scroll extent grows enough to bring the last row above it. It reads
padding from its own context and collapses to zero height wherever the inset
is already zero: desktop, Android TV, tvOS via _AppleTvScale, and inside
MainScreen's tab bodies. No platform branching, and it stacks additively with
the music detail screens' existing mini-player spacers, which is correct
because the mini-player itself floats above the navigation bar on a pushed
route.

Scroll views that are not sliver lists take the inset in their own padding:
the companion remote's ListView, the auth screen's scroll container, and the
two SliverFillRemaining sign-in forms, whose children size themselves from
the extent remaining before them and so cannot be helped by a trailing
sliver. The logs empty state is left alone for the same reason inverted - it
already fills the viewport, and a trailing inset would only add scroll slack.

Verified on a Pixel 7 running Android 16 (API 36) with 3-button navigation:
Settings, Logs, and Video Playback all end clear of the bar.

close #1766
2026-08-03 00:05:21 +02:00

826 lines
30 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:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';
import '../../connection/connection.dart';
import '../../exceptions/media_server_exceptions.dart';
import '../../focus/card_focus_scope.dart';
import '../../focus/focusable_button.dart';
import '../../focus/focusable_text_field.dart';
import '../../focus/focusable_wrapper.dart';
import '../../i18n/strings.g.dart';
import '../../mixins/controller_disposer_mixin.dart';
import '../../profiles/active_profile_binder.dart';
import '../../profiles/active_profile_provider.dart';
import '../../profiles/profile.dart';
import '../../profiles/profile_connection.dart';
import '../../services/jellyfin_auth_service.dart';
import '../../services/jellyfin_endpoint_discovery.dart';
import '../../services/jellyfin_lan_discovery_service.dart';
import '../../services/storage_service.dart';
import '../../theme/mono_tokens.dart';
import '../../utils/app_logger.dart';
import '../../utils/device_identity.dart';
import '../../utils/platform_detector.dart';
import '../../widgets/focused_scroll_scaffold.dart';
import '../profile/profile_switch_screen.dart';
import 'async_form_state_mixin.dart';
import 'connection_persistence.dart';
import '../../widgets/loading_indicator_box.dart';
@visibleForTesting
Future<String> resolveJellyfinClientVersion({Future<PackageInfo> Function()? packageInfoLoader}) async {
const fallbackVersion = '1.0';
try {
final packageInfo = await (packageInfoLoader == null ? PackageInfo.fromPlatform() : packageInfoLoader());
final version = packageInfo.version.trim();
if (version.isNotEmpty) return version;
appLogger.w('Package version is empty; using Jellyfin client version $fallbackVersion');
} catch (error, stackTrace) {
appLogger.w(
'Failed to resolve package version; using Jellyfin client version $fallbackVersion',
error: error,
stackTrace: stackTrace,
);
}
return fallbackVersion;
}
@visibleForTesting
bool shouldCreateLocalJellyfinProfile({
required Profile? targetProfile,
required Profile? activeProfile,
required bool hasProfiles,
}) {
return targetProfile == null && activeProfile == null && !hasProfiles;
}
@visibleForTesting
bool shouldPromptForJellyfinProfileSelection({
required Profile? targetProfile,
required Profile? activeProfile,
required bool hasProfiles,
}) {
return targetProfile == null && activeProfile == null && hasProfiles;
}
/// Three-step form to add a Jellyfin server:
/// 1. Probe URL candidates (`/System/Info/Public`).
/// 2. Username + password (`/Users/AuthenticateByName`) **or** Quick Connect
/// (`/QuickConnect/Initiate` → poll → `/Users/AuthenticateWithQuickConnect`).
/// 3. Persist via [ConnectionRegistry] and create a [ProfileConnection]
/// row binding the server to [targetProfile] (or the active profile,
/// if not provided). When the target *is* the active profile we also
/// register the client with the manager so libraries refresh
/// immediately; otherwise the binder picks it up on the next switch.
class AddJellyfinScreen extends StatefulWidget {
/// When set, the new Jellyfin connection is bound to this profile via a
/// [ProfileConnection] row. When null, falls back to the currently active
/// profile (typical for the global Connections screen entry point).
final Profile? targetProfile;
final FutureOr<JellyfinConnectionAuthService> Function()? _authServiceFactory;
final FutureOr<List<DiscoveredJellyfinServer>> Function()? _localDiscoveryFactory;
const AddJellyfinScreen({
super.key,
this.targetProfile,
@visibleForTesting this._authServiceFactory,
@visibleForTesting this._localDiscoveryFactory,
});
@override
State<AddJellyfinScreen> createState() => _AddJellyfinScreenState();
}
class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormStateMixin, ControllerDisposerMixin {
late final _urlController = createTextEditingController();
late final _usernameController = createTextEditingController();
late final _passwordController = createTextEditingController();
final _urlFocus = FocusNode(debugLabel: 'AddJellyfin:Url');
final _findServerFocus = FocusNode(debugLabel: 'AddJellyfin:FindServer');
final _changeServerFocus = FocusNode(debugLabel: 'AddJellyfin:ChangeServer');
final _usernameFocus = FocusNode(debugLabel: 'AddJellyfin:Username');
// Owned so the username field can advance focus on Enter; mobile keyboards
// act on `textInputAction: next` automatically but TV remotes / hardware
// keyboards need the explicit `onFieldSubmitted` handler below.
final _passwordFocus = FocusNode(debugLabel: 'AddJellyfin:Password');
final _signInFocus = FocusNode(debugLabel: 'AddJellyfin:SignIn');
final _quickConnectFocus = FocusNode(debugLabel: 'AddJellyfin:QuickConnect');
final _cancelQuickConnectFocus = FocusNode(debugLabel: 'AddJellyfin:CancelQuickConnect');
final _discoveredServerFocusNodes = <String, FocusNode>{};
final _formKey = GlobalKey<FormState>();
JellyfinServerInfo? _serverInfo;
JellyfinEndpointRaceResult? _serverEndpoint;
List<DiscoveredJellyfinServer> _localServers = const [];
bool _isDiscoveringLocalServers = true;
bool _quickConnectEnabled = false;
JellyfinQuickConnectInitiation? _qcInitiation;
bool _qcCancelled = false;
int _qcAttemptId = 0;
int _localDiscoveryAttemptId = 0;
@override
void initState() {
super.initState();
unawaited(_discoverLocalServers());
}
@override
void dispose() {
// Short-circuit any in-flight Quick Connect poll so it doesn't try to
// setState after the widget is gone.
_qcCancelled = true;
_qcAttemptId++;
_urlFocus.dispose();
_findServerFocus.dispose();
_changeServerFocus.dispose();
_usernameFocus.dispose();
_passwordFocus.dispose();
_signInFocus.dispose();
_quickConnectFocus.dispose();
_cancelQuickConnectFocus.dispose();
for (final node in _discoveredServerFocusNodes.values) {
node.dispose();
}
super.dispose();
}
Future<void> _discoverLocalServers() async {
final attemptId = ++_localDiscoveryAttemptId;
try {
final factory = widget._localDiscoveryFactory;
final servers = factory != null
? await factory()
: await JellyfinLanDiscoveryService().discover(responseWindow: const Duration(milliseconds: 1300));
if (!mounted || attemptId != _localDiscoveryAttemptId) return;
setState(() {
_localServers = servers;
_isDiscoveringLocalServers = false;
_syncDiscoveredServerFocusNodes(servers);
});
} catch (e, st) {
appLogger.w('Add Jellyfin local discovery failed', error: e, stackTrace: st);
if (!mounted || attemptId != _localDiscoveryAttemptId) return;
setState(() => _isDiscoveringLocalServers = false);
}
}
void _syncDiscoveredServerFocusNodes(List<DiscoveredJellyfinServer> servers) {
final ids = servers.map((server) => server.id).toSet();
final removed = _discoveredServerFocusNodes.keys.where((id) => !ids.contains(id)).toList(growable: false);
for (final id in removed) {
_discoveredServerFocusNodes.remove(id)?.dispose();
}
for (final server in servers) {
_discoveredServerFocusNodes.putIfAbsent(
server.id,
() => FocusNode(debugLabel: 'AddJellyfin:Discovered:${server.id}'),
);
}
}
void _clearResolvedServer() {
_serverEndpoint = null;
_serverInfo = null;
_quickConnectEnabled = false;
}
Future<void> _useDiscoveredServer(DiscoveredJellyfinServer server) async {
if (busy) return;
setState(() {
_urlController.text = server.address;
_clearResolvedServer();
});
await _probe();
}
Future<void> _probe() async {
final input = JellyfinEndpointDiscovery.buildUserInputCandidates(_enteredUrls());
if (input.probeBaseUrls.isEmpty) {
setErrorText(t.addServer.enterJellyfinUrlError);
return;
}
final autoStartQuickConnect = await runAsync<bool>(
() async {
final auth = await _buildAuthService();
final endpoint = await auth.raceEndpoints(
input.probeBaseUrls,
baseUrlsToPersist: input.explicitBaseUrls,
baseUrlValidationGroups: input.validationBaseUrlGroups,
);
final qcEnabled = await auth.isQuickConnectEnabled(endpoint.activeBaseUrl);
if (!mounted) return false;
setState(() {
_serverEndpoint = endpoint;
_serverInfo = endpoint.serverInfo;
_quickConnectEnabled = qcEnabled;
_urlController.text = endpoint.baseUrls.join('\n');
});
// On TV, typing a username/password with a remote is misery — auto-jump
// to Quick Connect when the server supports it. Mirrors the
// PlatformDetector.isTV() default in add_plex_account_screen.dart.
final autoStart = qcEnabled && PlatformDetector.isTV();
if (!autoStart) _requestFocusAfterFrame(_usernameFocus);
return autoStart;
},
errorMapper: (e) =>
e is MediaServerUrlException ? e.message : t.addServer.couldNotReachServer(error: e.toString()),
);
// Sequenced after the probe's runAsync so busy stays set straight through
// /QuickConnect/Initiate. Started from inside the probe body, the probe's
// `finally` cleared busy mid-initiate, re-enabling the form — the focus
// fallback from the removed tile/button then landed on the URL field and
// auto-opened the TV keyboard over the Quick Connect panel.
if (autoStartQuickConnect == true && mounted) await _startQuickConnect();
}
Future<void> _signIn() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
final info = _serverInfo;
final endpoint = _serverEndpoint;
if (info == null || endpoint == null) {
await _probe();
return;
}
await runAsync<void>(
() async {
final auth = await _buildAuthService();
final storage = await StorageService.getInstance();
final deviceId = await storage.getOrCreateClientIdentifier();
final connection = await auth.authenticateByName(
baseUrl: endpoint.activeBaseUrl,
baseUrls: endpoint.baseUrls,
username: _usernameController.text,
password: _passwordController.text,
deviceId: deviceId,
serverInfo: info,
);
if (!mounted) return;
await _persistAndExit(connection);
},
errorMapper: (e) {
if (e is MediaServerAuthException) return e.message;
appLogger.e('Add Jellyfin failed', error: e);
return t.addServer.signInFailed(error: e.toString());
},
);
}
Future<void> _startQuickConnect() async {
final info = _serverInfo;
final endpoint = _serverEndpoint;
if (info == null || endpoint == null) return;
final attemptId = ++_qcAttemptId;
setState(() => _qcCancelled = false);
await runAsync<void>(
() async {
final auth = await _buildAuthService();
final storage = await StorageService.getInstance();
final deviceId = await storage.getOrCreateClientIdentifier();
final initiation = await auth.initiateQuickConnect(baseUrl: endpoint.activeBaseUrl, deviceId: deviceId);
if (!_isCurrentQuickConnectAttempt(attemptId)) return;
// Show the waiting panel without a spinner — opt-out of busy mid-flow
// so the user-visible state matches "we're polling, nothing for you to do".
setState(() => _qcInitiation = initiation);
_requestFocusAfterFrame(_cancelQuickConnectFocus);
setBusy(false);
final connection = await auth.authenticateByQuickConnect(
baseUrl: endpoint.activeBaseUrl,
baseUrls: endpoint.baseUrls,
secret: initiation.secret,
deviceId: deviceId,
serverInfo: info,
shouldCancel: () => _qcCancelled || attemptId != _qcAttemptId,
);
if (!_isCurrentQuickConnectAttempt(attemptId)) return;
if (connection == null) {
// Either user cancelled or the secret expired before approval.
// Cancellation is silent; expiry surfaces an error.
setState(() => _qcInitiation = null);
if (!_qcCancelled) setErrorText(t.auth.quickConnectExpired);
return;
}
await _persistAndExit(connection);
},
errorMapper: (e) {
if (e is MediaServerAuthException) return e.message;
appLogger.e('Jellyfin Quick Connect failed', error: e);
return t.addServer.quickConnectFailed(error: e.toString());
},
shouldApplyState: () => attemptId == _qcAttemptId,
);
// Clear the QC panel after any error so the form re-shows.
if (_isCurrentQuickConnectAttempt(attemptId) && errorText != null && _qcInitiation != null) {
setState(() => _qcInitiation = null);
}
}
bool _isCurrentQuickConnectAttempt(int attemptId) => mounted && attemptId == _qcAttemptId;
void _cancelQuickConnect() {
_qcAttemptId++;
setState(() {
_qcCancelled = true;
_qcInitiation = null;
});
setBusy(false);
}
void _requestFocusAfterFrame(FocusNode node) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !node.canRequestFocus) return;
node.requestFocus();
});
}
void _focusFirstDiscoveredServerOrFind() {
if (_localServers.isEmpty) {
_findServerFocus.requestFocus();
return;
}
_discoveredServerFocusNodes[_localServers.first.id]?.requestFocus();
}
void _focusLastDiscoveredServerOrUrl() {
if (_localServers.isEmpty) {
_urlFocus.requestFocus();
return;
}
_discoveredServerFocusNodes[_localServers.last.id]?.requestFocus();
}
List<String> _enteredUrls() => JellyfinEndpointDiscovery.parseUserEnteredUrls(_urlController.text);
/// Shared persistence path for both username/password and Quick Connect:
/// atomically provision the optional first-run profile, connection, and
/// ownership row, then bind and pop only after durable success.
Future<void> _persistAndExit(JellyfinConnection connection) async {
if (!mounted) return;
final activeProvider = context.read<ActiveProfileProvider>();
await activeProvider.initialize();
if (!mounted) return;
final targetProfile = widget.targetProfile;
var boundProfile = targetProfile ?? activeProvider.active;
if (shouldPromptForJellyfinProfileSelection(
targetProfile: targetProfile,
activeProfile: activeProvider.active,
hasProfiles: activeProvider.profiles.isNotEmpty,
)) {
await Navigator.of(
context,
rootNavigator: true,
).push<bool>(MaterialPageRoute(builder: (_) => const ProfileSwitchScreen(requireSelection: true)));
if (!mounted) return;
boundProfile = activeProvider.active;
if (boundProfile == null) {
setErrorText(t.messages.noProfilesAvailable);
return;
}
}
Profile? firstRunProfile;
if (shouldCreateLocalJellyfinProfile(
targetProfile: targetProfile,
activeProfile: boundProfile,
hasProfiles: activeProvider.profiles.isNotEmpty,
)) {
final now = DateTime.now();
firstRunProfile = Profile.local(
id: 'local-${const Uuid().v4()}',
displayName: connection.userName.isNotEmpty ? connection.userName : connection.serverName,
sortOrder: now.millisecondsSinceEpoch,
createdAt: now,
);
boundProfile = firstRunProfile;
}
final bindProfile = boundProfile;
if (bindProfile == null) {
setErrorText(t.messages.noProfilesAvailable);
return;
}
await persistAndBindConnection(
context: context,
connection: connection,
bindToProfile: ProfileConnection(
profileId: bindProfile.id,
connectionId: connection.id,
userToken: connection.accessToken,
userIdentifier: connection.userId,
tokenAcquiredAt: DateTime.now(),
),
addToManager: null,
firstRunProfile: firstRunProfile,
);
final boundToActive = bindProfile.id == activeProvider.activeId;
if (!mounted) return;
if (boundToActive) {
await context.read<ActiveProfileBinder>().rebindIfActive(bindProfile.id);
}
if (!mounted) return;
Navigator.of(context).pop(true);
}
Future<JellyfinConnectionAuthService> _buildAuthService() async {
final authServiceFactory = widget._authServiceFactory;
if (authServiceFactory != null) return await authServiceFactory();
final clientVersion = await resolveJellyfinClientVersion();
final deviceName = await _resolveDeviceName();
return JellyfinConnectionAuthService(clientName: 'Plezy', clientVersion: clientVersion, deviceName: deviceName);
}
/// The raw name, not a header-sanitized one: the Jellyfin `MediaBrowser`
/// header percent-encodes it, so the device list shows it verbatim.
Future<String> _resolveDeviceName() async {
final identity = await DeviceIdentityService.resolve();
final name = identity.deviceName?.trim();
return name == null || name.isEmpty ? 'Plezy' : name;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FocusedScrollScaffold(
title: Text(t.addServer.addJellyfinTitle),
slivers: [
if (_qcInitiation != null)
SliverFillRemaining(
hasScrollBody: false,
child: Padding(
padding: .fromLTRB(24, 24, 24, 24 + MediaQuery.paddingOf(context).bottom),
child: Center(child: _buildQuickConnectPanel(theme)),
),
)
else
SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverToBoxAdapter(
child: Form(
key: _formKey,
child: Column(crossAxisAlignment: .stretch, children: _buildBodyChildren(theme)),
),
),
),
],
);
}
List<Widget> _buildBodyChildren(ThemeData theme) {
return [
FocusableTextFormField(
controller: _urlController,
focusNode: _urlFocus,
tvTextInputPresentation: PlatformDetector.isAppleTV()
? TvTextInputPresentation.platform
: TvTextInputPresentation.automatic,
autofocus: true,
tvTextInputAutoOpenBehavior: deferredUrlFieldAutoOpen,
keyboardType: TextInputType.url,
minLines: 1,
maxLines: 4,
autocorrect: false,
enableSuggestions: false,
enabled: !busy,
onChanged: (_) {
if (_serverInfo == null && _serverEndpoint == null && !_quickConnectEnabled) return;
setState(() {
_clearResolvedServer();
});
},
onNavigateDown: _serverInfo == null
? _focusFirstDiscoveredServerOrFind
: () => _changeServerFocus.requestFocus(),
textInputAction: TextInputAction.go,
onFieldSubmitted: busy ? null : (_) => _probe(),
decoration: InputDecoration(
labelText: t.addServer.serverUrls,
// URL example — intentionally not localized.
hintText: 'https://jellyfin.example.com',
helperText: _serverInfo == null ? t.addServer.serverUrlsHelper : null,
prefixIcon: const AppIcon(Symbols.link_rounded, fill: 1),
),
validator: (_) => _enteredUrls().isEmpty ? t.addServer.required : null,
),
if (_serverInfo == null) ...[
..._buildLocalDiscoverySection(theme),
const SizedBox(height: 16),
FocusableButton(
focusNode: _findServerFocus,
useBackgroundFocus: true,
onNavigateUp: _focusLastDiscoveredServerOrUrl,
onPressed: busy ? null : _probe,
child: FilledButton.icon(
onPressed: busy ? null : _probe,
icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.travel_explore_rounded, fill: 1),
label: Text(t.addServer.findServer),
),
),
] else ...[
const SizedBox(height: 16),
_buildServerCard(theme),
const SizedBox(height: 16),
FocusableTextFormField(
controller: _usernameController,
focusNode: _usernameFocus,
autocorrect: false,
enableSuggestions: false,
enabled: !busy,
onNavigateUp: () => _changeServerFocus.requestFocus(),
textInputAction: TextInputAction.next,
onFieldSubmitted: busy ? null : (_) => _passwordFocus.requestFocus(),
decoration: InputDecoration(
labelText: t.addServer.username,
prefixIcon: const AppIcon(Symbols.person_rounded, fill: 1),
),
validator: (v) => v == null || v.trim().isEmpty ? t.addServer.required : null,
),
const SizedBox(height: 12),
FocusableTextFormField(
controller: _passwordController,
focusNode: _passwordFocus,
obscureText: true,
enabled: !busy,
textInputAction: TextInputAction.done,
onFieldSubmitted: busy ? null : (_) => _signIn(),
decoration: InputDecoration(
labelText: t.addServer.password,
prefixIcon: const AppIcon(Symbols.lock_rounded, fill: 1),
),
// Empty password is valid for some Jellyfin setups, so don't
// require a value.
),
const SizedBox(height: 16),
FocusableButton(
focusNode: _signInFocus,
useBackgroundFocus: true,
onPressed: busy ? null : _signIn,
child: FilledButton.icon(
onPressed: busy ? null : _signIn,
icon: busy ? const LoadingIndicatorBox() : const AppIcon(Symbols.login_rounded, fill: 1),
label: Text(t.addServer.signIn),
),
),
if (_quickConnectEnabled) ...[
const SizedBox(height: 12),
FocusableButton(
focusNode: _quickConnectFocus,
useBackgroundFocus: true,
onPressed: busy ? null : _startQuickConnect,
child: OutlinedButton.icon(
onPressed: busy ? null : _startQuickConnect,
icon: const AppIcon(Symbols.tap_and_play_rounded, fill: 1),
label: Text(t.auth.useQuickConnect),
),
),
],
],
...buildInlineError(theme),
];
}
Widget _buildServerCard(ThemeData theme) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(tokens(context).radiusMd),
),
child: Row(
children: [
const AppIcon(Symbols.cloud_done_rounded, fill: 1),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: .start,
children: [
Text(_serverInfo!.serverName, style: theme.textTheme.titleSmall),
Text(
'Jellyfin ${_serverInfo!.version}',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.7)),
),
],
),
),
FocusableButton(
focusNode: _changeServerFocus,
useBackgroundFocus: true,
onNavigateUp: () => _urlFocus.requestFocus(),
onNavigateDown: () => _usernameFocus.requestFocus(),
onPressed: busy
? null
: () => setState(() {
_clearResolvedServer();
}),
child: TextButton(
onPressed: busy
? null
: () => setState(() {
_clearResolvedServer();
}),
child: Text(t.addServer.change),
),
),
],
),
);
}
List<Widget> _buildLocalDiscoverySection(ThemeData theme) {
if (_isDiscoveringLocalServers) {
return [
const SizedBox(height: 16),
Row(
children: [
const LoadingIndicatorBox(size: 16),
const SizedBox(width: 10),
Expanded(
child: Text(
t.addServer.searchingLocalServers,
style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.7)),
),
),
],
),
];
}
if (_localServers.isEmpty) return const [];
final tokensRef = tokens(context);
return [
const SizedBox(height: 16),
Text(t.addServer.localServers, style: theme.textTheme.titleSmall),
const SizedBox(height: 8),
for (final (i, server) in _localServers.indexed) ...[
if (i > 0) SizedBox(height: tokensRef.groupGap),
_DiscoveredJellyfinServerTile(
server: server,
borderRadius: groupItemRadii(context, i, _localServers.length),
focusNode: _discoveredServerFocusNodes[server.id],
onNavigateUp: () {
final index = _localServers.indexOf(server);
if (index <= 0) {
_urlFocus.requestFocus();
return;
}
_discoveredServerFocusNodes[_localServers[index - 1].id]?.requestFocus();
},
onNavigateDown: () {
final index = _localServers.indexOf(server);
if (index < 0 || index == _localServers.length - 1) {
_findServerFocus.requestFocus();
return;
}
_discoveredServerFocusNodes[_localServers[index + 1].id]?.requestFocus();
},
onTap: busy ? null : () => unawaited(_useDiscoveredServer(server)),
),
],
const SizedBox(height: 8),
];
}
Widget _buildQuickConnectPanel(ThemeData theme) {
final code = _qcInitiation!.code;
final muted = theme.colorScheme.onSurface.withValues(alpha: 0.7);
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Column(
mainAxisSize: .min,
children: [
Text(
t.auth.quickConnectInstructions,
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(color: muted),
),
const SizedBox(height: 32),
FittedBox(
fit: BoxFit.scaleDown,
child: Padding(
// letterSpacing adds a trailing gap after the last glyph;
// matching left padding keeps the code optically centered.
padding: const EdgeInsets.only(left: 12),
child: Text(
code,
style: theme.textTheme.displayLarge?.copyWith(
fontFamily: 'monospace',
fontWeight: .bold,
letterSpacing: 12,
),
),
),
),
const SizedBox(height: 32),
Row(
mainAxisSize: .min,
children: [
const LoadingIndicatorBox(size: 16),
const SizedBox(width: 10),
Text(t.auth.quickConnectWaiting, style: theme.textTheme.bodyMedium?.copyWith(color: muted)),
],
),
const SizedBox(height: 32),
FocusableButton(
focusNode: _cancelQuickConnectFocus,
useBackgroundFocus: true,
onPressed: _cancelQuickConnect,
child: OutlinedButton.icon(
onPressed: _cancelQuickConnect,
icon: const AppIcon(Symbols.close_rounded, fill: 1),
label: Text(t.auth.quickConnectCancel),
),
),
...buildInlineError(theme, gap: 16, center: true),
],
),
);
}
}
class _DiscoveredJellyfinServerTile extends StatelessWidget {
final DiscoveredJellyfinServer server;
final BorderRadius borderRadius;
final FocusNode? focusNode;
final VoidCallback? onNavigateUp;
final VoidCallback? onNavigateDown;
final VoidCallback? onTap;
const _DiscoveredJellyfinServerTile({
required this.server,
required this.borderRadius,
required this.focusNode,
required this.onNavigateUp,
required this.onNavigateDown,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return FocusableWrapper(
focusNode: focusNode,
disableScale: true,
// Border drawn by CardFocusBorder so it paints over the opaque Material.
delegateFocusBorder: true,
descendantsAreFocusable: false,
onSelect: onTap,
onNavigateUp: onNavigateUp,
onNavigateDown: onNavigateDown,
child: CardFocusBorder(
borderRadii: borderRadius,
strokeAlign: BorderSide.strokeAlignInside,
child: Material(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: borderRadius,
child: InkWell(
onTap: onTap,
borderRadius: borderRadius,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
const AppIcon(Symbols.dns_rounded, fill: 1),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: .start,
mainAxisSize: .min,
children: [
Text(server.name, style: theme.textTheme.titleSmall),
const SizedBox(height: 2),
Text(
server.address,
maxLines: 1,
overflow: .ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurface.withValues(alpha: 0.7),
),
),
],
),
),
const SizedBox(width: 12),
const AppIcon(Symbols.chevron_right_rounded, fill: 1),
],
),
),
),
),
),
);
}
}