fix: back improvements
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'dpad_navigator.dart';
|
||||
import 'key_event_utils.dart';
|
||||
|
||||
/// Callbacks for chip key event handling.
|
||||
class ChipKeyCallbacks {
|
||||
@@ -102,12 +103,19 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
||||
/// Returns [KeyEventResult.handled] if the event was consumed,
|
||||
/// [KeyEventResult.ignored] otherwise.
|
||||
KeyEventResult handleChipKeyEvent(FocusNode node, KeyEvent event, ChipKeyCallbacks callbacks) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
if (callbacks.onBack != null) {
|
||||
final backResult = handleBackKeyAction(event, callbacks.onBack!);
|
||||
if (backResult != KeyEventResult.ignored) {
|
||||
return backResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
// SELECT key activates the chip
|
||||
if (key.isSelectKey && callbacks.onSelect != null) {
|
||||
callbacks.onSelect!();
|
||||
@@ -138,12 +146,6 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// BACK key
|
||||
if (key.isBackKey && callbacks.onBack != null) {
|
||||
callbacks.onBack!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/services.dart';
|
||||
import 'dpad_navigator.dart';
|
||||
import 'focus_theme.dart';
|
||||
import 'input_mode_tracker.dart';
|
||||
import 'key_event_utils.dart';
|
||||
|
||||
/// A wrapper widget that makes its child focusable with D-pad navigation support.
|
||||
///
|
||||
@@ -300,6 +301,13 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
}
|
||||
}
|
||||
|
||||
if (widget.onBack != null) {
|
||||
final backResult = handleBackKeyAction(event, widget.onBack!);
|
||||
if (backResult != KeyEventResult.ignored) {
|
||||
return backResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle SELECT key with optional long-press detection
|
||||
if (key.isSelectKey) {
|
||||
if (widget.enableLongPress) {
|
||||
@@ -358,12 +366,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// BACK key
|
||||
if (key.isBackKey && widget.onBack != null) {
|
||||
widget.onBack!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,16 +28,53 @@ import 'dpad_navigator.dart';
|
||||
/// child: ...
|
||||
/// )
|
||||
/// ```
|
||||
KeyEventResult handleBackKeyNavigation<T>(BuildContext context, KeyEvent event, {T? result}) {
|
||||
// Handle on KeyUpEvent to prevent double-pop when returning from child screens
|
||||
// (KeyDownEvent can be received by both the popping screen and the returned-to screen)
|
||||
if (event is KeyUpEvent && event.logicalKey.isBackKey) {
|
||||
Navigator.pop(context, result);
|
||||
class BackKeyCoordinator {
|
||||
static bool _handledThisFrame = false;
|
||||
static bool _clearScheduled = false;
|
||||
|
||||
static void markHandled() {
|
||||
_handledThisFrame = true;
|
||||
if (_clearScheduled) return;
|
||||
_clearScheduled = true;
|
||||
// Clear on next frame to avoid blocking unrelated future back presses.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_handledThisFrame = false;
|
||||
_clearScheduled = false;
|
||||
});
|
||||
}
|
||||
|
||||
static bool consumeIfHandled() {
|
||||
if (_handledThisFrame) {
|
||||
_handledThisFrame = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a BACK key press by running [onBack] on key up.
|
||||
///
|
||||
/// This consumes KeyDown/KeyRepeat to avoid duplicate actions from key repeat.
|
||||
/// Optionally suppresses stray KeyUp events delivered to the next route after a pop.
|
||||
KeyEventResult handleBackKeyAction(
|
||||
KeyEvent event,
|
||||
VoidCallback onBack,
|
||||
) {
|
||||
if (!event.logicalKey.isBackKey) return KeyEventResult.ignored;
|
||||
|
||||
if (event is KeyUpEvent) {
|
||||
BackKeyCoordinator.markHandled();
|
||||
onBack();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// Consume KeyDownEvent to prevent it from propagating but don't pop yet
|
||||
if (event is KeyDownEvent && event.logicalKey.isBackKey) {
|
||||
if (event is KeyDownEvent || event is KeyRepeatEvent) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
KeyEventResult handleBackKeyNavigation<T>(BuildContext context, KeyEvent event, {T? result}) {
|
||||
// Handle on KeyUpEvent to prevent double-pop when returning from child screens
|
||||
// (KeyDownEvent can be received by both the popping screen and the returned-to screen)
|
||||
return handleBackKeyAction(event, () => Navigator.pop(context, result));
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../utils/app_logger.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import 'base_media_list_detail_screen.dart';
|
||||
import 'focusable_detail_screen_mixin.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
|
||||
/// Screen to display the contents of a collection
|
||||
class CollectionDetailScreen extends StatefulWidget {
|
||||
@@ -135,6 +136,7 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (BackKeyCoordinator.consumeIfHandled()) return;
|
||||
if (didPop) return;
|
||||
final shouldPop = handleBackNavigation();
|
||||
if (shouldPop && mounted) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../widgets/app_icon.dart';
|
||||
|
||||
/// Configuration for app bar buttons
|
||||
@@ -141,11 +142,16 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T> {
|
||||
|
||||
/// Handle key events when app bar is focused
|
||||
KeyEventResult handleAppBarKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
final key = event.logicalKey;
|
||||
final maxButton = appBarButtonCount - 1;
|
||||
|
||||
final backResult = handleBackKeyAction(event, () => Navigator.pop(context));
|
||||
if (backResult != KeyEventResult.ignored) {
|
||||
return backResult;
|
||||
}
|
||||
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
if (key.isLeftKey && appBarFocusedButton > 0) {
|
||||
setState(() => appBarFocusedButton--);
|
||||
_focusAppBarButton(appBarFocusedButton);
|
||||
@@ -168,11 +174,6 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T> {
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isBackKey) {
|
||||
// Already on app bar, exit the screen
|
||||
Navigator.pop(context);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../focus/dpad_navigator.dart';
|
||||
import '../../focus/input_mode_tracker.dart';
|
||||
import '../../focus/key_event_utils.dart';
|
||||
import '../../services/gamepad_service.dart';
|
||||
import '../../../services/plex_client.dart';
|
||||
import '../../models/plex_library.dart';
|
||||
@@ -1223,10 +1224,30 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
final backResult = handleBackKeyAction(event, () {
|
||||
if (_movingIndex != null) {
|
||||
// Cancel move - restore original position
|
||||
setState(() {
|
||||
if (_originalOrder != null) {
|
||||
_tempLibraries = List.from(_originalOrder!);
|
||||
}
|
||||
_focusedIndex = _originalIndex ?? 0;
|
||||
_movingIndex = null;
|
||||
_originalIndex = null;
|
||||
_originalOrder = null;
|
||||
});
|
||||
} else {
|
||||
Navigator.pop(context);
|
||||
}
|
||||
});
|
||||
if (backResult != KeyEventResult.ignored) {
|
||||
return backResult;
|
||||
}
|
||||
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
if (_movingIndex != null) {
|
||||
// Move mode - arrows reorder the item
|
||||
if (key.isUpKey && _movingIndex! > 0) {
|
||||
@@ -1257,19 +1278,6 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isBackKey) {
|
||||
// Cancel move - restore original position
|
||||
setState(() {
|
||||
if (_originalOrder != null) {
|
||||
_tempLibraries = List.from(_originalOrder!);
|
||||
}
|
||||
_focusedIndex = _originalIndex ?? 0;
|
||||
_movingIndex = null;
|
||||
_originalIndex = null;
|
||||
_originalOrder = null;
|
||||
});
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
} else {
|
||||
// Navigation mode
|
||||
if (key.isUpKey && _focusedIndex > 0) {
|
||||
@@ -1315,10 +1323,6 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isBackKey) {
|
||||
Navigator.pop(context);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
|
||||
@@ -27,6 +27,7 @@ import '../services/plex_auth_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../utils/desktop_window_padding.dart';
|
||||
import '../widgets/side_navigation_rail.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import 'discover_screen.dart';
|
||||
import 'libraries/libraries_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
@@ -419,25 +420,14 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
}
|
||||
|
||||
KeyEventResult _handleBackKey(KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
// Handle all back keys - this handler is only reached if lower widgets
|
||||
// (e.g., LibrariesScreen tab content/chips) don't handle the back key first
|
||||
final isBackKey =
|
||||
event.logicalKey == LogicalKeyboardKey.escape ||
|
||||
event.logicalKey == LogicalKeyboardKey.goBack ||
|
||||
event.logicalKey == LogicalKeyboardKey.browserBack ||
|
||||
event.logicalKey == LogicalKeyboardKey.gameButtonB;
|
||||
|
||||
if (!isBackKey) return KeyEventResult.ignored;
|
||||
|
||||
// Toggle focus between sidebar and content
|
||||
if (_isSidebarFocused) {
|
||||
_focusContent();
|
||||
} else {
|
||||
_focusSidebar();
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
// Toggle focus between sidebar and content on BACK key
|
||||
return handleBackKeyAction(event, () {
|
||||
if (_isSidebarFocused) {
|
||||
_focusContent();
|
||||
} else {
|
||||
_focusSidebar();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -1200,20 +1200,29 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
|
||||
// Show loading state while fetching full metadata
|
||||
if (_isLoadingMetadata) {
|
||||
return Focus(
|
||||
final loading = Focus(
|
||||
onKeyEvent: handleBack,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: const Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
final blockSystemBack = Platform.isAndroid && InputModeTracker.isKeyboardMode(context);
|
||||
if (!blockSystemBack) {
|
||||
return loading;
|
||||
}
|
||||
return PopScope(
|
||||
canPop: false, // Prevent system back from double-popping on Android keyboard/TV
|
||||
onPopInvokedWithResult: (didPop, result) {},
|
||||
child: loading,
|
||||
);
|
||||
}
|
||||
|
||||
// Determine header height based on screen size
|
||||
final size = MediaQuery.of(context).size;
|
||||
final headerHeight = size.height * 0.6;
|
||||
|
||||
return Focus(
|
||||
final content = Focus(
|
||||
onKeyEvent: handleBack,
|
||||
child: Scaffold(
|
||||
body: Stack(
|
||||
@@ -1618,6 +1627,17 @@ class _MediaDetailScreenState extends State<MediaDetailScreen> with WatchStateAw
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final blockSystemBack = Platform.isAndroid && InputModeTracker.isKeyboardMode(context);
|
||||
if (!blockSystemBack) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: false, // Prevent system back from double-popping on Android keyboard/TV
|
||||
onPopInvokedWithResult: (didPop, result) {},
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoRow(String label, String value) {
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../../widgets/desktop_app_bar.dart';
|
||||
import '../../providers/settings_provider.dart';
|
||||
import '../../focus/dpad_navigator.dart';
|
||||
import '../../focus/input_mode_tracker.dart';
|
||||
import '../../focus/key_event_utils.dart';
|
||||
import 'playlist_item_card.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../utils/dialogs.dart';
|
||||
@@ -369,10 +370,24 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
|
||||
/// Handle key events for list navigation
|
||||
KeyEventResult _handleListKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
final backResult = handleBackKeyAction(event, () {
|
||||
if (_movingIndex != null) {
|
||||
// Cancel move mode, set flag to prevent PopScope exit
|
||||
_backHandledByKeyEvent = true;
|
||||
_cancelMoveMode();
|
||||
} else {
|
||||
// Navigate to app bar on BACK, set flag to prevent PopScope exit
|
||||
_handleBackFromContent();
|
||||
}
|
||||
});
|
||||
if (backResult != KeyEventResult.ignored) {
|
||||
return backResult;
|
||||
}
|
||||
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
if (_movingIndex != null) {
|
||||
// Move mode - arrows reorder the item
|
||||
if (key.isUpKey && _movingIndex! > 0) {
|
||||
@@ -411,12 +426,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
_persistMoveToServer(oldIndex, newIndex);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isBackKey) {
|
||||
// Cancel move mode, set flag to prevent PopScope exit
|
||||
_backHandledByKeyEvent = true;
|
||||
_cancelMoveMode();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
} else {
|
||||
// Navigation mode
|
||||
if (key.isUpKey) {
|
||||
@@ -481,11 +490,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isBackKey) {
|
||||
// Navigate to app bar on BACK, set flag to prevent PopScope exit
|
||||
_handleBackFromContent();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
@@ -493,12 +497,17 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
|
||||
/// Handle key events when app bar is focused
|
||||
KeyEventResult _handleAppBarKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
final key = event.logicalKey;
|
||||
final hasDelete = !widget.playlist.smart;
|
||||
final maxButton = hasDelete ? 2 : 1;
|
||||
|
||||
final backResult = handleBackKeyAction(event, () => Navigator.pop(context));
|
||||
if (backResult != KeyEventResult.ignored) {
|
||||
return backResult;
|
||||
}
|
||||
|
||||
if (event is! KeyDownEvent) return KeyEventResult.ignored;
|
||||
|
||||
if (key.isLeftKey && _appBarFocusedButton > 0) {
|
||||
setState(() => _appBarFocusedButton--);
|
||||
_focusAppBarButton(_appBarFocusedButton);
|
||||
@@ -525,11 +534,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
_triggerAppBarButton(_appBarFocusedButton);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isBackKey) {
|
||||
// Already on app bar, exit the screen
|
||||
Navigator.pop(context);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
@@ -701,6 +705,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (BackKeyCoordinator.consumeIfHandled()) return;
|
||||
if (didPop) return;
|
||||
final shouldPop = _handleBackNavigation();
|
||||
if (shouldPop && mounted) {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../services/plex_client.dart';
|
||||
import '../main.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../models/download_models.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
@@ -23,6 +26,7 @@ import '../mixins/watch_state_aware.dart';
|
||||
import '../utils/watch_state_notifier.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
|
||||
class SeasonDetailScreen extends StatefulWidget {
|
||||
final PlexMetadata season;
|
||||
@@ -34,7 +38,7 @@ class SeasonDetailScreen extends StatefulWidget {
|
||||
State<SeasonDetailScreen> createState() => _SeasonDetailScreenState();
|
||||
}
|
||||
|
||||
class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdatable, WatchStateAware {
|
||||
class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdatable, WatchStateAware, RouteAware {
|
||||
PlexClient? _client;
|
||||
|
||||
@override
|
||||
@@ -45,6 +49,8 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdata
|
||||
bool _watchStateChanged = false;
|
||||
// Capture keyboard mode once at init to avoid rebuild dependency
|
||||
bool _initialKeyboardMode = false;
|
||||
bool _suppressNextBackKeyUp = false;
|
||||
bool _routeSubscribed = false;
|
||||
|
||||
// WatchStateAware: watch all episode ratingKeys
|
||||
@override
|
||||
@@ -135,10 +141,45 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdata
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_routeSubscribed) return;
|
||||
final route = ModalRoute.of(context);
|
||||
if (route is PageRoute) {
|
||||
routeObserver.subscribe(this, route);
|
||||
_routeSubscribed = true;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_routeSubscribed) {
|
||||
routeObserver.unsubscribe(this);
|
||||
_routeSubscribed = false;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didPopNext() {
|
||||
// Returning from a child route (e.g., video player).
|
||||
// Suppress the first BACK KeyUp which can otherwise pop this route.
|
||||
_suppressNextBackKeyUp = true;
|
||||
}
|
||||
|
||||
KeyEventResult _handleBackKeyEvent(KeyEvent event) {
|
||||
if (_suppressNextBackKeyUp && event is KeyUpEvent && event.logicalKey.isBackKey) {
|
||||
_suppressNextBackKeyUp = false;
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return handleBackKeyNavigation(context, event, result: _watchStateChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Focus(
|
||||
onKeyEvent: (_, event) => handleBackKeyNavigation(context, event, result: _watchStateChanged),
|
||||
final content = Focus(
|
||||
onKeyEvent: (_, event) => _handleBackKeyEvent(event),
|
||||
child: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
@@ -200,6 +241,17 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen> with ItemUpdata
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final blockSystemBack = Platform.isAndroid && InputModeTracker.isKeyboardMode(context);
|
||||
if (!blockSystemBack) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: false, // Prevent system back from double-popping on Android keyboard/TV
|
||||
onPopInvokedWithResult: (didPop, result) {},
|
||||
child: content,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ import '../utils/video_player_navigation.dart';
|
||||
import '../widgets/video_controls/video_controls.dart';
|
||||
import '../focus/focusable_wrapper.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../watch_together/providers/watch_together_provider.dart';
|
||||
|
||||
@@ -96,6 +97,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation
|
||||
bool _isDisposingForNavigation = false;
|
||||
bool _waitingForExternalSubsTrackSelection = false;
|
||||
bool _isHandlingBack = false;
|
||||
|
||||
// Auto-play next episode
|
||||
Timer? _autoPlayTimer;
|
||||
@@ -981,6 +983,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
/// Handle back button press
|
||||
/// For non-host participants in Watch Together, shows leave session confirmation
|
||||
Future<void> _handleBackButton() async {
|
||||
if (_isHandlingBack) return;
|
||||
_isHandlingBack = true;
|
||||
try {
|
||||
// For non-host participants, show leave session confirmation
|
||||
if (_watchTogetherProvider != null && _watchTogetherProvider!.isInSession && !_watchTogetherProvider!.isHost) {
|
||||
final confirmed = await showDialog<bool>(
|
||||
@@ -1035,6 +1040,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
if (!mounted) return;
|
||||
_isExiting.value = true;
|
||||
Navigator.of(context).pop(true);
|
||||
} finally {
|
||||
_isHandlingBack = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1586,8 +1594,13 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
return PopScope(
|
||||
canPop: false, // Disable swipe-back gesture to prevent interference with timeline scrubbing
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (BackKeyCoordinator.consumeIfHandled()) return;
|
||||
// Allow programmatic back navigation from UI controls
|
||||
if (!didPop) _handleBackButton();
|
||||
if (!didPop) {
|
||||
// Mark handled to prevent the same BACK press from reaching the next route.
|
||||
BackKeyCoordinator.markHandled();
|
||||
_handleBackButton();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
// Use transparent background on macOS when native video layer is active
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../providers/settings_provider.dart';
|
||||
import '../services/settings_service.dart' show EpisodePosterMode;
|
||||
import '../theme/mono_tokens.dart';
|
||||
@@ -209,6 +210,13 @@ class HubSectionState extends State<HubSection> {
|
||||
}
|
||||
}
|
||||
|
||||
if (widget.onBack != null) {
|
||||
final backResult = handleBackKeyAction(event, widget.onBack!);
|
||||
if (backResult != KeyEventResult.ignored) {
|
||||
return backResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle key down and repeat events
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
@@ -260,12 +268,6 @@ class HubSectionState extends State<HubSection> {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// Back key: navigate to tab bar
|
||||
if (key.isBackKey && widget.onBack != null) {
|
||||
widget.onBack!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/focus_theme.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import 'app_icon.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
@@ -114,17 +115,19 @@ class _TvNumberSpinnerState extends State<TvNumberSpinner> {
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
if (widget.onCancel != null) {
|
||||
final backResult = handleBackKeyAction(event, widget.onCancel!);
|
||||
if (backResult != KeyEventResult.ignored) {
|
||||
return backResult;
|
||||
}
|
||||
}
|
||||
|
||||
if (event is KeyDownEvent) {
|
||||
// Handle SELECT key to confirm/move to save button
|
||||
if (key.isSelectKey && widget.onConfirm != null) {
|
||||
widget.onConfirm!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// Handle BACK key to cancel
|
||||
if (key.isBackKey && widget.onCancel != null) {
|
||||
widget.onCancel!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isUpKey || key.isRightKey) {
|
||||
_startRepeat(_increment);
|
||||
return KeyEventResult.handled;
|
||||
|
||||
@@ -28,6 +28,7 @@ import '../../models/plex_media_info.dart';
|
||||
import '../../models/plex_media_version.dart';
|
||||
import '../../models/plex_metadata.dart';
|
||||
import '../../screens/video_player_screen.dart';
|
||||
import '../../focus/key_event_utils.dart';
|
||||
import '../../services/keyboard_shortcuts_service.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
@@ -1136,14 +1137,6 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
key == LogicalKeyboardKey.arrowRight;
|
||||
}
|
||||
|
||||
/// Check if a key is a back/escape key
|
||||
bool _isBackKey(LogicalKeyboardKey key) {
|
||||
return key == LogicalKeyboardKey.escape ||
|
||||
key == LogicalKeyboardKey.goBack ||
|
||||
key == LogicalKeyboardKey.browserBack ||
|
||||
key == LogicalKeyboardKey.gameButtonB;
|
||||
}
|
||||
|
||||
/// Check if a key is a select/enter key
|
||||
bool _isSelectKey(LogicalKeyboardKey key) {
|
||||
return key == LogicalKeyboardKey.select ||
|
||||
@@ -1300,6 +1293,23 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
focusNode: _focusNode,
|
||||
autofocus: true,
|
||||
onKeyEvent: (node, event) {
|
||||
final backResult = handleBackKeyAction(event, () {
|
||||
// On Windows/Linux with navigation off, ESC first exits fullscreen
|
||||
if (!_videoPlayerNavigationEnabled && _isFullscreen && (Platform.isWindows || Platform.isLinux)) {
|
||||
_toggleFullscreen();
|
||||
return;
|
||||
}
|
||||
if (!_showControls) {
|
||||
_showControlsWithFocus();
|
||||
return;
|
||||
}
|
||||
// Controls visible - navigate back
|
||||
(widget.onBack ?? () => Navigator.of(context).pop(true))();
|
||||
});
|
||||
if (backResult != KeyEventResult.ignored) {
|
||||
return backResult;
|
||||
}
|
||||
|
||||
// Only handle KeyDown and KeyRepeat events
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
@@ -1347,23 +1357,6 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// Handle Back/Escape: show controls if hidden, navigate back if visible
|
||||
if (_isBackKey(key)) {
|
||||
// On Windows/Linux with navigation off, ESC first exits fullscreen
|
||||
if (!_videoPlayerNavigationEnabled && _isFullscreen && (Platform.isWindows || Platform.isLinux)) {
|
||||
_toggleFullscreen();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (!_showControls) {
|
||||
_showControlsWithFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// Controls visible - navigate back
|
||||
(widget.onBack ?? () => Navigator.of(context).pop(true))();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// Handle Select/Enter when controls are hidden: pause and show controls
|
||||
// Only intercept if this Focus node itself has primary focus (not a descendant)
|
||||
if (_isSelectKey(key) && !_showControls && _focusNode.hasPrimaryFocus) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../../focus/dpad_navigator.dart';
|
||||
import '../../../focus/key_event_utils.dart';
|
||||
import '../../../mpv/mpv.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
@@ -92,16 +93,20 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
}
|
||||
|
||||
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
if (_isAdjustMode) {
|
||||
if (key.isBackKey) {
|
||||
return handleBackKeyAction(event, _exitAdjustMode);
|
||||
}
|
||||
|
||||
// Notify activity on any key in adjust mode (to reset hide timer)
|
||||
widget.onFocusActivity?.call();
|
||||
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
// In adjust mode: left/right adjusts volume, back/escape exits
|
||||
if (key == LogicalKeyboardKey.arrowLeft) {
|
||||
_adjustVolume(-_volumeStep);
|
||||
@@ -111,7 +116,7 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
_adjustVolume(_volumeStep);
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
if (key.isBackKey || key.isSelectKey) {
|
||||
if (key.isSelectKey) {
|
||||
_exitAdjustMode();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
@@ -125,6 +130,10 @@ class _VolumeControlState extends State<VolumeControl> {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
// Not in adjust mode: use the provided key event handler for navigation
|
||||
return widget.onKeyEvent?.call(node, event) ?? KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user