diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index 1a58d76c..a71c0912 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -188,7 +188,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta setErrorText(t.addServer.enterJellyfinUrlError); return; } - await runAsync( + final autoStartQuickConnect = await runAsync( () async { final auth = await _buildAuthService(); final endpoint = await auth.raceEndpoints( @@ -197,7 +197,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta baseUrlValidationGroups: input.validationBaseUrlGroups, ); final qcEnabled = await auth.isQuickConnectEnabled(endpoint.activeBaseUrl); - if (!mounted) return; + if (!mounted) return false; setState(() { _serverEndpoint = endpoint; _serverInfo = endpoint.serverInfo; @@ -207,15 +207,19 @@ class _AddJellyfinScreenState extends State with AsyncFormSta // 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. - if (qcEnabled && PlatformDetector.isTV()) { - unawaited(_startQuickConnect()); - } else { - _requestFocusAfterFrame(_usernameFocus); - } + 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 _signIn() async { diff --git a/lib/screens/settings/async_form_state_mixin.dart b/lib/screens/settings/async_form_state_mixin.dart index 88fb00ed..45b70df8 100644 --- a/lib/screens/settings/async_form_state_mixin.dart +++ b/lib/screens/settings/async_form_state_mixin.dart @@ -9,9 +9,17 @@ import 'package:flutter/widgets.dart'; /// Mid-flow state changes (e.g. clearing busy *before* the body finishes so /// the UI can swap into a "waiting" panel) are still possible via [setBusy] /// from inside the [runAsync] body — the `finally` clears busy idempotently. +/// +/// [runAsync] calls must be sequenced, never overlapped: there is a single +/// busy flag, so the first call to finish clears it while the other is still +/// running. Don't start a second runAsync (or fire one with `unawaited`) +/// while another that can still apply state is in flight — debug-asserted. +/// Overlapping a *stale* run whose [runAsync]'s `shouldApplyState` has gone +/// false (e.g. a cancelled poll unwinding) is fine; it no longer touches busy. mixin AsyncFormStateMixin on State { bool _busy = false; String? _errorText; + final List _activeRunAsyncGuards = []; bool get busy => _busy; String? get errorText => _errorText; @@ -39,6 +47,16 @@ mixin AsyncFormStateMixin on State { }) async { bool canApplyState() => mounted && (shouldApplyState?.call() ?? true); if (!canApplyState()) return null; + assert( + !_activeRunAsyncGuards.any((stillApplies) => stillApplies()), + 'runAsync overlapped with another runAsync that can still apply state: ' + 'the first to finish clears busy out from under the other. Sequence the ' + 'calls (await the first) instead of nesting or unawaiting them.', + ); + assert(() { + _activeRunAsyncGuards.add(canApplyState); + return true; + }()); setState(() { _busy = true; _errorText = null; @@ -51,6 +69,10 @@ mixin AsyncFormStateMixin on State { } return null; } finally { + assert(() { + _activeRunAsyncGuards.remove(canApplyState); + return true; + }()); if (canApplyState()) setState(() => _busy = false); } } diff --git a/test/screens/settings/add_jellyfin_screen_test.dart b/test/screens/settings/add_jellyfin_screen_test.dart index 6275edcd..017db3e5 100644 --- a/test/screens/settings/add_jellyfin_screen_test.dart +++ b/test/screens/settings/add_jellyfin_screen_test.dart @@ -17,7 +17,7 @@ import '../../test_helpers/prefs.dart'; Profile _profile(String id) => Profile.local(id: id, displayName: id, sortOrder: 0, createdAt: DateTime.fromMillisecondsSinceEpoch(0)); -JellyfinConnectionAuthService _jellyfinAuthService({bool quickConnectEnabled = false}) { +JellyfinConnectionAuthService _jellyfinAuthService({bool quickConnectEnabled = false, Duration? initiateDelay}) { return JellyfinConnectionAuthService( clientName: 'Plezy', clientVersion: 'test', @@ -33,6 +33,7 @@ JellyfinConnectionAuthService _jellyfinAuthService({bool quickConnectEnabled = f case '/QuickConnect/Enabled': return http.Response(jsonEncode(quickConnectEnabled), 200, headers: {'content-type': 'application/json'}); case '/QuickConnect/Initiate': + if (initiateDelay != null) await Future.delayed(initiateDelay); return http.Response( jsonEncode({'Code': '123456', 'Secret': 'qc-secret'}), 200, @@ -267,6 +268,67 @@ void main() { await tester.pump(const Duration(seconds: 6)); }); + testWidgets('TV auto Quick Connect never opens the keyboard across the panel swap', (tester) async { + resetSharedPreferencesForTest(); + TvDetectionService.debugSetAppleTVOverride(null); + await TvDetectionService.getInstance(forceTv: true); + TvDetectionService.setForceTVSync(true); + + await tester.pumpWidget( + InputModeTracker( + child: MaterialApp( + home: AddJellyfinScreen( + // Hold /QuickConnect/Initiate open so the frames between probe + // success and the panel swap are observable — that window is + // where the focus fallback used to auto-open the keyboard. + authServiceFactory: () => + _jellyfinAuthService(quickConnectEnabled: true, initiateDelay: const Duration(milliseconds: 50)), + localDiscoveryFactory: () async => [ + DiscoveredJellyfinServer(address: 'http://192.168.1.20:8096', id: 'srv-1', name: 'Home'), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url'); + + // D-pad to the discovered server and select it — on TV the probe + // auto-starts Quick Connect. + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Discovered:srv-1'); + + await tester.sendKeyEvent(LogicalKeyboardKey.select); + await tester.pump(); + await tester.pump(); + + // Pre-swap frames: probe done, initiate in flight — no keyboard. + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + + await tester.pump(const Duration(milliseconds: 60)); + await tester.pump(); + + // Quick Connect panel swapped in: code shown, Cancel focused, no keyboard. + expect(find.text('123456'), findsOneWidget); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:CancelQuickConnect'); + + await tester.tap(find.text('Cancel')); + await tester.pump(); + await tester.pump(); + + // Form returns; the URL field's autofocus re-fires on a fresh host whose + // first-focus suppression keeps the keyboard closed. + expect(find.text('123456'), findsNothing); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'AddJellyfin:Url'); + expect(find.byKey(const Key('tv_virtual_keyboard_panel')), findsNothing); + + // Let the cancelled poll's backoff timer fire so the test ends clean. + await tester.pump(const Duration(seconds: 6)); + }); + testWidgets('selecting a discovered Jellyfin server probes that address', (tester) async { await tester.pumpWidget( MaterialApp(