Linking a Seerr instance had two rough edges, both on the Jellyfin path. The URL step prepended https:// to anything without a scheme, so a plain-HTTP instance on the LAN — 192.168.1.5:5055, or a bare host on Seerr's default port — failed with "could not reach" unless the user knew to type the scheme. And the only way to sign in with a Jellyfin account was typing a username and password, which on a TV remote is misery. Schemeless input now expands into candidates that are probed together, but TLS wins by construction: a plaintext success is held while any https candidate is still in flight and is accepted only once they have all failed. The sign-in that follows posts a password to whichever URL wins here, so a slow-but-working https endpoint has to beat a fast plaintext one, and the wait is already bounded by the probe timeout. When nothing answers, the failure that reached a server outranks a transport error, so "this instance has not completed first-run setup" is not masked by "could not reach https://..." from a candidate the user never typed. Quick Connect goes through Seerr's own proxy routes (3.4+): initiate, then check polled at Seerr's own 2s cadence through the shared pollWithBackoff — a 404 mid-poll means the secret is gone and is terminal — then authenticate, which mints the session cookie through the existing sign-in path. The secret rides in a query string, so it is registered for log redaction the way the Jellyfin flow registers its own. An instance that predates the routes 404s the initiate and is told apart from a generic rejection. SeerrAuthMethod.quickConnect deliberately stores no secret: silent re-auth is impossible by construction, so an expired cookie lands in the existing "no stored credentials" arm, unlinks the session, and the connect flow asks for a fresh code. The affordance is limited to the Jellyfin credential form, since Seerr rejects Quick Connect for Emby, and is not auto-started on TV the way the MediaBrowser form is: /settings/public exposes no "Quick Connect enabled" flag to gate that on. The waiting panel and its attempt/cancel bookkeeping moved out of AddJellyfinScreen into QuickConnectCodePanel and QuickConnectFlowMixin so both screens share one implementation, and the post-frame focus helper both forms use moved to AsyncFormStateMixin.
101 lines
3.3 KiB
Dart
101 lines
3.3 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
|
|
import '../focus/focusable_button.dart';
|
|
import '../i18n/strings.g.dart';
|
|
import 'app_icon.dart';
|
|
import 'loading_indicator_box.dart';
|
|
|
|
/// The Quick Connect waiting panel: the code to type into Jellyfin, a
|
|
/// "waiting for approval" line, and a cancel affordance.
|
|
///
|
|
/// Shared by the MediaBrowser add-server flow and the Seerr connect flow —
|
|
/// the same panel for the same interaction, only the server polling the code
|
|
/// differs. Callers place it in a filling slot (`SliverFillRemaining` + a
|
|
/// centering `Padding`) and own the poll itself.
|
|
class QuickConnectCodePanel extends StatelessWidget {
|
|
/// Code the user types into Jellyfin's Quick Connect screen.
|
|
final String code;
|
|
|
|
/// Focused after the panel appears so a remote can dismiss it.
|
|
final FocusNode? cancelFocusNode;
|
|
|
|
final VoidCallback onCancel;
|
|
|
|
/// Inline failure text under the cancel button, styled like
|
|
/// `AsyncFormStateMixin.buildInlineError`.
|
|
final String? errorText;
|
|
|
|
const QuickConnectCodePanel({
|
|
super.key,
|
|
required this.code,
|
|
required this.onCancel,
|
|
this.cancelFocusNode,
|
|
this.errorText,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
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: cancelFocusNode,
|
|
useBackgroundFocus: true,
|
|
onPressed: onCancel,
|
|
child: OutlinedButton.icon(
|
|
onPressed: onCancel,
|
|
icon: const AppIcon(Symbols.close_rounded, fill: 1),
|
|
label: Text(t.auth.quickConnectCancel),
|
|
),
|
|
),
|
|
if (errorText != null) ...[
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
errorText!,
|
|
textAlign: TextAlign.center,
|
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|