fix(tv): keyboard no longer opens over jellyfin quick connect screen
Auto-starting quick connect from inside the probe's runAsync let the probe's finally clear busy mid-initiate; the re-enabled form then took focus fallback on the URL field, auto-opening the TV keyboard. Sequence the two busy scopes and debug-assert against overlapping runAsync.
This commit is contained in:
@@ -188,7 +188,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
||||
setErrorText(t.addServer.enterJellyfinUrlError);
|
||||
return;
|
||||
}
|
||||
await runAsync<void>(
|
||||
final autoStartQuickConnect = await runAsync<bool>(
|
||||
() async {
|
||||
final auth = await _buildAuthService();
|
||||
final endpoint = await auth.raceEndpoints(
|
||||
@@ -197,7 +197,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> 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<AddJellyfinScreen> 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<void> _signIn() async {
|
||||
|
||||
@@ -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<T extends StatefulWidget> on State<T> {
|
||||
bool _busy = false;
|
||||
String? _errorText;
|
||||
final List<bool Function()> _activeRunAsyncGuards = [];
|
||||
|
||||
bool get busy => _busy;
|
||||
String? get errorText => _errorText;
|
||||
@@ -39,6 +47,16 @@ mixin AsyncFormStateMixin<T extends StatefulWidget> on State<T> {
|
||||
}) 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<T extends StatefulWidget> on State<T> {
|
||||
}
|
||||
return null;
|
||||
} finally {
|
||||
assert(() {
|
||||
_activeRunAsyncGuards.remove(canApplyState);
|
||||
return true;
|
||||
}());
|
||||
if (canApplyState()) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void>.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(
|
||||
|
||||
Reference in New Issue
Block a user