From 01bb1126ef8baf9a816efd70295c150d2b142c38 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 26 Nov 2025 13:51:25 +0100 Subject: [PATCH] feat: keyboard navigation --- lib/mixins/keyboard_long_press_mixin.dart | 109 ++ lib/screens/discover_screen.dart | 664 ++++--- lib/screens/libraries_screen.dart | 1198 ++++++++++--- .../library_tabs/base_library_tab.dart | 13 + .../library_tabs/library_browse_tab.dart | 236 ++- .../library_tabs/library_collections_tab.dart | 24 +- .../library_tabs/library_playlists_tab.dart | 20 + .../library_tabs/library_recommended_tab.dart | 36 +- lib/screens/main_screen.dart | 195 +- lib/screens/media_detail_screen.dart | 1576 +++++++++-------- lib/screens/playlist_detail_screen.dart | 1 + lib/screens/search_screen.dart | 284 +-- lib/screens/season_detail_screen.dart | 621 ++++--- lib/screens/settings_screen.dart | 61 +- lib/services/keyboard_shortcuts_service.dart | 12 +- lib/services/media_controls_manager.dart | 4 +- lib/services/track_selection_service.dart | 59 +- lib/utils/keyboard_utils.dart | 15 + lib/widgets/adaptive_media_grid.dart | 68 +- lib/widgets/filters_bottom_sheet.dart | 339 ++-- lib/widgets/focus/focus_indicator.dart | 146 ++ lib/widgets/hub_navigation_controller.dart | 115 ++ lib/widgets/hub_section.dart | 364 +++- lib/widgets/media_card.dart | 777 +++++--- lib/widgets/media_context_menu.dart | 411 ++++- lib/widgets/playlist_item_card.dart | 245 ++- lib/widgets/sort_bottom_sheet.dart | 135 +- .../video_controls/video_controls.dart | 1 + 28 files changed, 5233 insertions(+), 2496 deletions(-) create mode 100644 lib/mixins/keyboard_long_press_mixin.dart create mode 100644 lib/utils/keyboard_utils.dart create mode 100644 lib/widgets/focus/focus_indicator.dart create mode 100644 lib/widgets/hub_navigation_controller.dart diff --git a/lib/mixins/keyboard_long_press_mixin.dart b/lib/mixins/keyboard_long_press_mixin.dart new file mode 100644 index 00000000..d201eb04 --- /dev/null +++ b/lib/mixins/keyboard_long_press_mixin.dart @@ -0,0 +1,109 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// A mixin that provides keyboard long-press detection for focusable widgets. +/// +/// This allows keyboard/gamepad users to access context menus by holding +/// the activation key (Enter, Space, Select, or GameButtonA) for 1 second. +/// +/// Usage: +/// ```dart +/// class _MyWidgetState extends State with KeyboardLongPressMixin { +/// @override +/// void onKeyboardTap() { +/// // Handle short press (normal tap) +/// } +/// +/// @override +/// void onKeyboardLongPress() { +/// // Handle long press (show context menu) +/// } +/// +/// KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { +/// final result = handleKeyboardLongPress(event); +/// if (result == KeyEventResult.handled) return result; +/// // Handle other keys... +/// return KeyEventResult.ignored; +/// } +/// } +/// ``` +mixin KeyboardLongPressMixin on State { + Timer? _longPressTimer; + LogicalKeyboardKey? _pressedKey; + bool _longPressTriggered = false; + + static const _longPressDuration = Duration(seconds: 1); + + /// Override to handle tap action (short press) + void onKeyboardTap(); + + /// Override to handle long press action (e.g., show context menu) + void onKeyboardLongPress(); + + /// Check if the given key is an activation key + bool _isActivationKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.enter || + key == LogicalKeyboardKey.space || + key == LogicalKeyboardKey.select || + key == LogicalKeyboardKey.gameButtonA; + } + + /// Call this from your onKeyEvent handler to enable long-press detection. + /// Returns [KeyEventResult.handled] if the event was an activation key, + /// [KeyEventResult.ignored] otherwise. + KeyEventResult handleKeyboardLongPress(KeyEvent event) { + if (!_isActivationKey(event.logicalKey)) { + return KeyEventResult.ignored; + } + + if (event is KeyDownEvent) { + // Only start timer on initial press (not key repeat) + if (_pressedKey == null) { + _pressedKey = event.logicalKey; + _longPressTriggered = false; + _longPressTimer = Timer(_longPressDuration, () { + _longPressTriggered = true; + onKeyboardLongPress(); + }); + } + return KeyEventResult.handled; + } + + // Handle key repeat events (suppress system sound on macOS) + if (event is KeyRepeatEvent) { + return KeyEventResult.handled; + } + + if (event is KeyUpEvent && event.logicalKey == _pressedKey) { + _longPressTimer?.cancel(); + _longPressTimer = null; + _pressedKey = null; + + // Only trigger tap if long press wasn't already triggered + if (!_longPressTriggered) { + onKeyboardTap(); + } + _longPressTriggered = false; + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + /// Cancel any pending long-press timer. Call this if focus is lost + /// or the widget is being disposed while a key is held. + void cancelKeyboardLongPress() { + _longPressTimer?.cancel(); + _longPressTimer = null; + _pressedKey = null; + _longPressTriggered = false; + } + + @override + void dispose() { + _longPressTimer?.cancel(); + super.dispose(); + } +} diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 990505b1..7549a54c 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:provider/provider.dart'; import '../client/plex_client.dart'; @@ -14,6 +15,8 @@ import '../widgets/desktop_app_bar.dart'; import '../widgets/user_avatar_widget.dart'; import '../widgets/horizontal_scroll_with_arrows.dart'; import '../widgets/hub_section.dart'; +import '../widgets/hub_navigation_controller.dart'; +import '../widgets/focus/focus_indicator.dart'; import 'profile_switch_screen.dart'; import '../providers/user_profile_provider.dart'; import '../providers/settings_provider.dart'; @@ -21,10 +24,12 @@ import '../mixins/refreshable.dart'; import '../i18n/strings.g.dart'; import '../mixins/item_updatable.dart'; import '../utils/app_logger.dart'; +import '../utils/keyboard_utils.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; import '../utils/content_rating_formatter.dart'; import 'auth_screen.dart'; +import 'main_screen.dart'; class DiscoverScreen extends StatefulWidget { final VoidCallback? onBecameVisible; @@ -56,6 +61,7 @@ class _DiscoverScreenState extends State List _onDeck = []; List _hubs = []; bool _isLoading = true; + bool _isInitialLoad = true; String? _errorMessage; final PageController _heroController = PageController(); final ScrollController _scrollController = ScrollController(); @@ -63,6 +69,10 @@ class _DiscoverScreenState extends State Timer? _autoScrollTimer; late AnimationController _indicatorAnimationController; bool _isAutoScrollPaused = false; + final HubNavigationController _hubNavigationController = + HubNavigationController(); + late final FocusNode _heroFocusNode; + bool _heroIsFocused = false; /// Get the correct PlexClient for an item's server PlexClient _getClientForItem(PlexMetadata? item) { @@ -90,16 +100,92 @@ class _DiscoverScreenState extends State vsync: this, duration: _heroAutoScrollDuration, ); + _heroFocusNode = FocusNode(debugLabel: 'HeroSection'); + _heroFocusNode.addListener(_handleHeroFocusChange); _loadContent(); _startAutoScroll(); } + void _handleHeroFocusChange() { + if (_heroIsFocused != _heroFocusNode.hasFocus) { + setState(() { + _heroIsFocused = _heroFocusNode.hasFocus; + }); + if (_heroFocusNode.hasFocus) { + // Scroll to the very top when hero is focused + _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ); + } + } + } + + /// Handle back key press - focus bottom navigation + KeyEventResult _handleBackKey(FocusNode node, KeyEvent event) { + if (isBackKeyEvent(event)) { + BackNavigationScope.of(context)?.focusBottomNav(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + KeyEventResult _handleHeroKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent) { + // Enter/Space to play current hero item + if (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.space || + event.logicalKey == LogicalKeyboardKey.select || + event.logicalKey == LogicalKeyboardKey.gameButtonA) { + if (_onDeck.isNotEmpty && _currentHeroIndex < _onDeck.length) { + navigateToVideoPlayer(context, metadata: _onDeck[_currentHeroIndex]); + return KeyEventResult.handled; + } + } + + // Left arrow to go to previous hero item + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + if (_onDeck.isNotEmpty && _currentHeroIndex > 0) { + _heroController.previousPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + return KeyEventResult.handled; + } + } + + // Right arrow to go to next hero item + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + if (_onDeck.isNotEmpty && _currentHeroIndex < _onDeck.length - 1) { + _heroController.nextPage( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + return KeyEventResult.handled; + } + } + + // Down arrow to navigate to first hub section + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + // Try to navigate to the first hub section + if (_hubNavigationController.navigateToAdjacentHub('_hero_', 1)) { + return KeyEventResult.handled; + } + } + } + return KeyEventResult.ignored; + } + @override void dispose() { _autoScrollTimer?.cancel(); _heroController.dispose(); _scrollController.dispose(); _indicatorAnimationController.dispose(); + _hubNavigationController.dispose(); + _heroFocusNode.removeListener(_handleHeroFocusChange); + _heroFocusNode.dispose(); super.dispose(); } @@ -246,6 +332,16 @@ class _DiscoverScreenState extends State _heroController.jumpToPage(0); } + // Focus the hero on initial load + if (_isInitialLoad && onDeck.isNotEmpty) { + _isInitialLoad = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _heroFocusNode.requestFocus(); + } + }); + } + appLogger.d('Discover content loaded successfully'); } catch (e) { appLogger.e('Failed to load discover content', error: e); @@ -305,6 +401,13 @@ class _DiscoverScreenState extends State _loadContent(); } + /// Focus the hero section (for keyboard navigation) + void focusHero() { + if (_onDeck.isNotEmpty) { + _heroFocusNode.requestFocus(); + } + } + /// Get icon for hub based on its title IconData _getHubIcon(String title) { final lowerTitle = title.toLowerCase(); @@ -485,291 +588,314 @@ class _DiscoverScreenState extends State Widget build(BuildContext context) { return Scaffold( body: SafeArea( - child: CustomScrollView( - controller: _scrollController, - slivers: [ - DesktopSliverAppBar( - title: Text(t.discover.title), - floating: true, - pinned: true, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - surfaceTintColor: Colors.transparent, - shadowColor: Colors.transparent, - scrolledUnderElevation: 0, - actions: [ - IconButton( - icon: const Icon(Icons.refresh), - onPressed: _loadContent, - ), - Consumer( - builder: (context, userProvider, child) { - return PopupMenuButton( - icon: userProvider.currentUser?.thumb != null - ? UserAvatarWidget( - user: userProvider.currentUser!, - size: 32, - showIndicators: false, - ) - : const Icon(Icons.account_circle, size: 32), - onSelected: (value) { - if (value == 'switch_profile') { - _handleSwitchProfile(context); - } else if (value == 'logout') { - _handleLogout(); - } - }, - itemBuilder: (context) => [ - // Only show Switch Profile if multiple users available - if (userProvider.hasMultipleUsers) - PopupMenuItem( - value: 'switch_profile', - child: Row( - children: [ - Icon(Icons.people), - SizedBox(width: 8), - Text(t.discover.switchProfile), - ], + child: Focus( + onKeyEvent: _handleBackKey, + child: HubNavigationScope( + controller: _hubNavigationController, + child: CustomScrollView( + controller: _scrollController, + slivers: [ + DesktopSliverAppBar( + title: Text(t.discover.title), + floating: true, + pinned: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + scrolledUnderElevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadContent, + ), + Consumer( + builder: (context, userProvider, child) { + return PopupMenuButton( + icon: userProvider.currentUser?.thumb != null + ? UserAvatarWidget( + user: userProvider.currentUser!, + size: 32, + showIndicators: false, + ) + : const Icon(Icons.account_circle, size: 32), + onSelected: (value) { + if (value == 'switch_profile') { + _handleSwitchProfile(context); + } else if (value == 'logout') { + _handleLogout(); + } + }, + itemBuilder: (context) => [ + // Only show Switch Profile if multiple users available + if (userProvider.hasMultipleUsers) + PopupMenuItem( + value: 'switch_profile', + child: Row( + children: [ + Icon(Icons.people), + SizedBox(width: 8), + Text(t.discover.switchProfile), + ], + ), + ), + PopupMenuItem( + value: 'logout', + child: Row( + children: [ + Icon(Icons.logout), + SizedBox(width: 8), + Text(t.discover.logout), + ], + ), ), - ), - PopupMenuItem( - value: 'logout', - child: Row( - children: [ - Icon(Icons.logout), - SizedBox(width: 8), - Text(t.discover.logout), - ], - ), - ), - ], - ); - }, + ], + ); + }, + ), + ], ), + if (_isLoading) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ), + if (_errorMessage != null) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: Colors.red, + ), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadContent, + child: Text(t.common.retry), + ), + ], + ), + ), + ), + if (!_isLoading && _errorMessage == null) ...[ + // Hero Section (Continue Watching) + Consumer( + builder: (context, settingsProvider, child) { + if (_onDeck.isNotEmpty && + settingsProvider.showHeroSection) { + return _buildHeroSection(); + } + return const SliverToBoxAdapter(child: SizedBox.shrink()); + }, + ), + + // On Deck / Continue Watching + if (_onDeck.isNotEmpty) + SliverToBoxAdapter( + child: HubSection( + hub: PlexHub( + hubKey: 'continue_watching', + title: t.discover.continueWatching, + type: 'mixed', + hubIdentifier: '_continue_watching_', + size: _onDeck.length, + more: false, + items: _onDeck, + ), + icon: Icons.play_circle_outline, + onRefresh: updateItem, + onRemoveFromContinueWatching: _refreshContinueWatching, + isInContinueWatching: true, + navigationOrder: 1, // After hero + ), + ), + + // Recommendation Hubs (Trending, Top in Genre, etc.) + for (int i = 0; i < _hubs.length; i++) + SliverToBoxAdapter( + child: HubSection( + hub: _hubs[i], + icon: _getHubIcon(_hubs[i].title), + onRefresh: updateItem, + navigationOrder: 2 + i, // After continue watching + ), + ), + + if (_onDeck.isEmpty && _hubs.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.movie_outlined, + size: 64, + color: Colors.grey, + ), + SizedBox(height: 16), + Text(t.discover.noContentAvailable), + SizedBox(height: 8), + Text( + t.discover.addMediaToLibraries, + style: TextStyle(color: Colors.grey), + ), + ], + ), + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], ], ), - if (_isLoading) - const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), - ), - if (_errorMessage != null) - SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.error_outline, - size: 48, - color: Colors.red, - ), - const SizedBox(height: 16), - Text(_errorMessage!), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadContent, - child: Text(t.common.retry), - ), - ], - ), - ), - ), - if (!_isLoading && _errorMessage == null) ...[ - // Hero Section (Continue Watching) - Consumer( - builder: (context, settingsProvider, child) { - if (_onDeck.isNotEmpty && settingsProvider.showHeroSection) { - return _buildHeroSection(); - } - return const SliverToBoxAdapter(child: SizedBox.shrink()); - }, - ), - - // On Deck / Continue Watching - if (_onDeck.isNotEmpty) ...[ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), - child: Row( - children: [ - const Icon(Icons.play_circle_outline), - const SizedBox(width: 8), - Text( - t.discover.continueWatching, - style: Theme.of(context).textTheme.titleLarge, - ), - ], - ), - ), - ), - _buildHorizontalList( - _onDeck, - isLarge: false, - isInContinueWatching: true, - ), - ], - - // Recommendation Hubs (Trending, Top in Genre, etc.) - for (final hub in _hubs) - SliverToBoxAdapter( - child: HubSection( - hub: hub, - icon: _getHubIcon(hub.title), - onRefresh: updateItem, - ), - ), - - if (_onDeck.isEmpty && _hubs.isEmpty) - SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.movie_outlined, - size: 64, - color: Colors.grey, - ), - SizedBox(height: 16), - Text(t.discover.noContentAvailable), - SizedBox(height: 8), - Text( - t.discover.addMediaToLibraries, - style: TextStyle(color: Colors.grey), - ), - ], - ), - ), - ), - - const SliverToBoxAdapter(child: SizedBox(height: 24)), - ], - ], + ), ), ), ); } Widget _buildHeroSection() { - return SliverToBoxAdapter( - child: SizedBox( - height: 500, - child: Stack( - children: [ - PageView.builder( - controller: _heroController, - itemCount: _onDeck.length, - onPageChanged: (index) { - // Validate index is within bounds before updating - if (index >= 0 && index < _onDeck.length) { - setState(() { - _currentHeroIndex = index; - }); - _resetAutoScrollTimer(); - } - }, - itemBuilder: (context, index) { - return _buildHeroItem(_onDeck[index]); - }, - ), - // Page indicators with animated progress and pause/play button - Positioned( - bottom: 16, - left: -26, - right: 0, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // Pause/Play button - GestureDetector( - onTap: () { - if (_isAutoScrollPaused) { - _resumeAutoScroll(); - } else { - _pauseAutoScroll(); - } - }, - child: Icon( - _isAutoScrollPaused ? Icons.play_arrow : Icons.pause, - color: Colors.white, - size: 18, - semanticLabel: - '${_isAutoScrollPaused ? t.discover.play : t.discover.pause} auto-scroll', - ), - ), - // Spacer to separate indicators from button - const SizedBox(width: 8), - // Page indicators (limited to 5 dots) - ...() { - final range = _getVisibleDotRange(); - return List.generate(range.end - range.start + 1, (i) { - final index = range.start + i; - final isActive = _currentHeroIndex == index; - final dotSize = _getDotSize( - index, - range.start, - range.end, - ); + // Register hero section with navigation controller + // This allows pressing up from first hub to return to hero + _hubNavigationController.register( + HubSectionRegistration( + hubId: '_hero_', + itemCount: 1, + focusItem: (_) => _heroFocusNode.requestFocus(), + order: 0, // Hero is first + ), + ); - if (isActive) { - // Animated progress indicator for active page - return AnimatedBuilder( - animation: _indicatorAnimationController, - builder: (context, child) { - // Fill width animates based on dot size - final maxWidth = - dotSize * 3; // 24px for normal, 15px for small - final fillWidth = - dotSize + - ((maxWidth - dotSize) * - _indicatorAnimationController.value); - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - margin: const EdgeInsets.symmetric(horizontal: 4), - width: maxWidth, - height: dotSize, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular( - dotSize / 2, + return SliverToBoxAdapter( + child: Focus( + focusNode: _heroFocusNode, + onKeyEvent: _handleHeroKeyEvent, + child: SizedBox( + height: 500, + child: Stack( + children: [ + PageView.builder( + controller: _heroController, + itemCount: _onDeck.length, + onPageChanged: (index) { + // Validate index is within bounds before updating + if (index >= 0 && index < _onDeck.length) { + setState(() { + _currentHeroIndex = index; + }); + _resetAutoScrollTimer(); + } + }, + itemBuilder: (context, index) { + return _buildHeroItem(_onDeck[index]); + }, + ), + // Page indicators with animated progress and pause/play button + Positioned( + bottom: 16, + left: -26, + right: 0, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // Pause/Play button + GestureDetector( + onTap: () { + if (_isAutoScrollPaused) { + _resumeAutoScroll(); + } else { + _pauseAutoScroll(); + } + }, + child: Icon( + _isAutoScrollPaused ? Icons.play_arrow : Icons.pause, + color: Colors.white, + size: 18, + semanticLabel: + '${_isAutoScrollPaused ? t.discover.play : t.discover.pause} auto-scroll', + ), + ), + // Spacer to separate indicators from button + const SizedBox(width: 8), + // Page indicators (limited to 5 dots) + ...() { + final range = _getVisibleDotRange(); + return List.generate(range.end - range.start + 1, (i) { + final index = range.start + i; + final isActive = _currentHeroIndex == index; + final dotSize = _getDotSize( + index, + range.start, + range.end, + ); + + if (isActive) { + // Animated progress indicator for active page + return AnimatedBuilder( + animation: _indicatorAnimationController, + builder: (context, child) { + // Fill width animates based on dot size + final maxWidth = + dotSize * + 3; // 24px for normal, 15px for small + final fillWidth = + dotSize + + ((maxWidth - dotSize) * + _indicatorAnimationController.value); + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + margin: const EdgeInsets.symmetric( + horizontal: 4, ), - ), - child: Align( - alignment: Alignment.centerLeft, - child: Container( - width: fillWidth, - height: dotSize, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular( - dotSize / 2, + width: maxWidth, + height: dotSize, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular( + dotSize / 2, + ), + ), + child: Align( + alignment: Alignment.centerLeft, + child: Container( + width: fillWidth, + height: dotSize, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular( + dotSize / 2, + ), ), ), ), - ), - ); - }, - ); - } else { - // Static indicator for inactive pages - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - margin: const EdgeInsets.symmetric(horizontal: 4), - width: dotSize, - height: dotSize, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(dotSize / 2), - ), - ); - } - }); - }(), - ], + ); + }, + ); + } else { + // Static indicator for inactive pages + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + margin: const EdgeInsets.symmetric(horizontal: 4), + width: dotSize, + height: dotSize, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(dotSize / 2), + ), + ); + } + }); + }(), + ], + ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/screens/libraries_screen.dart b/lib/screens/libraries_screen.dart index 3d04313e..374e918b 100644 --- a/lib/screens/libraries_screen.dart +++ b/lib/screens/libraries_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:dio/dio.dart'; import '../client/plex_client.dart'; @@ -8,6 +9,7 @@ import '../models/plex_sort.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/multi_server_provider.dart'; import '../utils/app_logger.dart'; +import '../utils/keyboard_utils.dart'; import '../utils/provider_extensions.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/context_menu_wrapper.dart'; @@ -22,6 +24,7 @@ import 'library_tabs/library_browse_tab.dart'; import 'library_tabs/library_recommended_tab.dart'; import 'library_tabs/library_collections_tab.dart'; import 'library_tabs/library_playlists_tab.dart'; +import 'main_screen.dart'; class LibrariesScreen extends StatefulWidget { const LibrariesScreen({super.key}); @@ -71,11 +74,33 @@ class _LibrariesScreenState extends State int _requestId = 0; static const int _pageSize = 1000; + /// Focus node for the tab chips row (single focusable element) + late final FocusNode _tabChipsFocusNode; + + /// Focus node for the library dropdown in the app bar + late final FocusNode _libraryDropdownFocusNode; + + /// Focus node for the edit libraries button in the app bar + late final FocusNode _editButtonFocusNode; + + /// Focus node for the refresh button in the app bar + late final FocusNode _refreshButtonFocusNode; + + /// Key for the library dropdown popup menu button + final _libraryDropdownKey = GlobalKey>(); + + /// Scroll controller for the main CustomScrollView + final ScrollController _scrollController = ScrollController(); + @override void initState() { super.initState(); _tabController = TabController(length: 4, vsync: this); _tabController.addListener(_onTabChanged); + _tabChipsFocusNode = FocusNode(debugLabel: 'LibraryTabChips'); + _libraryDropdownFocusNode = FocusNode(debugLabel: 'LibraryDropdown'); + _editButtonFocusNode = FocusNode(debugLabel: 'EditLibrariesButton'); + _refreshButtonFocusNode = FocusNode(debugLabel: 'RefreshButton'); _loadLibraries(); } @@ -97,10 +122,76 @@ class _LibrariesScreenState extends State void dispose() { _tabController.removeListener(_onTabChanged); _tabController.dispose(); + _tabChipsFocusNode.dispose(); + _libraryDropdownFocusNode.dispose(); + _editButtonFocusNode.dispose(); + _refreshButtonFocusNode.dispose(); + _scrollController.dispose(); _cancelToken?.cancel(); super.dispose(); } + /// Handle back key press with two-level navigation: + /// - From content → focus tab chips + /// - From tab chips → focus bottom nav + KeyEventResult _handleBackKey(FocusNode node, KeyEvent event) { + if (isBackKeyEvent(event)) { + // Check if focus is currently on the tab chips + final isInTabs = _tabChipsFocusNode.hasFocus; + + if (isInTabs) { + // In tabs zone, go to bottom nav + BackNavigationScope.of(context)?.focusBottomNav(); + } else { + // In content zone, move to tab chips + // First scroll to top to make tab chips visible + _scrollController.animateTo( + 0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + _tabChipsFocusNode.requestFocus(); + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + + /// Focus the first item in the current tab content + /// Called when navigating to the Libraries screen from bottom nav + void focusFirstContentItem() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + // Focus the first item based on current tab + switch (_tabController.index) { + case 0: // Recommended + final recommendedState = _recommendedTabKey.currentState; + if (recommendedState != null) { + (recommendedState as dynamic).focusFirstItem(); + } + break; + case 1: // Browse + final browseState = _browseTabKey.currentState; + if (browseState != null) { + (browseState as dynamic).focusFirstItem(); + } + break; + case 2: // Collections + final collectionsState = _collectionsTabKey.currentState; + if (collectionsState != null) { + (collectionsState as dynamic).focusFirstItem(); + } + break; + case 3: // Playlists + final playlistsState = _playlistsTabKey.currentState; + if (playlistsState != null) { + (playlistsState as dynamic).focusFirstItem(); + } + break; + } + }); + } + void _updateState(VoidCallback fn) { if (!mounted) return; setState(fn); @@ -641,6 +732,9 @@ class _LibrariesScreenState extends State } void _showLibraryManagementSheet() { + // Check if opened via keyboard (edit button has focus) + final openedViaKeyboard = _editButtonFocusNode.hasFocus; + final hiddenLibrariesProvider = Provider.of( context, listen: false, @@ -661,6 +755,7 @@ class _LibrariesScreenState extends State onToggleVisibility: _toggleLibraryVisibility, getLibraryMenuItems: _getLibraryMenuItems, onLibraryMenuAction: _handleLibraryMenuAction, + autoFocusFirstHandle: openedViaKeyboard, ), ); } @@ -859,6 +954,45 @@ class _LibrariesScreenState extends State return menuItems; } + /// Handle key events for the tab chips area + KeyEventResult _handleTabChipsKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + // Left arrow: select previous tab + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + if (_tabController.index > 0) { + setState(() { + _tabController.index = _tabController.index - 1; + }); + } + return KeyEventResult.handled; + } + + // Right arrow: select next tab + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + if (_tabController.index < 3) { + setState(() { + _tabController.index = _tabController.index + 1; + }); + } + return KeyEventResult.handled; + } + + // Down arrow: focus the tab content + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + node.nextFocus(); + return KeyEventResult.handled; + } + + // Up arrow: focus the library selector in app bar + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + _libraryDropdownFocusNode.requestFocus(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + Widget _buildTabChip(String label, int index) { final isSelected = _tabController.index == index; final t = tokens(context); @@ -890,45 +1024,156 @@ class _LibrariesScreenState extends State orElse: () => visibleLibraries.first, ); - return PopupMenuButton( - offset: const Offset(0, 48), - tooltip: t.libraries.selectLibrary, - onSelected: (libraryGlobalKey) { - _loadLibraryContent(libraryGlobalKey); + return Focus( + focusNode: _libraryDropdownFocusNode, + onKeyEvent: (node, event) { + if (event is KeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.space || + event.logicalKey == LogicalKeyboardKey.select || + event.logicalKey == LogicalKeyboardKey.gameButtonA) { + _libraryDropdownKey.currentState?.showButtonMenu(); + return KeyEventResult.handled; + } + // Down arrow from dropdown goes to tab chips + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + _tabChipsFocusNode.requestFocus(); + return KeyEventResult.handled; + } + // Right arrow from dropdown goes to edit button (if libraries exist) + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + if (_allLibraries.isNotEmpty) { + _editButtonFocusNode.requestFocus(); + } else { + _refreshButtonFocusNode.requestFocus(); + } + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; }, - itemBuilder: (context) => _buildGroupedLibraryMenuItems(visibleLibraries), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(_getLibraryIcon(selectedLibrary.type), size: 20), - const SizedBox(width: 8), - if (_hasMultipleServers && selectedLibrary.serverName != null) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - selectedLibrary.title, - style: Theme.of(context).textTheme.titleMedium, - ), - Text( - selectedLibrary.serverName!, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: Theme.of( - context, - ).textTheme.bodySmall?.color?.withValues(alpha: 0.6), - ), - ), - ], - ) - else - Text( - selectedLibrary.title, - style: Theme.of(context).textTheme.titleLarge, + child: Builder( + builder: (context) { + final isFocused = Focus.of(context).hasFocus; + return PopupMenuButton( + key: _libraryDropdownKey, + offset: const Offset(0, 48), + tooltip: t.libraries.selectLibrary, + onSelected: (libraryGlobalKey) { + _loadLibraryContent(libraryGlobalKey); + }, + itemBuilder: (context) => + _buildGroupedLibraryMenuItems(visibleLibraries), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: isFocused + ? BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + ) + : null, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(_getLibraryIcon(selectedLibrary.type), size: 20), + const SizedBox(width: 8), + if (_hasMultipleServers && selectedLibrary.serverName != null) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + selectedLibrary.title, + style: Theme.of(context).textTheme.titleMedium, + ), + Text( + selectedLibrary.serverName!, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: Theme.of(context) + .textTheme + .bodySmall + ?.color + ?.withValues(alpha: 0.6), + ), + ), + ], + ) + else + Text( + selectedLibrary.title, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(width: 4), + const Icon(Icons.arrow_drop_down, size: 24), + ], + ), ), - const SizedBox(width: 4), - const Icon(Icons.arrow_drop_down, size: 24), - ], + ); + }, + ), + ); + } + + /// Build a focusable icon button with keyboard navigation support + Widget _buildFocusableIconButton({ + required FocusNode focusNode, + required IconData icon, + required String semanticLabel, + required VoidCallback onPressed, + VoidCallback? onLeft, + VoidCallback? onRight, + VoidCallback? onDown, + }) { + return Focus( + focusNode: focusNode, + onKeyEvent: (node, event) { + if (event is KeyDownEvent) { + // Activation keys + if (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.space || + event.logicalKey == LogicalKeyboardKey.select || + event.logicalKey == LogicalKeyboardKey.gameButtonA) { + onPressed(); + return KeyEventResult.handled; + } + // Navigation + if (event.logicalKey == LogicalKeyboardKey.arrowLeft && + onLeft != null) { + onLeft(); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowRight && + onRight != null) { + onRight(); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowDown && + onDown != null) { + onDown(); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + }, + child: Builder( + builder: (context) { + final isFocused = Focus.of(context).hasFocus; + return IconButton( + icon: Icon(icon, semanticLabel: semanticLabel), + onPressed: onPressed, + style: isFocused + ? IconButton.styleFrom( + backgroundColor: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.08), + ) + : null, + ); + }, ), ); } @@ -945,138 +1190,161 @@ class _LibrariesScreenState extends State .toList(); return Scaffold( - body: CustomScrollView( - slivers: [ - DesktopSliverAppBar( - title: - visibleLibraries.isNotEmpty && _selectedLibraryGlobalKey != null - ? _buildLibraryDropdownTitle(visibleLibraries) - : Text(t.libraries.title), - floating: true, - pinned: true, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - surfaceTintColor: Colors.transparent, - shadowColor: Colors.transparent, - scrolledUnderElevation: 0, - actions: [ - if (_allLibraries.isNotEmpty) - IconButton( - icon: Icon( - Icons.edit, + body: Focus( + onKeyEvent: _handleBackKey, + child: CustomScrollView( + controller: _scrollController, + slivers: [ + DesktopSliverAppBar( + title: + visibleLibraries.isNotEmpty && + _selectedLibraryGlobalKey != null + ? _buildLibraryDropdownTitle(visibleLibraries) + : Text(t.libraries.title), + floating: true, + pinned: true, + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + scrolledUnderElevation: 0, + actions: [ + if (_allLibraries.isNotEmpty) + _buildFocusableIconButton( + focusNode: _editButtonFocusNode, + icon: Icons.edit, semanticLabel: t.libraries.manageLibraries, + onPressed: _showLibraryManagementSheet, + onLeft: () => _libraryDropdownFocusNode.requestFocus(), + onRight: () => _refreshButtonFocusNode.requestFocus(), + onDown: () => _tabChipsFocusNode.requestFocus(), ), - onPressed: _showLibraryManagementSheet, + _buildFocusableIconButton( + focusNode: _refreshButtonFocusNode, + icon: Icons.refresh, + semanticLabel: t.common.refresh, + onPressed: _refreshCurrentTab, + onLeft: () { + if (_allLibraries.isNotEmpty) { + _editButtonFocusNode.requestFocus(); + } else { + _libraryDropdownFocusNode.requestFocus(); + } + }, + onDown: () => _tabChipsFocusNode.requestFocus(), ), - IconButton( - icon: Icon(Icons.refresh, semanticLabel: t.common.refresh), - onPressed: _refreshCurrentTab, - ), - ], - ), - if (_isLoadingLibraries) - const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), - ) - else if (_errorMessage != null && visibleLibraries.isEmpty) - SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.error_outline, - size: 48, - color: Colors.red, - ), - const SizedBox(height: 16), - Text(_errorMessage!), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadLibraries, - child: Text(t.common.retry), - ), - ], - ), - ), - ) - else if (visibleLibraries.isEmpty) - SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon( - Icons.video_library_outlined, - size: 64, - color: Colors.grey, - ), - const SizedBox(height: 16), - Text(t.libraries.noLibrariesFound), - ], - ), - ), - ) - else ...[ - // Tab selector chips - if (_selectedLibraryGlobalKey != null) - SliverToBoxAdapter( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - _buildTabChip(t.libraries.tabs.recommended, 0), - const SizedBox(width: 8), - _buildTabChip(t.libraries.tabs.browse, 1), - const SizedBox(width: 8), - _buildTabChip(t.libraries.tabs.collections, 2), - const SizedBox(width: 8), - _buildTabChip(t.libraries.tabs.playlists, 3), - ], - ), - ), - ), - ), - - // Tab content - if (_selectedLibraryGlobalKey != null) + ], + ), + if (_isLoadingLibraries) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_errorMessage != null && visibleLibraries.isEmpty) SliverFillRemaining( - child: TabBarView( - controller: _tabController, - children: [ - LibraryRecommendedTab( - key: _recommendedTabKey, - library: _allLibraries.firstWhere( - (lib) => lib.globalKey == _selectedLibraryGlobalKey, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.error_outline, + size: 48, + color: Colors.red, ), - ), - LibraryBrowseTab( - key: _browseTabKey, - library: _allLibraries.firstWhere( - (lib) => lib.globalKey == _selectedLibraryGlobalKey, + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadLibraries, + child: Text(t.common.retry), ), - ), - LibraryCollectionsTab( - key: _collectionsTabKey, - library: _allLibraries.firstWhere( - (lib) => lib.globalKey == _selectedLibraryGlobalKey, - ), - ), - LibraryPlaylistsTab( - key: _playlistsTabKey, - library: _allLibraries.firstWhere( - (lib) => lib.globalKey == _selectedLibraryGlobalKey, - ), - ), - ], + ], + ), ), - ), + ) + else if (visibleLibraries.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.video_library_outlined, + size: 64, + color: Colors.grey, + ), + const SizedBox(height: 16), + Text(t.libraries.noLibrariesFound), + ], + ), + ), + ) + else ...[ + // Tab selector chips + if (_selectedLibraryGlobalKey != null) + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Focus( + focusNode: _tabChipsFocusNode, + onKeyEvent: _handleTabChipsKeyEvent, + child: ExcludeFocus( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildTabChip(t.libraries.tabs.recommended, 0), + const SizedBox(width: 8), + _buildTabChip(t.libraries.tabs.browse, 1), + const SizedBox(width: 8), + _buildTabChip(t.libraries.tabs.collections, 2), + const SizedBox(width: 8), + _buildTabChip(t.libraries.tabs.playlists, 3), + ], + ), + ), + ), + ), + ), + ), + + // Tab content + if (_selectedLibraryGlobalKey != null) + SliverFillRemaining( + child: TabBarView( + controller: _tabController, + children: [ + LibraryRecommendedTab( + key: _recommendedTabKey, + library: _allLibraries.firstWhere( + (lib) => lib.globalKey == _selectedLibraryGlobalKey, + ), + ), + LibraryBrowseTab( + key: _browseTabKey, + library: _allLibraries.firstWhere( + (lib) => lib.globalKey == _selectedLibraryGlobalKey, + ), + ), + LibraryCollectionsTab( + key: _collectionsTabKey, + library: _allLibraries.firstWhere( + (lib) => lib.globalKey == _selectedLibraryGlobalKey, + ), + ), + LibraryPlaylistsTab( + key: _playlistsTabKey, + library: _allLibraries.firstWhere( + (lib) => lib.globalKey == _selectedLibraryGlobalKey, + ), + ), + ], + ), + ), + ], ], - ], + ), ), ); } @@ -1104,6 +1372,7 @@ class _LibraryManagementSheet extends StatefulWidget { final Function(PlexLibrary) onToggleVisibility; final List Function(PlexLibrary) getLibraryMenuItems; final void Function(String action, PlexLibrary library) onLibraryMenuAction; + final bool autoFocusFirstHandle; const _LibraryManagementSheet({ required this.allLibraries, @@ -1112,6 +1381,7 @@ class _LibraryManagementSheet extends StatefulWidget { required this.onToggleVisibility, required this.getLibraryMenuItems, required this.onLibraryMenuAction, + this.autoFocusFirstHandle = false, }); @override @@ -1123,11 +1393,94 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { late List _tempLibraries; List? _serverOrder; + /// Index of library currently being moved via keyboard (null if not moving) + int? _movingLibraryIndex; + + /// Original index of library when move started (for cancel/restore) + int? _originalLibraryIndex; + + /// Index of server currently being moved via keyboard (null if not moving) + int? _movingServerIndex; + + /// Original index of server when move started (for cancel/restore) + int? _originalServerIndex; + + /// Focus nodes for library drag handles, keyed by library globalKey + final Map _libraryDragFocusNodes = {}; + + /// Focus nodes for server drag handles, keyed by serverId + final Map _serverDragFocusNodes = {}; + + /// Get or create a focus node for a library drag handle + FocusNode _getLibraryDragFocusNode(String globalKey) { + return _libraryDragFocusNodes.putIfAbsent( + globalKey, + () => FocusNode(debugLabel: 'LibraryDrag-$globalKey'), + ); + } + + /// Get or create a focus node for a server drag handle + FocusNode _getServerDragFocusNode(String serverId) { + return _serverDragFocusNodes.putIfAbsent( + serverId, + () => FocusNode(debugLabel: 'ServerDrag-$serverId'), + ); + } + @override void initState() { super.initState(); _tempLibraries = List.from(widget.allLibraries); _loadServerOrder(); + + // Focus the first drag handle after the sheet is built (only if opened via keyboard) + if (widget.autoFocusFirstHandle) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusFirstDragHandle(); + }); + } + } + + @override + void dispose() { + // Dispose all focus nodes + for (final node in _libraryDragFocusNodes.values) { + node.dispose(); + } + for (final node in _serverDragFocusNodes.values) { + node.dispose(); + } + super.dispose(); + } + + /// Focus the first drag handle in the list + void _focusFirstDragHandle() { + if (_hasMultipleServers) { + // Focus first server drag handle + final serverKeys = _getOrderedServerIds(); + if (serverKeys.isNotEmpty) { + _getServerDragFocusNode(serverKeys.first).requestFocus(); + } + } else { + // Focus first library drag handle + if (_tempLibraries.isNotEmpty) { + _getLibraryDragFocusNode(_tempLibraries.first.globalKey).requestFocus(); + } + } + } + + /// Handle back key to close the sheet + KeyEventResult _handleBackKey(FocusNode node, KeyEvent event) { + // Don't close if we're in the middle of moving an item + if (_movingLibraryIndex != null || _movingServerIndex != null) { + return KeyEventResult.ignored; + } + + if (isBackKeyEvent(event)) { + Navigator.pop(context); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; } /// Load server order from storage @@ -1194,6 +1547,257 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { return uniqueServerIds.length > 1; } + /// Start moving a library via keyboard + void _startLibraryMove(int index) { + setState(() { + _movingLibraryIndex = index; + _originalLibraryIndex = index; + }); + } + + /// Move library to new position during keyboard reordering + void _moveLibraryTo(int newIndex) { + if (_movingLibraryIndex == null) return; + if (newIndex < 0 || newIndex >= _tempLibraries.length) return; + if (newIndex == _movingLibraryIndex) return; + + setState(() { + final library = _tempLibraries.removeAt(_movingLibraryIndex!); + _tempLibraries.insert(newIndex, library); + _movingLibraryIndex = newIndex; + }); + } + + /// End library move and save the new order + void _endLibraryMove() { + if (_movingLibraryIndex == null) return; + widget.onReorder(_tempLibraries); + setState(() { + _movingLibraryIndex = null; + _originalLibraryIndex = null; + }); + } + + /// Cancel library move and restore original position + void _cancelLibraryMove() { + if (_movingLibraryIndex == null || _originalLibraryIndex == null) return; + if (_movingLibraryIndex != _originalLibraryIndex) { + setState(() { + final library = _tempLibraries.removeAt(_movingLibraryIndex!); + _tempLibraries.insert(_originalLibraryIndex!, library); + }); + } + setState(() { + _movingLibraryIndex = null; + _originalLibraryIndex = null; + }); + } + + /// Start moving a server via keyboard + void _startServerMove(int index) { + setState(() { + _movingServerIndex = index; + _originalServerIndex = index; + }); + } + + /// Move server to new position during keyboard reordering + void _moveServerTo(int newIndex, List serverKeys) { + if (_movingServerIndex == null) return; + if (newIndex < 0 || newIndex >= serverKeys.length) return; + if (newIndex == _movingServerIndex) return; + + final reorderedServerIds = List.from(serverKeys); + final serverId = reorderedServerIds.removeAt(_movingServerIndex!); + reorderedServerIds.insert(newIndex, serverId); + _saveServerOrder(reorderedServerIds); + + setState(() { + _movingServerIndex = newIndex; + }); + } + + /// End server move + void _endServerMove() { + setState(() { + _movingServerIndex = null; + _originalServerIndex = null; + }); + } + + /// Cancel server move and restore original position + void _cancelServerMove(List serverKeys) { + if (_movingServerIndex == null || _originalServerIndex == null) return; + if (_movingServerIndex != _originalServerIndex) { + final reorderedServerIds = List.from(serverKeys); + final serverId = reorderedServerIds.removeAt(_movingServerIndex!); + reorderedServerIds.insert(_originalServerIndex!, serverId); + _saveServerOrder(reorderedServerIds); + } + setState(() { + _movingServerIndex = null; + _originalServerIndex = null; + }); + } + + /// Build a keyboard-accessible drag handle + /// When [reorderableIndex] is provided, wraps with ReorderableDragStartListener for mouse/touch drag + Widget _buildKeyboardDragHandle({ + required FocusNode focusNode, + required int index, + required bool isMoving, + required int maxIndex, + required VoidCallback onStartMove, + required void Function(int delta) onMove, + required VoidCallback onEndMove, + required VoidCallback onCancelMove, + int? reorderableIndex, + }) { + Widget handle = Focus( + focusNode: focusNode, + onKeyEvent: (node, event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + if (isMoving) { + // Moving mode - handle arrow keys and confirm/cancel + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + if (index > 0) onMove(-1); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + if (index < maxIndex) onMove(1); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.space || + event.logicalKey == LogicalKeyboardKey.select || + event.logicalKey == LogicalKeyboardKey.gameButtonA) { + onEndMove(); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.escape || + event.logicalKey == LogicalKeyboardKey.gameButtonB) { + onCancelMove(); + return KeyEventResult.handled; + } + } else { + // Not moving - Enter/Space starts move + if (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.space || + event.logicalKey == LogicalKeyboardKey.select || + event.logicalKey == LogicalKeyboardKey.gameButtonA) { + onStartMove(); + return KeyEventResult.handled; + } + // Allow left/right navigation to other focusable items + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + focusNode.nextFocus(); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + focusNode.previousFocus(); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + }, + child: Builder( + builder: (context) { + final isFocused = Focus.of(context).hasFocus; + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: isMoving + ? Theme.of(context).colorScheme.primaryContainer + : isFocused + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.2) + : null, + ), + child: Icon( + isMoving ? Icons.unfold_more : Icons.drag_indicator, + color: isMoving + ? Theme.of(context).colorScheme.onPrimaryContainer + : isFocused + ? Theme.of(context).colorScheme.primary + : IconTheme.of(context).color?.withValues(alpha: 0.5), + ), + ); + }, + ), + ); + + // Wrap with ReorderableDragStartListener for mouse/touch drag support + if (reorderableIndex != null) { + handle = ReorderableDragStartListener( + index: reorderableIndex, + child: handle, + ); + } + + return handle; + } + + /// Build an IconButton with left/right arrow key navigation + Widget _buildNavigableIconButton({ + required IconData iconData, + required VoidCallback onPressed, + String? tooltip, + }) { + return Focus( + onKeyEvent: (node, event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + // Handle activation + if (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.space || + event.logicalKey == LogicalKeyboardKey.select || + event.logicalKey == LogicalKeyboardKey.gameButtonA) { + onPressed(); + return KeyEventResult.handled; + } + + if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + node.previousFocus(); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + node.nextFocus(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Builder( + builder: (context) { + final isFocused = Focus.of(context).hasFocus; + return GestureDetector( + onTap: onPressed, + child: Tooltip( + message: tooltip ?? '', + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: isFocused + ? Theme.of( + context, + ).colorScheme.primary.withValues(alpha: 0.2) + : null, + ), + child: Icon( + iconData, + color: isFocused + ? Theme.of(context).colorScheme.primary + : IconTheme.of(context).color, + ), + ), + ), + ); + }, + ), + ); + } + void _reorderLibraries(int oldIndex, int newIndex) { setState(() { if (newIndex > oldIndex) { @@ -1207,34 +1811,45 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { } Future _showLibraryMenuBottomSheet( - BuildContext context, + BuildContext outerContext, PlexLibrary library, ) async { final menuItems = widget.getLibraryMenuItems(library); final selected = await showModalBottomSheet( - context: context, - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Text( - library.title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, + context: outerContext, + builder: (context) => Focus( + autofocus: true, + onKeyEvent: (node, event) { + if (isBackKeyEvent(event)) { + Navigator.pop(context); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Text( + library.title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), ), ), - ), - ...menuItems.map( - (item) => ListTile( - leading: Icon(item.icon), - title: Text(item.label), - onTap: () => Navigator.pop(context, item.value), + ...menuItems.indexed.map( + (entry) => ListTile( + autofocus: entry.$1 == 0, + leading: Icon(entry.$2.icon), + title: Text(entry.$2.label), + onTap: () => Navigator.pop(context, entry.$2.value), + ), ), - ), - ], + ], + ), ), ), ); @@ -1307,47 +1922,53 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { maxChildSize: 0.95, expand: false, builder: (context, scrollController) { - return Column( - children: [ - // Header - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: Theme.of(context).dividerColor), + return Focus( + onKeyEvent: _handleBackKey, + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide(color: Theme.of(context).dividerColor), + ), ), - ), - child: Row( - children: [ - const Icon(Icons.edit), - const SizedBox(width: 12), - Expanded( - child: Text( - t.libraries.manageLibraries, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, + child: Row( + children: [ + const Icon(Icons.edit), + const SizedBox(width: 12), + Expanded( + child: Text( + t.libraries.manageLibraries, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), ), ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), ), - ), - // Library list (grouped by server if multiple servers) - Expanded( - child: _hasMultipleServers - ? _buildGroupedLibraryList( - scrollController, - hiddenLibraryKeys, - ) - : _buildFlatLibraryList(scrollController, hiddenLibraryKeys), - ), - ], + // Library list (grouped by server if multiple servers) + Expanded( + child: _hasMultipleServers + ? _buildGroupedLibraryList( + scrollController, + hiddenLibraryKeys, + ) + : _buildFlatLibraryList( + scrollController, + hiddenLibraryKeys, + ), + ), + ], + ), ); }, ); @@ -1406,28 +2027,40 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { final libraries = groupedLibraries[serverKey]!; final serverName = libraries.first.serverName ?? 'Unknown Server'; + final isServerMoving = _movingServerIndex == serverIndex; + return Column( key: ValueKey(serverKey), crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - // Server header with drag handle - ListTile( - leading: ReorderableDragStartListener( - index: serverIndex, - child: Icon( - Icons.drag_indicator, - color: Theme.of( - context, - ).textTheme.bodyMedium?.color?.withValues(alpha: 0.5), + // Server header with keyboard-accessible drag handle + Container( + color: isServerMoving + ? Theme.of( + context, + ).colorScheme.primaryContainer.withValues(alpha: 0.3) + : null, + child: ListTile( + leading: _buildKeyboardDragHandle( + focusNode: _getServerDragFocusNode(serverKey), + index: serverIndex, + isMoving: isServerMoving, + maxIndex: serverKeys.length - 1, + onStartMove: () => _startServerMove(serverIndex), + onMove: (delta) => + _moveServerTo(serverIndex + delta, serverKeys), + onEndMove: _endServerMove, + onCancelMove: () => _cancelServerMove(serverKeys), + reorderableIndex: serverIndex, ), - ), - title: Text( - serverName, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.primary, + title: Text( + serverName, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Theme.of(context).colorScheme.primary, + ), ), ), ), @@ -1459,57 +2092,64 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { bool enableDrag = true, }) { final isHidden = hiddenLibraryKeys.contains(library.globalKey); + final isMoving = _movingLibraryIndex == index; return Opacity( key: ValueKey(library.globalKey), opacity: isHidden ? 0.5 : 1.0, - child: ListTile( - leading: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (enableDrag) - ReorderableDragStartListener( - index: index, - child: Padding( - padding: const EdgeInsets.only(right: 12), - child: Icon( - Icons.drag_indicator, - color: Theme.of( - context, - ).textTheme.bodyMedium?.color?.withValues(alpha: 0.5), - ), + child: Container( + color: isMoving + ? Theme.of( + context, + ).colorScheme.primaryContainer.withValues(alpha: 0.3) + : null, + child: ListTile( + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (enableDrag) + _buildKeyboardDragHandle( + focusNode: _getLibraryDragFocusNode(library.globalKey), + index: index, + isMoving: isMoving, + maxIndex: _tempLibraries.length - 1, + onStartMove: () => _startLibraryMove(index), + onMove: (delta) => _moveLibraryTo(index + delta), + onEndMove: _endLibraryMove, + onCancelMove: _cancelLibraryMove, + reorderableIndex: index, ), + if (enableDrag) const SizedBox(width: 8), + if (!enableDrag) const SizedBox(width: 12), + Icon(_getLibraryIcon(library.type)), + ], + ), + title: Row( + children: [ + Expanded(child: Text(library.title)), + if (showServerBadge && + _hasMultipleServers && + library.serverName != null) + ServerBadge(serverName: library.serverName, showFullName: true), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _buildNavigableIconButton( + iconData: isHidden ? Icons.visibility_off : Icons.visibility, + onPressed: () => widget.onToggleVisibility(library), + tooltip: isHidden + ? t.libraries.showLibrary + : t.libraries.hideLibrary, ), - if (enableDrag) const SizedBox(width: 8), - if (!enableDrag) const SizedBox(width: 12), - Icon(_getLibraryIcon(library.type)), - ], - ), - title: Row( - children: [ - Expanded(child: Text(library.title)), - if (showServerBadge && - _hasMultipleServers && - library.serverName != null) - ServerBadge(serverName: library.serverName, showFullName: true), - ], - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon(isHidden ? Icons.visibility_off : Icons.visibility), - onPressed: () => widget.onToggleVisibility(library), - tooltip: isHidden - ? t.libraries.showLibrary - : t.libraries.hideLibrary, - ), - IconButton( - icon: const Icon(Icons.more_vert), - onPressed: () => _showLibraryMenuBottomSheet(context, library), - tooltip: t.libraries.libraryOptions, - ), - ], + _buildNavigableIconButton( + iconData: Icons.more_vert, + onPressed: () => _showLibraryMenuBottomSheet(context, library), + tooltip: t.libraries.libraryOptions, + ), + ], + ), ), ), ); diff --git a/lib/screens/library_tabs/base_library_tab.dart b/lib/screens/library_tabs/base_library_tab.dart index afdbfd32..caaa9cf0 100644 --- a/lib/screens/library_tabs/base_library_tab.dart +++ b/lib/screens/library_tabs/base_library_tab.dart @@ -139,6 +139,19 @@ abstract class BaseLibraryTabState> } } + /// Focus the first item in the tab content + /// Subclasses can override this for custom focus behavior + void focusFirstItem() { + // Default implementation: try to focus the first focusable item + if (_items.isNotEmpty && mounted) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + FocusScope.of(context).nextFocus(); + } + }); + } + } + @override Widget build(BuildContext context) { super.build(context); // Required for AutomaticKeepAliveClientMixin diff --git a/lib/screens/library_tabs/library_browse_tab.dart b/lib/screens/library_tabs/library_browse_tab.dart index 04976c17..b1a0b373 100644 --- a/lib/screens/library_tabs/library_browse_tab.dart +++ b/lib/screens/library_tabs/library_browse_tab.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:dio/dio.dart'; import '../../client/plex_client.dart'; @@ -9,6 +10,7 @@ import '../../models/plex_sort.dart'; import '../../providers/settings_provider.dart'; import '../../utils/error_message_utils.dart'; import '../../utils/grid_size_calculator.dart'; +import '../../utils/keyboard_utils.dart'; import '../../widgets/media_card.dart'; import '../../widgets/folder_tree_view.dart'; import '../../widgets/filters_bottom_sheet.dart'; @@ -87,6 +89,11 @@ class _LibraryBrowseTabState extends State int _requestId = 0; static const int _pageSize = 500; + /// Focus node for the first item in the list/grid + final FocusNode _firstItemFocusNode = FocusNode( + debugLabel: 'BrowseFirstItem', + ); + @override void initState() { super.initState(); @@ -105,9 +112,17 @@ class _LibraryBrowseTabState extends State @override void dispose() { _cancelToken?.cancel(); + _firstItemFocusNode.dispose(); super.dispose(); } + /// Focus the first item in the list/grid + void focusFirstItem() { + if (_items.isNotEmpty) { + _firstItemFocusNode.requestFocus(); + } + } + Future _loadContent() async { // Cancel any pending request _cancelToken?.cancel(); @@ -312,36 +327,50 @@ class _LibraryBrowseTabState extends State void _showGroupingBottomSheet() { showModalBottomSheet( context: context, - builder: (context) { - return ListView( - shrinkWrap: true, - children: _getGroupingOptions().map((grouping) { - return RadioListTile( - title: Text(_getGroupingLabel(grouping)), - value: grouping, - // ignore: deprecated_member_use - groupValue: _selectedGrouping, - // ignore: deprecated_member_use - onChanged: (value) async { - if (value != null) { - setState(() { - _selectedGrouping = value; - }); + builder: (sheetContext) { + final options = _getGroupingOptions(); + return Focus( + autofocus: true, + onKeyEvent: (node, event) { + if (isBackKeyEvent(event)) { + Navigator.pop(sheetContext); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: FocusTraversalGroup( + child: ListView.builder( + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (context, index) { + final grouping = options[index]; + return RadioListTile( + autofocus: index == 0, + title: Text(_getGroupingLabel(grouping)), + value: grouping, + groupValue: _selectedGrouping, + onChanged: (value) async { + if (value != null) { + setState(() { + _selectedGrouping = value; + }); - final storage = await StorageService.getInstance(); - await storage.saveLibraryGrouping( - widget.library.globalKey, - value, - ); + final storage = await StorageService.getInstance(); + await storage.saveLibraryGrouping( + widget.library.globalKey, + value, + ); - if (!context.mounted) return; + if (!sheetContext.mounted) return; - Navigator.pop(context); - _loadItems(); - } + Navigator.pop(sheetContext); + _loadItems(); + } + }, + ); }, - ); - }).toList(), + ), + ), ); }, ); @@ -407,32 +436,55 @@ class _LibraryBrowseTabState extends State required String label, required VoidCallback onPressed, }) { - return InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(20), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: 16, - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - const SizedBox(width: 6), - Text( - label, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, + return Focus( + onKeyEvent: (node, event) { + if (event is KeyDownEvent) { + if (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.space || + event.logicalKey == LogicalKeyboardKey.select || + event.logicalKey == LogicalKeyboardKey.gameButtonA) { + onPressed(); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + }, + child: Builder( + builder: (context) { + final isFocused = Focus.of(context).hasFocus; + final colorScheme = Theme.of(context).colorScheme; + // When focused: inverse colors (white bg + dark text in dark mode, dark bg + light text in light mode) + final backgroundColor = isFocused + ? colorScheme.onSurface + : colorScheme.surfaceContainerHighest; + final foregroundColor = isFocused + ? colorScheme.surface + : colorScheme.onSurfaceVariant; + return InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: foregroundColor), + const SizedBox(width: 6), + Text( + label, + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(color: foregroundColor), + ), + ], ), ), - ], - ), + ); + }, ), ); } @@ -533,48 +585,58 @@ class _LibraryBrowseTabState extends State child: Consumer( builder: (context, settingsProvider, child) { if (settingsProvider.viewMode == ViewMode.list) { - return ListView.builder( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), - itemCount: _items.length + (_hasMoreItems && _isLoading ? 1 : 0), - itemBuilder: (context, index) { - if (index >= _items.length) { - return const Padding( - padding: EdgeInsets.all(16.0), - child: Center(child: CircularProgressIndicator()), + return ClipRect( + child: ListView.builder( + clipBehavior: Clip.none, // Allow focus indicator to overflow + padding: const EdgeInsets.all(8), + itemCount: + _items.length + (_hasMoreItems && _isLoading ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _items.length) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: Center(child: CircularProgressIndicator()), + ); + } + final item = _items[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onRefresh: updateItem, + focusNode: index == 0 ? _firstItemFocusNode : null, ); - } - final item = _items[index]; - return MediaCard( - key: Key(item.ratingKey), - item: item, - onRefresh: updateItem, - ); - }, + }, + ), ); } else { - return GridView.builder( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( - context, - settingsProvider.libraryDensity, + return ClipRect( + child: GridView.builder( + clipBehavior: Clip.none, // Allow focus indicator to overflow + padding: const EdgeInsets.all(8), + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, + ), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 0, + mainAxisSpacing: 0, ), - childAspectRatio: 2 / 3.3, - crossAxisSpacing: 0, - mainAxisSpacing: 0, + itemCount: + _items.length + (_hasMoreItems && _isLoading ? 1 : 0), + itemBuilder: (context, index) { + if (index >= _items.length) { + return const Center(child: CircularProgressIndicator()); + } + final item = _items[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onRefresh: updateItem, + focusNode: index == 0 ? _firstItemFocusNode : null, + ); + }, ), - itemCount: _items.length + (_hasMoreItems && _isLoading ? 1 : 0), - itemBuilder: (context, index) { - if (index >= _items.length) { - return const Center(child: CircularProgressIndicator()); - } - final item = _items[index]; - return MediaCard( - key: Key(item.ratingKey), - item: item, - onRefresh: updateItem, - ); - }, ); } }, diff --git a/lib/screens/library_tabs/library_collections_tab.dart b/lib/screens/library_tabs/library_collections_tab.dart index cf9d9254..498ca7ea 100644 --- a/lib/screens/library_tabs/library_collections_tab.dart +++ b/lib/screens/library_tabs/library_collections_tab.dart @@ -21,6 +21,17 @@ class LibraryCollectionsTab extends BaseLibraryTab { class _LibraryCollectionsTabState extends BaseLibraryTabState { + /// Focus node for the first item in the grid + final FocusNode _firstItemFocusNode = FocusNode( + debugLabel: 'CollectionsFirstItem', + ); + + @override + void dispose() { + _firstItemFocusNode.dispose(); + super.dispose(); + } + @override IconData get emptyIcon => Icons.collections; @@ -43,8 +54,19 @@ class _LibraryCollectionsTabState return await client.getLibraryCollections(widget.library.key); } + @override + void focusFirstItem() { + if (items.isNotEmpty) { + _firstItemFocusNode.requestFocus(); + } + } + @override Widget buildContent(List items) { - return AdaptiveMediaGrid(items: items, onRefresh: loadItems); + return AdaptiveMediaGrid( + items: items, + onRefresh: loadItems, + firstItemFocusNode: _firstItemFocusNode, + ); } } diff --git a/lib/screens/library_tabs/library_playlists_tab.dart b/lib/screens/library_tabs/library_playlists_tab.dart index 003f9de9..eb8ffc6d 100644 --- a/lib/screens/library_tabs/library_playlists_tab.dart +++ b/lib/screens/library_tabs/library_playlists_tab.dart @@ -25,6 +25,17 @@ class LibraryPlaylistsTab extends BaseLibraryTab { class _LibraryPlaylistsTabState extends BaseLibraryTabState { + /// Focus node for the first item in the grid + final FocusNode _firstItemFocusNode = FocusNode( + debugLabel: 'PlaylistsFirstItem', + ); + + @override + void dispose() { + _firstItemFocusNode.dispose(); + super.dispose(); + } + @override IconData get emptyIcon => Icons.playlist_play; @@ -49,6 +60,13 @@ class _LibraryPlaylistsTabState ); } + @override + void focusFirstItem() { + if (items.isNotEmpty) { + _firstItemFocusNode.requestFocus(); + } + } + @override Widget buildContent(List items) { return Consumer( @@ -63,6 +81,7 @@ class _LibraryPlaylistsTabState key: Key(playlist.ratingKey), item: playlist, onListRefresh: loadItems, + focusNode: index == 0 ? _firstItemFocusNode : null, ); }, ); @@ -85,6 +104,7 @@ class _LibraryPlaylistsTabState key: Key(playlist.ratingKey), item: playlist, onListRefresh: loadItems, + focusNode: index == 0 ? _firstItemFocusNode : null, ); }, ); diff --git a/lib/screens/library_tabs/library_recommended_tab.dart b/lib/screens/library_tabs/library_recommended_tab.dart index 9121b4a6..65b594d6 100644 --- a/lib/screens/library_tabs/library_recommended_tab.dart +++ b/lib/screens/library_tabs/library_recommended_tab.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../models/plex_hub.dart'; import '../../widgets/hub_section.dart'; +import '../../widgets/hub_navigation_controller.dart'; import '../../i18n/strings.g.dart'; import 'base_library_tab.dart'; @@ -15,6 +16,20 @@ class LibraryRecommendedTab extends BaseLibraryTab { class _LibraryRecommendedTabState extends BaseLibraryTabState { + final HubNavigationController _hubNavigationController = + HubNavigationController(); + + @override + void dispose() { + _hubNavigationController.dispose(); + super.dispose(); + } + + /// Focus the first item in the first hub + void focusFirstItem() { + _hubNavigationController.focusHub(0, 0); + } + @override IconData get emptyIcon => Icons.recommend; @@ -35,13 +50,20 @@ class _LibraryRecommendedTabState @override Widget buildContent(List items) { - return ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: items.length, - itemBuilder: (context, index) { - final hub = items[index]; - return HubSection(hub: hub, icon: _getHubIcon(hub)); - }, + return HubNavigationScope( + controller: _hubNavigationController, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: items.length, + itemBuilder: (context, index) { + final hub = items[index]; + return HubSection( + hub: hub, + icon: _getHubIcon(hub), + navigationOrder: index, + ); + }, + ), ); } diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 19e75bef..5db05afa 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../client/plex_client.dart'; import '../i18n/strings.g.dart'; import '../utils/app_logger.dart'; +import '../utils/keyboard_utils.dart'; import '../utils/provider_extensions.dart'; import '../main.dart'; import '../mixins/refreshable.dart'; @@ -17,6 +19,30 @@ import 'libraries_screen.dart'; import 'search_screen.dart'; import 'settings_screen.dart'; +/// InheritedWidget that provides back navigation functionality to child screens +class BackNavigationScope extends InheritedWidget { + final VoidCallback focusBottomNav; + + const BackNavigationScope({ + super.key, + required this.focusBottomNav, + required super.child, + }); + + static BackNavigationScope? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + static BackNavigationScope? maybeOf(BuildContext context) { + return context.getInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(BackNavigationScope oldWidget) { + return focusBottomNav != oldWidget.focusBottomNav; + } +} + class MainScreen extends StatefulWidget { final PlexClient client; @@ -35,9 +61,18 @@ class _MainScreenState extends State with RouteAware { final GlobalKey> _searchKey = GlobalKey(); final GlobalKey> _settingsKey = GlobalKey(); + /// Focus scope node for the bottom navigation bar + /// Using FocusScopeNode so requestFocus() focuses the first child + late final FocusScopeNode _bottomNavFocusScopeNode; + + /// Focus scope node for the main content area + late final FocusScopeNode _contentFocusScopeNode; + @override void initState() { super.initState(); + _bottomNavFocusScopeNode = FocusScopeNode(debugLabel: 'BottomNavigation'); + _contentFocusScopeNode = FocusScopeNode(debugLabel: 'MainContent'); _screens = [ DiscoverScreen( @@ -69,9 +104,38 @@ class _MainScreenState extends State with RouteAware { @override void dispose() { routeObserver.unsubscribe(this); + _bottomNavFocusScopeNode.dispose(); + _contentFocusScopeNode.dispose(); super.dispose(); } + /// Focus the bottom navigation bar (called by child screens on back press) + void _focusBottomNav() { + // Request focus on the scope, then navigate to the currently selected tab + _bottomNavFocusScopeNode.requestFocus(); + WidgetsBinding.instance.addPostFrameCallback((_) { + // Move to first item, then advance to current index + _bottomNavFocusScopeNode.nextFocus(); + for (int i = 0; i < _currentIndex; i++) { + _bottomNavFocusScopeNode.nextFocus(); + } + }); + } + + /// Focus the content area (called when back is pressed in navbar) + void _focusContent() { + _contentFocusScopeNode.requestFocus(); + } + + /// Handle back key in navbar - focus content area + KeyEventResult _handleNavBarBackKey(FocusNode node, KeyEvent event) { + if (isBackKeyEvent(event)) { + _focusContent(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + @override void didPush() { // Called when this route has been pushed (initial navigation) @@ -150,44 +214,111 @@ class _MainScreenState extends State with RouteAware { } } + void _selectTab(int index) { + // Check if selection came from keyboard/d-pad (bottom nav has focus) + final isKeyboardNavigation = _bottomNavFocusScopeNode.hasFocus; + + setState(() { + _currentIndex = index; + }); + // Notify discover screen when it becomes visible via tab switch + if (index == 0) { + _onDiscoverBecameVisible(); + // Focus hero when selecting Home tab via keyboard/d-pad + if (isKeyboardNavigation) { + final discoverState = _discoverKey.currentState; + if (discoverState != null) { + (discoverState as dynamic).focusHero(); + } + } + } + // Focus first content item when selecting Libraries tab via keyboard/d-pad + if (index == 1 && isKeyboardNavigation) { + final librariesState = _librariesKey.currentState; + if (librariesState != null) { + (librariesState as dynamic).focusFirstContentItem(); + } + } + // Focus search input when selecting Search tab (for both click/tap and keyboard) + if (index == 2) { + final searchState = _searchKey.currentState; + if (searchState != null) { + (searchState as dynamic).focusSearchInput(); + } + } + // Move focus to the content area when selecting a tab via keyboard + if (isKeyboardNavigation) { + _contentFocusScopeNode.requestFocus(); + } + } + @override Widget build(BuildContext context) { - return Scaffold( - body: IndexedStack(index: _currentIndex, children: _screens), - bottomNavigationBar: NavigationBar( - selectedIndex: _currentIndex, - onDestinationSelected: (index) { - setState(() { - _currentIndex = index; - }); - // Notify discover screen when it becomes visible via tab switch - if (index == 0) { - _onDiscoverBecameVisible(); - } + return Shortcuts( + shortcuts: { + // Number keys 1-4 for quick tab switching + const SingleActivator(LogicalKeyboardKey.digit1): _TabIntent(0), + const SingleActivator(LogicalKeyboardKey.digit2): _TabIntent(1), + const SingleActivator(LogicalKeyboardKey.digit3): _TabIntent(2), + const SingleActivator(LogicalKeyboardKey.digit4): _TabIntent(3), + }, + child: Actions( + actions: { + _TabIntent: CallbackAction<_TabIntent>( + onInvoke: (intent) { + _selectTab(intent.tabIndex); + return null; + }, + ), }, - destinations: [ - NavigationDestination( - icon: const Icon(Icons.home_outlined), - selectedIcon: const Icon(Icons.home), - label: t.navigation.home, + child: Scaffold( + body: BackNavigationScope( + focusBottomNav: _focusBottomNav, + child: FocusScope( + node: _contentFocusScopeNode, + child: FocusTraversalGroup( + child: IndexedStack(index: _currentIndex, children: _screens), + ), + ), ), - NavigationDestination( - icon: const Icon(Icons.video_library_outlined), - selectedIcon: const Icon(Icons.video_library), - label: t.navigation.libraries, + bottomNavigationBar: FocusScope( + node: _bottomNavFocusScopeNode, + onKeyEvent: _handleNavBarBackKey, + child: NavigationBar( + selectedIndex: _currentIndex, + onDestinationSelected: _selectTab, + destinations: [ + NavigationDestination( + icon: const Icon(Icons.home_outlined), + selectedIcon: const Icon(Icons.home), + label: t.navigation.home, + ), + NavigationDestination( + icon: const Icon(Icons.video_library_outlined), + selectedIcon: const Icon(Icons.video_library), + label: t.navigation.libraries, + ), + NavigationDestination( + icon: const Icon(Icons.search), + selectedIcon: const Icon(Icons.search), + label: t.navigation.search, + ), + NavigationDestination( + icon: const Icon(Icons.settings_outlined), + selectedIcon: const Icon(Icons.settings), + label: t.navigation.settings, + ), + ], + ), ), - NavigationDestination( - icon: const Icon(Icons.search), - selectedIcon: const Icon(Icons.search), - label: t.navigation.search, - ), - NavigationDestination( - icon: const Icon(Icons.settings_outlined), - selectedIcon: const Icon(Icons.settings), - label: t.navigation.settings, - ), - ], + ), ), ); } } + +/// Intent for switching tabs via keyboard shortcuts +class _TabIntent extends Intent { + final int tabIndex; + const _TabIntent(this.tabIndex); +} diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 8ccf7b37..97eac4df 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -1,8 +1,11 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../i18n/strings.g.dart'; +import '../mixins/keyboard_long_press_mixin.dart'; +import '../widgets/focus/focus_indicator.dart'; import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; import '../providers/playback_state_provider.dart'; @@ -10,6 +13,7 @@ import '../theme/theme_helper.dart'; import '../utils/app_logger.dart'; import '../utils/content_rating_formatter.dart'; import '../utils/duration_formatter.dart'; +import '../utils/keyboard_utils.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; import '../widgets/app_bar_back_button.dart'; @@ -35,6 +39,7 @@ class _MediaDetailScreenState extends State { bool _isLoadingMetadata = true; late final ScrollController _scrollController; bool _watchStateChanged = false; + final FocusNode _playButtonFocusNode = FocusNode(debugLabel: 'PlayButton'); @override void initState() { @@ -46,6 +51,7 @@ class _MediaDetailScreenState extends State { @override void dispose() { _scrollController.dispose(); + _playButtonFocusNode.dispose(); super.dispose(); } @@ -87,6 +93,11 @@ class _MediaDetailScreenState extends State { _isLoadingMetadata = false; }); + // Focus the play button after loading + WidgetsBinding.instance.addPostFrameCallback((_) { + _playButtonFocusNode.requestFocus(); + }); + // Load seasons if it's a show if (metadata.type.toLowerCase() == 'show') { _loadSeasons(); @@ -100,6 +111,11 @@ class _MediaDetailScreenState extends State { _isLoadingMetadata = false; }); + // Focus the play button after loading + WidgetsBinding.instance.addPostFrameCallback((_) { + _playButtonFocusNode.requestFocus(); + }); + if (widget.metadata.type.toLowerCase() == 'show') { _loadSeasons(); } @@ -110,6 +126,11 @@ class _MediaDetailScreenState extends State { _isLoadingMetadata = false; }); + // Focus the play button after loading + WidgetsBinding.instance.addPostFrameCallback((_) { + _playButtonFocusNode.requestFocus(); + }); + if (widget.metadata.type.toLowerCase() == 'show') { _loadSeasons(); } @@ -370,120 +391,104 @@ class _MediaDetailScreenState extends State { final headerHeight = isDesktop ? size.height * 0.6 : size.height * 0.4; return Scaffold( - body: CustomScrollView( - controller: _scrollController, - slivers: [ - // Hero header with background art - DesktopSliverAppBar( - expandedHeight: headerHeight, - pinned: true, - leading: AppBarBackButton( - style: BackButtonStyle.circular, - onPressed: () => Navigator.pop(context, _watchStateChanged), - ), - flexibleSpace: FlexibleSpaceBar( - background: Stack( - fit: StackFit.expand, - children: [ - // Background Art - if (metadata.art != null) - Builder( - builder: (context) { - final client = _getClientForMetadata(context); - return CachedNetworkImage( - imageUrl: client.getThumbnailUrl(metadata.art), - fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), - errorWidget: (context, url, error) => Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), - ); - }, - ) - else - Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), + body: Focus( + autofocus: true, + onKeyEvent: (node, event) { + if (isBackKeyEvent(event)) { + Navigator.pop(context, _watchStateChanged); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: CustomScrollView( + controller: _scrollController, + slivers: [ + // Hero header with background art + DesktopSliverAppBar( + expandedHeight: headerHeight, + pinned: true, + leading: AppBarBackButton( + style: BackButtonStyle.circular, + onPressed: () => Navigator.pop(context, _watchStateChanged), + ), + flexibleSpace: FlexibleSpaceBar( + background: Stack( + fit: StackFit.expand, + children: [ + // Background Art + if (metadata.art != null) + Builder( + builder: (context) { + final client = _getClientForMetadata(context); + return CachedNetworkImage( + imageUrl: client.getThumbnailUrl(metadata.art), + fit: BoxFit.cover, + placeholder: (context, url) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + ); + }, + ) + else + Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), - // Gradient overlay - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.7), - Colors.black.withValues(alpha: 0.95), - ], - stops: const [0.3, 0.7, 1.0], + // Gradient overlay + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.7), + Colors.black.withValues(alpha: 0.95), + ], + stops: const [0.3, 0.7, 1.0], + ), ), ), - ), - // Content at bottom - Positioned( - bottom: 16, - left: 0, - right: 0, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // Clear logo or title - if (metadata.clearLogo != null) - SizedBox( - height: 120, - width: 400, - child: Builder( - builder: (context) { - final client = _getClientForMetadata( - context, - ); - return CachedNetworkImage( - imageUrl: client.getThumbnailUrl( - metadata.clearLogo, - ), - filterQuality: FilterQuality.medium, - fit: BoxFit.contain, - alignment: Alignment.centerLeft, - placeholder: (context, url) => Align( - alignment: Alignment.centerLeft, - child: Text( - metadata.title, - style: Theme.of(context) - .textTheme - .displaySmall - ?.copyWith( - color: Colors.white.withValues( - alpha: 0.3, - ), - fontWeight: FontWeight.bold, - shadows: [ - Shadow( - color: Colors.black - .withValues(alpha: 0.5), - blurRadius: 8, - ), - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, + // Content at bottom + Positioned( + bottom: 16, + left: 0, + right: 0, + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Clear logo or title + if (metadata.clearLogo != null) + SizedBox( + height: 120, + width: 400, + child: Builder( + builder: (context) { + final client = _getClientForMetadata( + context, + ); + return CachedNetworkImage( + imageUrl: client.getThumbnailUrl( + metadata.clearLogo, ), - ), - errorWidget: (context, url, error) { - return Align( + filterQuality: FilterQuality.medium, + fit: BoxFit.contain, + alignment: Alignment.centerLeft, + placeholder: (context, url) => Align( alignment: Alignment.centerLeft, child: Text( metadata.title, @@ -491,7 +496,8 @@ class _MediaDetailScreenState extends State { .textTheme .displaySmall ?.copyWith( - color: Colors.white, + color: Colors.white + .withValues(alpha: 0.3), fontWeight: FontWeight.bold, shadows: [ Shadow( @@ -506,254 +512,324 @@ class _MediaDetailScreenState extends State { maxLines: 2, overflow: TextOverflow.ellipsis, ), - ); - }, - ); - }, + ), + errorWidget: (context, url, error) { + return Align( + alignment: Alignment.centerLeft, + child: Text( + metadata.title, + style: Theme.of(context) + .textTheme + .displaySmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black + .withValues( + alpha: 0.5, + ), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + }, + ); + }, + ), + ) + else + Text( + metadata.title, + style: Theme.of(context) + .textTheme + .displaySmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black.withValues( + alpha: 0.5, + ), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - ) - else - Text( - metadata.title, - style: Theme.of(context).textTheme.displaySmall - ?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - shadows: [ - Shadow( - color: Colors.black.withValues( - alpha: 0.5, - ), - blurRadius: 8, - ), - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 12), + const SizedBox(height: 12), - // Metadata chips - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - if (metadata.year != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.black.withValues( - alpha: 0.4, + // Metadata chips + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (metadata.year != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, ), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - '${metadata.year}', - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500, + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(6), ), - ), - ), - if (metadata.contentRating != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.black.withValues( - alpha: 0.4, - ), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - formatContentRating( - metadata.contentRating!, - ), - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ), - if (metadata.duration != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.black.withValues( - alpha: 0.4, - ), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - formatDurationTextual(metadata.duration!), - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ), - if (metadata.rating != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.black.withValues( - alpha: 0.4, - ), - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.star, + child: Text( + '${metadata.year}', + style: const TextStyle( color: Colors.white, - size: 16, + fontSize: 13, + fontWeight: FontWeight.w500, ), - const SizedBox(width: 4), - Text( - '${(metadata.rating! * 10).toStringAsFixed(0)}%', - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - if (metadata.audienceRating != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.black.withValues( - alpha: 0.4, ), - borderRadius: BorderRadius.circular(6), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.people, + if (metadata.contentRating != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + formatContentRating( + metadata.contentRating!, + ), + style: const TextStyle( color: Colors.white, - size: 16, + fontSize: 13, + fontWeight: FontWeight.w500, ), - const SizedBox(width: 4), - Text( - '${(metadata.audienceRating! * 10).toStringAsFixed(0)}%', - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ], + ), ), - ), - ], - ), - ], + if (metadata.duration != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + formatDurationTextual( + metadata.duration!, + ), + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + if (metadata.rating != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.star, + color: Colors.white, + size: 16, + ), + const SizedBox(width: 4), + Text( + '${(metadata.rating! * 10).toStringAsFixed(0)}%', + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + if (metadata.audienceRating != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.people, + color: Colors.white, + size: 16, + ), + const SizedBox(width: 4), + Text( + '${(metadata.audienceRating! * 10).toStringAsFixed(0)}%', + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + ], + ), ), ), ), - ), - ], + ], + ), ), ), - ), - // Main content - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Action buttons - Row( - children: [ - Expanded( - child: SizedBox( - height: 48, - child: FilledButton.icon( - onPressed: () async { - // For TV shows, play the OnDeck episode if available - // Otherwise, play the first episode of the first season - if (metadata.type.toLowerCase() == 'show') { - if (_onDeckEpisode != null) { - appLogger.d( - 'Playing on deck episode: ${_onDeckEpisode!.title}', - ); + // Main content + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Action buttons + Row( + children: [ + Expanded( + child: SizedBox( + height: 48, + child: FilledButton.icon( + focusNode: _playButtonFocusNode, + onPressed: () async { + // For TV shows, play the OnDeck episode if available + // Otherwise, play the first episode of the first season + if (metadata.type.toLowerCase() == 'show') { + if (_onDeckEpisode != null) { + appLogger.d( + 'Playing on deck episode: ${_onDeckEpisode!.title}', + ); + await navigateToVideoPlayer( + context, + metadata: _onDeckEpisode!, + ); + appLogger.d( + 'Returned from playback, refreshing metadata', + ); + // Refresh metadata when returning from video player + _loadFullMetadata(); + } else { + // No on deck episode, fetch first episode of first season + await _playFirstEpisode(); + } + } else { + appLogger.d('Playing: ${metadata.title}'); + // For movies or episodes, play directly await navigateToVideoPlayer( context, - metadata: _onDeckEpisode!, + metadata: metadata, ); appLogger.d( 'Returned from playback, refreshing metadata', ); // Refresh metadata when returning from video player _loadFullMetadata(); - } else { - // No on deck episode, fetch first episode of first season - await _playFirstEpisode(); } - } else { - appLogger.d('Playing: ${metadata.title}'); - // For movies or episodes, play directly - await navigateToVideoPlayer( - context, - metadata: metadata, - ); - appLogger.d( - 'Returned from playback, refreshing metadata', - ); - // Refresh metadata when returning from video player - _loadFullMetadata(); - } - }, - icon: const Icon(Icons.play_arrow, size: 20), - label: Text( - _getPlayButtonLabel(metadata), - style: const TextStyle(fontSize: 16), - ), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 16, + }, + icon: const Icon(Icons.play_arrow, size: 20), + label: Text( + _getPlayButtonLabel(metadata), + style: const TextStyle(fontSize: 16), + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), ), ), ), ), - ), - const SizedBox(width: 12), - // Shuffle button (only for shows and seasons) - if (metadata.type.toLowerCase() == 'show' || - metadata.type.toLowerCase() == 'season') ...[ + const SizedBox(width: 12), + // Shuffle button (only for shows and seasons) + if (metadata.type.toLowerCase() == 'show' || + metadata.type.toLowerCase() == 'season') ...[ + IconButton.filledTonal( + onPressed: () async { + await _handleShufflePlayWithQueue( + context, + metadata, + ); + }, + icon: const Icon(Icons.shuffle), + tooltip: t.tooltips.shufflePlay, + iconSize: 20, + style: IconButton.styleFrom( + minimumSize: const Size(48, 48), + maximumSize: const Size(48, 48), + ), + ), + const SizedBox(width: 12), + ], IconButton.filledTonal( onPressed: () async { - await _handleShufflePlayWithQueue( - context, - metadata, - ); + try { + final client = _getClientForMetadata(context); + + await client.markAsWatched(metadata.ratingKey); + if (context.mounted) { + _watchStateChanged = true; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(t.messages.markedAsWatched), + ), + ); + // Update watch state without full rebuild + _updateWatchState(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + t.messages.errorLoading( + error: e.toString(), + ), + ), + ), + ); + } + } }, - icon: const Icon(Icons.shuffle), - tooltip: t.tooltips.shufflePlay, + icon: const Icon(Icons.check), + tooltip: t.tooltips.markAsWatched, iconSize: 20, style: IconButton.styleFrom( minimumSize: const Size(48, 48), @@ -761,428 +837,271 @@ class _MediaDetailScreenState extends State { ), ), const SizedBox(width: 12), - ], - IconButton.filledTonal( - onPressed: () async { - try { - final client = _getClientForMetadata(context); + IconButton.filledTonal( + onPressed: () async { + try { + final client = _getClientForMetadata(context); - await client.markAsWatched(metadata.ratingKey); - if (context.mounted) { - _watchStateChanged = true; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.messages.markedAsWatched), - ), - ); - // Update watch state without full rebuild - _updateWatchState(); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - t.messages.errorLoading( - error: e.toString(), + await client.markAsUnwatched(metadata.ratingKey); + if (context.mounted) { + _watchStateChanged = true; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(t.messages.markedAsUnwatched), + ), + ); + // Update watch state without full rebuild + _updateWatchState(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + t.messages.errorLoading( + error: e.toString(), + ), ), ), - ), - ); + ); + } } - } - }, - icon: const Icon(Icons.check), - tooltip: t.tooltips.markAsWatched, - iconSize: 20, - style: IconButton.styleFrom( - minimumSize: const Size(48, 48), - maximumSize: const Size(48, 48), - ), - ), - const SizedBox(width: 12), - IconButton.filledTonal( - onPressed: () async { - try { - final client = _getClientForMetadata(context); - - await client.markAsUnwatched(metadata.ratingKey); - if (context.mounted) { - _watchStateChanged = true; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.messages.markedAsUnwatched), - ), - ); - // Update watch state without full rebuild - _updateWatchState(); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - t.messages.errorLoading( - error: e.toString(), - ), - ), - ), - ); - } - } - }, - icon: const Icon(Icons.remove_done), - tooltip: t.tooltips.markAsUnwatched, - iconSize: 20, - style: IconButton.styleFrom( - minimumSize: const Size(48, 48), - maximumSize: const Size(48, 48), - ), - ), - ], - ), - - const SizedBox(height: 24), - - // Summary - if (metadata.summary != null) ...[ - Text( - t.discover.overview, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 12), - Text( - metadata.summary!, - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(height: 1.6), - ), - const SizedBox(height: 24), - ], - - // Seasons (for TV shows) - if (isShow) ...[ - Text( - t.discover.seasons, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 12), - if (_isLoadingSeasons) - const Center( - child: Padding( - padding: EdgeInsets.all(32), - child: CircularProgressIndicator(), - ), - ) - else if (_seasons.isEmpty) - Padding( - padding: const EdgeInsets.all(32), - child: Center( - child: Text( - t.messages.noSeasonsFound, - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: Colors.grey), + }, + icon: const Icon(Icons.remove_done), + tooltip: t.tooltips.markAsUnwatched, + iconSize: 20, + style: IconButton.styleFrom( + minimumSize: const Size(48, 48), + maximumSize: const Size(48, 48), ), ), - ) - else - ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - padding: EdgeInsets.zero, - itemCount: _seasons.length, - separatorBuilder: (context, index) => - const SizedBox(height: 12), - itemBuilder: (context, index) { - final season = _seasons[index]; - return _buildSeasonCard(season); - }, - ), - const SizedBox(height: 24), - ], - - // Cast - if (metadata.role != null && metadata.role!.isNotEmpty) ...[ - Text( - t.discover.cast, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + ], ), - const SizedBox(height: 12), - SizedBox( - height: 220, - child: HorizontalScrollWithArrows( - builder: (scrollController) => ListView.separated( - controller: scrollController, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12), - itemCount: metadata.role!.length, - separatorBuilder: (context, index) => - const SizedBox(width: 12), - itemBuilder: (context, index) { - final actor = metadata.role![index]; - return SizedBox( - width: 120, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: actor.thumb != null - ? CachedNetworkImage( - imageUrl: actor.thumb!, - width: 120, - height: 120, - fit: BoxFit.cover, - placeholder: (context, url) => - Container( - width: 120, - height: 120, - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - child: const Center( - child: Icon(Icons.person), - ), - ), - errorWidget: - ( - context, - url, - error, - ) => Container( - width: 120, - height: 120, - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - child: const Center( - child: Icon(Icons.person), - ), - ), - ) - : Container( - width: 120, - height: 120, - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest, - child: const Center( - child: Icon(Icons.person), - ), - ), - ), - const SizedBox(height: 8), - SizedBox( - height: 84, - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - actor.tag, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - fontWeight: FontWeight.w600, + + const SizedBox(height: 24), + + // Summary + if (metadata.summary != null) ...[ + Text( + t.discover.overview, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + Text( + metadata.summary!, + style: Theme.of( + context, + ).textTheme.bodyLarge?.copyWith(height: 1.6), + ), + const SizedBox(height: 24), + ], + + // Seasons (for TV shows) + if (isShow) ...[ + Text( + t.discover.seasons, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + if (_isLoadingSeasons) + const Center( + child: Padding( + padding: EdgeInsets.all(32), + child: CircularProgressIndicator(), + ), + ) + else if (_seasons.isEmpty) + Padding( + padding: const EdgeInsets.all(32), + child: Center( + child: Text( + t.messages.noSeasonsFound, + style: Theme.of(context).textTheme.bodyLarge + ?.copyWith(color: Colors.grey), + ), + ), + ) + else + FocusTraversalGroup( + child: ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemCount: _seasons.length, + separatorBuilder: (context, index) => + const SizedBox(height: 12), + itemBuilder: (context, index) { + final season = _seasons[index]; + return _FocusableSeasonCard( + season: season, + client: _getClientForMetadata(context), + onTap: () async { + final watchStateChanged = + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + SeasonDetailScreen( + season: season, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), - if (actor.role != null) ...[ - const SizedBox(height: 2), + ); + if (watchStateChanged == true) { + _watchStateChanged = true; + _updateWatchState(); + } + }, + onRefresh: () { + _watchStateChanged = true; + _updateWatchState(); + }, + ); + }, + ), + ), + const SizedBox(height: 24), + ], + + // Cast + if (metadata.role != null && metadata.role!.isNotEmpty) ...[ + Text( + t.discover.cast, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 220, + child: HorizontalScrollWithArrows( + builder: (scrollController) => ListView.separated( + controller: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: metadata.role!.length, + separatorBuilder: (context, index) => + const SizedBox(width: 12), + itemBuilder: (context, index) { + final actor = metadata.role![index]; + return SizedBox( + width: 120, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: actor.thumb != null + ? CachedNetworkImage( + imageUrl: actor.thumb!, + width: 120, + height: 120, + fit: BoxFit.cover, + placeholder: (context, url) => + Container( + width: 120, + height: 120, + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + child: const Center( + child: Icon(Icons.person), + ), + ), + errorWidget: + ( + context, + url, + error, + ) => Container( + width: 120, + height: 120, + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + child: const Center( + child: Icon(Icons.person), + ), + ), + ) + : Container( + width: 120, + height: 120, + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + child: const Center( + child: Icon(Icons.person), + ), + ), + ), + const SizedBox(height: 8), + SizedBox( + height: 84, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ Text( - actor.role!, + actor.tag, style: Theme.of(context) .textTheme - .bodySmall + .bodyMedium ?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, + fontWeight: FontWeight.w600, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), + if (actor.role != null) ...[ + const SizedBox(height: 2), + Text( + actor.role!, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], ], - ], + ), ), - ), - ], - ), - ); - }, + ], + ), + ); + }, + ), ), ), - ), - const SizedBox(height: 24), - ], + const SizedBox(height: 24), + ], - // Additional info - if (metadata.studio != null) ...[ - _buildInfoRow(t.discover.studio, metadata.studio!), - const SizedBox(height: 12), + // Additional info + if (metadata.studio != null) ...[ + _buildInfoRow(t.discover.studio, metadata.studio!), + const SizedBox(height: 12), + ], + if (metadata.contentRating != null) ...[ + _buildInfoRow( + t.discover.rating, + formatContentRating(metadata.contentRating!), + ), + const SizedBox(height: 12), + ], ], - if (metadata.contentRating != null) ...[ - _buildInfoRow( - t.discover.rating, - formatContentRating(metadata.contentRating!), - ), - const SizedBox(height: 12), - ], - ], + ), ), ), - ), - ], - ), - ); - } - - Widget _buildSeasonCard(PlexMetadata season) { - return Card( - clipBehavior: Clip.antiAlias, - child: MediaContextMenu( - item: season, - onRefresh: (ratingKey) { - _watchStateChanged = true; - _updateWatchState(); - }, - onTap: () async { - final watchStateChanged = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SeasonDetailScreen(season: season), - ), - ); - if (watchStateChanged == true) { - _watchStateChanged = true; - _updateWatchState(); - } - }, - child: Semantics( - label: "media-season-${season.ratingKey}", - identifier: "media-season-${season.ratingKey}", - button: true, - hint: "Tap to view ${season.title}", - child: InkWell( - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - // Season poster - if (season.thumb != null) - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Builder( - builder: (context) { - final client = _getClientForMetadata(context); - return CachedNetworkImage( - imageUrl: client.getThumbnailUrl(season.thumb), - width: 80, - height: 120, - fit: BoxFit.cover, - placeholder: (context, url) => Container( - width: 80, - height: 120, - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), - errorWidget: (context, url, error) => Container( - width: 80, - height: 120, - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - child: const Icon(Icons.movie, size: 32), - ), - ); - }, - ), - ) - else - Container( - width: 80, - height: 120, - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(6), - ), - child: const Icon(Icons.movie, size: 32), - ), - const SizedBox(width: 16), - - // Season info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - season.title, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - if (season.leafCount != null) - Text( - t.discover.episodeCount( - count: season.leafCount.toString(), - ), - style: Theme.of(context).textTheme.bodyMedium - ?.copyWith(color: Colors.grey), - ), - const SizedBox(height: 8), - if (season.viewedLeafCount != null && - season.leafCount != null) - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 200, - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: - season.viewedLeafCount! / - season.leafCount!, - backgroundColor: tokens(context).outline, - valueColor: AlwaysStoppedAnimation( - Theme.of(context).colorScheme.primary, - ), - minHeight: 6, - ), - ), - ), - const SizedBox(height: 4), - Text( - t.discover.watchedProgress( - watched: season.viewedLeafCount.toString(), - total: season.leafCount.toString(), - ), - style: Theme.of(context).textTheme.bodySmall - ?.copyWith(color: Colors.grey), - ), - ], - ), - ], - ), - ), - - const Icon(Icons.chevron_right), - ], - ), - ), - ), + ], ), ), ); @@ -1243,3 +1162,216 @@ class _MediaDetailScreenState extends State { return t.discover.play; } } + +/// Focusable season card widget +class _FocusableSeasonCard extends StatefulWidget { + final PlexMetadata season; + final PlexClient client; + final VoidCallback onTap; + final VoidCallback onRefresh; + + const _FocusableSeasonCard({ + required this.season, + required this.client, + required this.onTap, + required this.onRefresh, + }); + + @override + State<_FocusableSeasonCard> createState() => _FocusableSeasonCardState(); +} + +class _FocusableSeasonCardState extends State<_FocusableSeasonCard> + with KeyboardLongPressMixin { + late final FocusNode _focusNode; + bool _isFocused = false; + final _contextMenuKey = GlobalKey(); + + @override + void onKeyboardTap() => widget.onTap(); + + @override + void onKeyboardLongPress() { + _contextMenuKey.currentState?.showContextMenu(context); + } + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(_handleFocusChange); + } + + @override + void dispose() { + _focusNode.removeListener(_handleFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + void _handleFocusChange() { + if (_isFocused != _focusNode.hasFocus) { + setState(() { + _isFocused = _focusNode.hasFocus; + }); + if (_focusNode.hasFocus) { + Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ); + } + } + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + // Handle long-press detection for activation keys + return handleKeyboardLongPress(event); + } + + @override + Widget build(BuildContext context) { + final season = widget.season; + + return Focus( + focusNode: _focusNode, + onKeyEvent: _handleKeyEvent, + child: FocusIndicator( + isFocused: _isFocused, + borderRadius: 12, + child: Card( + clipBehavior: Clip.antiAlias, + child: MediaContextMenu( + key: _contextMenuKey, + item: season, + onRefresh: (ratingKey) => widget.onRefresh(), + onTap: widget.onTap, + child: Semantics( + label: "media-season-${season.ratingKey}", + identifier: "media-season-${season.ratingKey}", + button: true, + hint: "Tap to view ${season.title}", + child: InkWell( + onTap: widget.onTap, + focusColor: Colors.transparent, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + // Season poster + if (season.thumb != null) + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: CachedNetworkImage( + imageUrl: widget.client.getThumbnailUrl( + season.thumb, + ), + width: 80, + height: 120, + fit: BoxFit.cover, + placeholder: (context, url) => Container( + width: 80, + height: 120, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + ), + errorWidget: (context, url, error) => Container( + width: 80, + height: 120, + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.movie, size: 32), + ), + ), + ) + else + Container( + width: 80, + height: 120, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: const Icon(Icons.movie, size: 32), + ), + const SizedBox(width: 16), + + // Season info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + season.title, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + if (season.leafCount != null) + Text( + t.discover.episodeCount( + count: season.leafCount.toString(), + ), + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: Colors.grey), + ), + const SizedBox(height: 8), + if (season.viewedLeafCount != null && + season.leafCount != null) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 200, + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: + season.viewedLeafCount! / + season.leafCount!, + backgroundColor: tokens( + context, + ).outline, + valueColor: + AlwaysStoppedAnimation( + Theme.of( + context, + ).colorScheme.primary, + ), + minHeight: 6, + ), + ), + ), + const SizedBox(height: 4), + Text( + t.discover.watchedProgress( + watched: season.viewedLeafCount + .toString(), + total: season.leafCount.toString(), + ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: Colors.grey), + ), + ], + ), + ], + ), + ), + + const Icon(Icons.chevron_right), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/playlist_detail_screen.dart b/lib/screens/playlist_detail_screen.dart index df2015af..97344519 100644 --- a/lib/screens/playlist_detail_screen.dart +++ b/lib/screens/playlist_detail_screen.dart @@ -336,6 +336,7 @@ class _PlaylistDetailScreenState index: index, onRemove: () => _removeItem(index), onTap: () => _playFromItem(index), + onRefresh: updateItem, canReorder: !widget.playlist.smart, ); }, diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 399be520..5d6562d2 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:rate_limiter/rate_limiter.dart'; import '../i18n/strings.g.dart'; +import 'main_screen.dart'; import '../mixins/refreshable.dart'; import '../models/plex_metadata.dart'; import '../providers/multi_server_provider.dart'; @@ -10,6 +12,7 @@ import '../providers/settings_provider.dart'; import '../services/settings_service.dart'; import '../utils/app_logger.dart'; import '../utils/grid_cross_axis_extent.dart'; +import '../utils/keyboard_utils.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/media_card.dart'; @@ -22,6 +25,7 @@ class SearchScreen extends StatefulWidget { class _SearchScreenState extends State with Refreshable { final _searchController = TextEditingController(); + final _searchFocusNode = FocusNode(debugLabel: 'SearchInput'); List _searchResults = []; bool _isSearching = false; bool _hasSearched = false; @@ -36,6 +40,10 @@ class _SearchScreenState extends State with Refreshable { const Duration(milliseconds: 500), ); _searchController.addListener(_onSearchChanged); + // Focus the search input when the screen is shown + WidgetsBinding.instance.addPostFrameCallback((_) { + _searchFocusNode.requestFocus(); + }); } @override @@ -43,6 +51,7 @@ class _SearchScreenState extends State with Refreshable { _searchDebounce.cancel(); _searchController.removeListener(_onSearchChanged); _searchController.dispose(); + _searchFocusNode.dispose(); super.dispose(); } @@ -122,6 +131,13 @@ class _SearchScreenState extends State with Refreshable { } } + /// Focus the search input field + void focusSearchInput() { + WidgetsBinding.instance.addPostFrameCallback((_) { + _searchFocusNode.requestFocus(); + }); + } + // Public method to fully reload all content (for profile switches) void fullRefresh() { appLogger.d( @@ -144,133 +160,171 @@ class _SearchScreenState extends State with Refreshable { } } + /// Handle back key press - focus bottom navigation + KeyEventResult _handleBackKey(FocusNode node, KeyEvent event) { + if (isBackKeyEvent(event)) { + BackNavigationScope.of(context)?.focusBottomNav(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( - child: CustomScrollView( - slivers: [ - DesktopSliverAppBar(title: Text(t.screens.search), floating: true), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(16), - child: SearchBar( - controller: _searchController, - hintText: t.search.hint, - leading: const Icon(Icons.search), - trailing: [ - if (_searchController.text.isNotEmpty) - IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - // State update handled by listener - }, + child: Focus( + onKeyEvent: _handleBackKey, + child: CustomScrollView( + slivers: [ + DesktopSliverAppBar( + title: Text(t.screens.search), + floating: true, + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: _searchController, + focusNode: _searchFocusNode, + decoration: InputDecoration( + hintText: t.search.hint, + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + // State update handled by listener + }, + ) + : null, + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide.none, ), - ], - autoFocus: false, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + ), ), ), - ), - if (_isSearching) - const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), - ) - else if (!_hasSearched) - SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.search, size: 80, color: Colors.grey.shade400), - const SizedBox(height: 16), - Text( - t.search.searchYourMedia, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Colors.grey.shade600, + if (_isSearching) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (!_hasSearched) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search, + size: 80, + color: Colors.grey.shade400, ), - ), - const SizedBox(height: 8), - Text( - t.search.enterTitleActorOrKeyword, - style: TextStyle(color: Colors.grey.shade600), - ), - ], - ), - ), - ) - else if (_searchResults.isEmpty) - SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.search_off, - size: 80, - color: Colors.grey.shade400, - ), - const SizedBox(height: 16), - Text( - t.messages.noResultsFound, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: Colors.grey.shade600, + const SizedBox(height: 16), + Text( + t.search.searchYourMedia, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(color: Colors.grey.shade600), ), - ), - const SizedBox(height: 8), - Text( - t.search.tryDifferentTerm, - style: TextStyle(color: Colors.grey.shade600), - ), - ], + const SizedBox(height: 8), + Text( + t.search.enterTitleActorOrKeyword, + style: TextStyle(color: Colors.grey.shade600), + ), + ], + ), ), - ), - ) - else - Consumer( - builder: (context, settingsProvider, child) { - if (settingsProvider.viewMode == ViewMode.list) { - return SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final item = _searchResults[index]; - return MediaCard( - key: Key(item.ratingKey), - item: item, - onRefresh: updateItem, - ); - }, childCount: _searchResults.length), - ), - ); - } else { - return SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverGrid( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: getMaxCrossAxisExtentWithPadding( + ) + else if (_searchResults.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 80, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + t.messages.noResultsFound, + style: Theme.of(context).textTheme.titleLarge + ?.copyWith(color: Colors.grey.shade600), + ), + const SizedBox(height: 8), + Text( + t.search.tryDifferentTerm, + style: TextStyle(color: Colors.grey.shade600), + ), + ], + ), + ), + ) + else + Consumer( + builder: (context, settingsProvider, child) { + if (settingsProvider.viewMode == ViewMode.list) { + return SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate(( context, - settingsProvider.libraryDensity, - 32, - ), - childAspectRatio: 2 / 3.3, - crossAxisSpacing: 8, - mainAxisSpacing: 8, + index, + ) { + final item = _searchResults[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onRefresh: updateItem, + ); + }, childCount: _searchResults.length), ), - delegate: SliverChildBuilderDelegate((context, index) { - final item = _searchResults[index]; - return MediaCard( - key: Key(item.ratingKey), - item: item, - onRefresh: updateItem, - ); - }, childCount: _searchResults.length), - ), - ); - } - }, - ), - ], + ); + } else { + return SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverGrid( + gridDelegate: + SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: + getMaxCrossAxisExtentWithPadding( + context, + settingsProvider.libraryDensity, + 32, + ), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ), + delegate: SliverChildBuilderDelegate(( + context, + index, + ) { + final item = _searchResults[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onRefresh: updateItem, + ); + }, childCount: _searchResults.length), + ), + ); + } + }, + ), + ], + ), ), ), ); diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index c0d241cb..1da2db00 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -1,13 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:cached_network_image/cached_network_image.dart'; import '../client/plex_client.dart'; +import '../widgets/focus/focus_indicator.dart'; import '../models/plex_metadata.dart'; +import '../utils/keyboard_utils.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; import '../utils/duration_formatter.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/media_context_menu.dart'; import '../mixins/item_updatable.dart'; +import '../mixins/keyboard_long_press_mixin.dart'; import '../theme/theme_helper.dart'; import '../i18n/strings.g.dart'; @@ -30,6 +34,9 @@ class _SeasonDetailScreenState extends State List _episodes = []; bool _isLoadingEpisodes = false; bool _watchStateChanged = false; + final FocusNode _firstEpisodeFocusNode = FocusNode( + debugLabel: 'FirstEpisode', + ); /// Get the correct PlexClient for this season's server PlexClient _getClientForSeason(BuildContext context) { @@ -46,6 +53,12 @@ class _SeasonDetailScreenState extends State }); } + @override + void dispose() { + _firstEpisodeFocusNode.dispose(); + super.dispose(); + } + Future _loadEpisodes() async { setState(() { _isLoadingEpisodes = true; @@ -59,6 +72,13 @@ class _SeasonDetailScreenState extends State _episodes = episodes; _isLoadingEpisodes = false; }); + + // Focus the first episode after loading + if (episodes.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _firstEpisodeFocusNode.requestFocus(); + }); + } } catch (e) { setState(() { _isLoadingEpisodes = false; @@ -83,52 +103,145 @@ class _SeasonDetailScreenState extends State @override Widget build(BuildContext context) { return Scaffold( - body: CustomScrollView( - slivers: [ - CustomAppBar( - title: Text(widget.season.title), - pinned: true, - onBackPressed: () => Navigator.pop(context, _watchStateChanged), - ), - if (_isLoadingEpisodes) - const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), - ) - else if (_episodes.isEmpty) - SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.movie_outlined, - size: 64, - color: tokens(context).textMuted, - ), - const SizedBox(height: 16), - Text( - t.messages.noEpisodesFoundGeneral, - style: Theme.of(context).textTheme.titleLarge?.copyWith( + body: Focus( + autofocus: true, + onKeyEvent: (node, event) { + if (isBackKeyEvent(event)) { + Navigator.pop(context, _watchStateChanged); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: CustomScrollView( + slivers: [ + CustomAppBar( + title: Text(widget.season.title), + pinned: true, + onBackPressed: () => Navigator.pop(context, _watchStateChanged), + ), + if (_isLoadingEpisodes) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_episodes.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.movie_outlined, + size: 64, color: tokens(context).textMuted, ), - ), - ], + const SizedBox(height: 16), + Text( + t.messages.noEpisodesFoundGeneral, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: tokens(context).textMuted, + ), + ), + ], + ), ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final episode = _episodes[index]; + return _EpisodeCard( + episode: episode, + client: _client, + focusNode: index == 0 ? _firstEpisodeFocusNode : null, + onTap: () async { + await navigateToVideoPlayer(context, metadata: episode); + // Refresh episodes when returning from video player + _loadEpisodes(); + }, + onRefresh: updateItem, + ); + }, childCount: _episodes.length), ), - ) - else - SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final episode = _episodes[index]; - return _buildEpisodeCard(episode); - }, childCount: _episodes.length), - ), - ], + ], + ), ), ); } +} - Widget _buildEpisodeCard(PlexMetadata episode) { +/// Focusable episode card widget +class _EpisodeCard extends StatefulWidget { + final PlexMetadata episode; + final PlexClient client; + final VoidCallback onTap; + final Future Function(String) onRefresh; + final FocusNode? focusNode; + + const _EpisodeCard({ + required this.episode, + required this.client, + required this.onTap, + required this.onRefresh, + this.focusNode, + }); + + @override + State<_EpisodeCard> createState() => _EpisodeCardState(); +} + +class _EpisodeCardState extends State<_EpisodeCard> + with KeyboardLongPressMixin { + FocusNode? _internalFocusNode; + FocusNode get _focusNode => + widget.focusNode ?? (_internalFocusNode ??= FocusNode()); + bool _isFocused = false; + final _contextMenuKey = GlobalKey(); + + @override + void onKeyboardTap() => widget.onTap(); + + @override + void onKeyboardLongPress() { + _contextMenuKey.currentState?.showContextMenu(context); + } + + @override + void initState() { + super.initState(); + _focusNode.addListener(_handleFocusChange); + } + + @override + void dispose() { + _focusNode.removeListener(_handleFocusChange); + _internalFocusNode?.dispose(); + super.dispose(); + } + + void _handleFocusChange() { + if (_isFocused != _focusNode.hasFocus) { + setState(() { + _isFocused = _focusNode.hasFocus; + }); + if (_focusNode.hasFocus) { + Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ); + } + } + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + // Handle long-press detection for activation keys + return handleKeyboardLongPress(event); + } + + @override + Widget build(BuildContext context) { + final episode = widget.episode; final hasProgress = episode.viewOffset != null && episode.duration != null && @@ -138,229 +251,245 @@ class _SeasonDetailScreenState extends State : 0.0; return MediaContextMenu( + key: _contextMenuKey, item: episode, - onRefresh: updateItem, - onTap: () async { - await navigateToVideoPlayer(context, metadata: episode); - // Refresh episodes when returning from video player - _loadEpisodes(); - }, - child: InkWell( - key: Key(episode.ratingKey), - hoverColor: Theme.of( - context, - ).colorScheme.surface.withValues(alpha: 0.05), - child: Container( - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: tokens(context).outline, width: 0.5), - ), - ), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Episode thumbnail (16:9 aspect ratio, fixed width) - SizedBox( - width: 160, - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(6), - child: AspectRatio( - aspectRatio: 16 / 9, - child: episode.thumb != null - ? Builder( - builder: (context) { - return CachedNetworkImage( - imageUrl: _client.getThumbnailUrl( - episode.thumb, - ), - filterQuality: FilterQuality.medium, - fit: BoxFit.cover, - placeholder: (context, url) => Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), - errorWidget: (context, url, error) => - Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - child: const Icon( - Icons.movie, - size: 32, - ), - ), - ); - }, - ) - : Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - child: const Icon(Icons.movie, size: 32), - ), - ), - ), - - // Play overlay - Positioned.fill( - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(6), - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.2), - ], - ), - ), - child: Center( - child: Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.6), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.play_arrow, - color: Colors.white, - size: 20, - ), - ), - ), - ), - ), - - // Progress bar at bottom - if (hasProgress && !episode.isWatched) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: ClipRRect( - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(6), - bottomRight: Radius.circular(6), - ), - child: LinearProgressIndicator( - value: progress, - backgroundColor: tokens(context).outline, - minHeight: 3, - ), - ), - ), - ], + onRefresh: widget.onRefresh, + onTap: widget.onTap, + child: Focus( + focusNode: _focusNode, + onKeyEvent: _handleKeyEvent, + child: FocusIndicator( + isFocused: _isFocused, + borderRadius: 0, + scale: 1.0, // No scale for full-width items to avoid clipping + child: InkWell( + key: Key(episode.ratingKey), + onTap: widget.onTap, + hoverColor: Theme.of( + context, + ).colorScheme.surface.withValues(alpha: 0.05), + focusColor: Colors.transparent, + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: tokens(context).outline, + width: 0.5, + ), ), ), - - const SizedBox(width: 12), - - // Episode info - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Episode number and title - Row( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Episode thumbnail (16:9 aspect ratio, fixed width) + SizedBox( + width: 160, + child: Stack( children: [ - if (episode.index != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 3, - ), + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: AspectRatio( + aspectRatio: 16 / 9, + child: episode.thumb != null + ? Builder( + builder: (context) { + return CachedNetworkImage( + imageUrl: widget.client.getThumbnailUrl( + episode.thumb, + ), + filterQuality: FilterQuality.medium, + fit: BoxFit.cover, + placeholder: (context, url) => + Container( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + ), + errorWidget: (context, url, error) => + Container( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest, + child: const Icon( + Icons.movie, + size: 32, + ), + ), + ); + }, + ) + : Container( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + child: const Icon(Icons.movie, size: 32), + ), + ), + ), + + // Play overlay + Positioned.fill( + child: Container( decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(3), + borderRadius: BorderRadius.circular(6), + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.2), + ], + ), ), - child: Text( - 'E${episode.index}', - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onPrimaryContainer, - fontSize: 11, - fontWeight: FontWeight.w600, + child: Center( + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.play_arrow, + color: Colors.white, + size: 20, + ), ), ), ), - const SizedBox(width: 8), - Expanded( - child: Text( - episode.title, - style: Theme.of(context).textTheme.titleSmall - ?.copyWith(fontWeight: FontWeight.bold), - maxLines: 2, + ), + + // Progress bar at bottom + if (hasProgress && !episode.isWatched) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: ClipRRect( + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(6), + bottomRight: Radius.circular(6), + ), + child: LinearProgressIndicator( + value: progress, + backgroundColor: tokens(context).outline, + minHeight: 3, + ), + ), + ), + ], + ), + ), + + const SizedBox(width: 12), + + // Episode info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Episode number and title + Row( + children: [ + if (episode.index != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 3, + ), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(3), + ), + child: Text( + 'E${episode.index}', + style: TextStyle( + color: Theme.of( + context, + ).colorScheme.onPrimaryContainer, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + episode.title, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.bold), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + + // Summary + if (episode.summary != null && + episode.summary!.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + episode.summary!, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + height: 1.3, + ), + maxLines: 3, overflow: TextOverflow.ellipsis, ), - ), - ], - ), - - // Summary - if (episode.summary != null && - episode.summary!.isNotEmpty) ...[ - const SizedBox(height: 6), - Text( - episode.summary!, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted, - height: 1.3, - ), - maxLines: 3, - overflow: TextOverflow.ellipsis, - ), - ], - - // Metadata row (duration, watched status) - const SizedBox(height: 8), - Row( - children: [ - if (episode.duration != null) - Text( - formatDurationTimestamp( - Duration(milliseconds: episode.duration!), - ), - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: tokens(context).textMuted, - fontSize: 12, - ), - ), - if (episode.duration != null && episode.isWatched) ...[ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 6), - child: Text( - '•', - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: tokens(context).textMuted, - fontSize: 12, - ), - ), - ), - Text( - '${t.discover.watched} ✓', - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: tokens(context).textMuted, - fontSize: 12, - ), - ), ], + + // Metadata row (duration, watched status) + const SizedBox(height: 8), + Row( + children: [ + if (episode.duration != null) + Text( + formatDurationTimestamp( + Duration(milliseconds: episode.duration!), + ), + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 12, + ), + ), + if (episode.duration != null && + episode.isWatched) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6, + ), + child: Text( + '•', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 12, + ), + ), + ), + Text( + '${t.discover.watched} ✓', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 12, + ), + ), + ], + ], + ), ], ), - ], - ), + ), + ], ), - ], + ), ), ), ), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 27d039cb..a3d8cef0 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,14 +1,17 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher.dart'; import '../i18n/strings.g.dart'; +import 'main_screen.dart'; import '../providers/settings_provider.dart'; import '../providers/theme_provider.dart'; import '../services/keyboard_shortcuts_service.dart'; import '../services/settings_service.dart' as settings; import '../services/update_service.dart'; +import '../utils/keyboard_utils.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/hotkey_recorder_widget.dart'; import 'about_screen.dart'; @@ -61,6 +64,15 @@ class _SettingsScreenState extends State { }); } + /// Handle back key press - focus bottom navigation + KeyEventResult _handleBackKey(FocusNode node, KeyEvent event) { + if (isBackKeyEvent(event)) { + BackNavigationScope.of(context)?.focusBottomNav(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + @override Widget build(BuildContext context) { if (_isLoading) { @@ -68,31 +80,34 @@ class _SettingsScreenState extends State { } return Scaffold( - body: CustomScrollView( - slivers: [ - CustomAppBar(title: Text(t.settings.title), pinned: true), - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildListDelegate([ - _buildAppearanceSection(), - const SizedBox(height: 24), - _buildVideoPlaybackSection(), - const SizedBox(height: 24), - _buildKeyboardShortcutsSection(), - const SizedBox(height: 24), - _buildAdvancedSection(), - const SizedBox(height: 24), - if (UpdateService.isUpdateCheckEnabled) ...[ - _buildUpdateSection(), + body: Focus( + onKeyEvent: _handleBackKey, + child: CustomScrollView( + slivers: [ + CustomAppBar(title: Text(t.settings.title), pinned: true), + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildListDelegate([ + _buildAppearanceSection(), const SizedBox(height: 24), - ], - _buildAboutSection(), - const SizedBox(height: 24), - ]), + _buildVideoPlaybackSection(), + const SizedBox(height: 24), + _buildKeyboardShortcutsSection(), + const SizedBox(height: 24), + _buildAdvancedSection(), + const SizedBox(height: 24), + if (UpdateService.isUpdateCheckEnabled) ...[ + _buildUpdateSection(), + const SizedBox(height: 24), + ], + _buildAboutSection(), + const SizedBox(height: 24), + ]), + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index a8593acc..e67fd60c 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -3,6 +3,7 @@ import 'package:flutter/services.dart'; import 'package:media_kit/media_kit.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; import 'settings_service.dart'; +import '../utils/keyboard_utils.dart'; import '../utils/player_utils.dart'; class KeyboardShortcutsService { @@ -153,10 +154,17 @@ class KeyboardShortcutsService { VoidCallback? onNextAudioTrack, VoidCallback? onNextSubtitleTrack, VoidCallback? onNextChapter, - VoidCallback? onPreviousChapter, - ) { + VoidCallback? onPreviousChapter, { + VoidCallback? onBack, + }) { if (event is! KeyDownEvent) return KeyEventResult.ignored; + // Handle back navigation keys first + if (isBackKey(event.logicalKey)) { + onBack?.call(); + return KeyEventResult.handled; + } + final physicalKey = event.physicalKey; final isShiftPressed = HardwareKeyboard.instance.isShiftPressed; final isControlPressed = HardwareKeyboard.instance.isControlPressed; diff --git a/lib/services/media_controls_manager.dart b/lib/services/media_controls_manager.dart index ccc9a5d6..e94594eb 100644 --- a/lib/services/media_controls_manager.dart +++ b/lib/services/media_controls_manager.dart @@ -94,7 +94,9 @@ class MediaControlsManager { try { await OsMediaControls.setPlaybackState( MediaPlaybackState( - state: params.isPlaying ? PlaybackState.playing : PlaybackState.paused, + state: params.isPlaying + ? PlaybackState.playing + : PlaybackState.paused, position: params.position, speed: params.speed, ), diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index 8143d4ff..8000b532 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -123,15 +123,15 @@ class TrackSelectionService { 'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}', ); - for (var track in availableTracks) { - final trackLang = track.language?.toLowerCase(); - if (trackLang != null && languageVariations.any((lang) => trackLang.startsWith(lang))) { - appLogger.d( - 'Found audio track matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}', - ); - return track; - } - } + final match = _findTrackByLanguageVariations( + availableTracks, + preferredLanguage, + languageVariations, + (t) => t.language, + (t) => t.title ?? 'Track ${t.id}', + 'audio track', + ); + if (match != null) return match; } appLogger.d( @@ -258,15 +258,15 @@ class TrackSelectionService { 'Checking language variations for "$preferredLanguage": ${languageVariations.join(", ")}', ); - for (var track in candidateTracks) { - final trackLang = track.language?.toLowerCase(); - if (trackLang != null && languageVariations.any((lang) => trackLang.startsWith(lang))) { - appLogger.d( - 'Found subtitle matching profile language "$preferredLanguage" (matched: "$trackLang"): ${track.title ?? "Track ${track.id}"}', - ); - return track; - } - } + final match = _findTrackByLanguageVariations( + candidateTracks, + preferredLanguage, + languageVariations, + (t) => t.language, + (t) => t.title ?? 'Track ${t.id}', + 'subtitle', + ); + if (match != null) return match; } appLogger.d( @@ -370,6 +370,29 @@ class TrackSelectionService { return title.contains('forced'); } + /// Find a track matching a preferred language from a list of tracks + /// Returns the first track whose language matches any variation of the preferred language + T? _findTrackByLanguageVariations( + List tracks, + String preferredLanguage, + List languageVariations, + String? Function(T) getLanguage, + String Function(T) getTrackDescription, + String trackType, + ) { + for (var track in tracks) { + final trackLang = getLanguage(track)?.toLowerCase(); + if (trackLang != null && + languageVariations.any((lang) => trackLang.startsWith(lang))) { + appLogger.d( + 'Found $trackType matching profile language "$preferredLanguage" (matched: "$trackLang"): ${getTrackDescription(track)}', + ); + return track; + } + } + return null; + } + /// Checks if a track language matches a preferred language /// /// Handles both 2-letter (ISO 639-1) and 3-letter (ISO 639-2) codes diff --git a/lib/utils/keyboard_utils.dart b/lib/utils/keyboard_utils.dart new file mode 100644 index 00000000..af761d94 --- /dev/null +++ b/lib/utils/keyboard_utils.dart @@ -0,0 +1,15 @@ +import 'package:flutter/services.dart'; + +/// Check if a logical key is a back navigation key +bool isBackKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.escape || + key == LogicalKeyboardKey.backspace || + key == LogicalKeyboardKey.goBack || + key == LogicalKeyboardKey.gameButtonB; +} + +/// Check if a key event is a back navigation key down event +bool isBackKeyEvent(KeyEvent event) { + if (event is! KeyDownEvent) return false; + return isBackKey(event.logicalKey); +} diff --git a/lib/widgets/adaptive_media_grid.dart b/lib/widgets/adaptive_media_grid.dart index cf8b37eb..2e4cb90f 100644 --- a/lib/widgets/adaptive_media_grid.dart +++ b/lib/widgets/adaptive_media_grid.dart @@ -22,12 +22,16 @@ class AdaptiveMediaGrid extends StatelessWidget { /// Child aspect ratio for grid items (width / height) final double childAspectRatio; + /// Optional focus node for the first item (for keyboard navigation) + final FocusNode? firstItemFocusNode; + const AdaptiveMediaGrid({ super.key, required this.items, this.onRefresh, this.padding = const EdgeInsets.fromLTRB(8, 8, 8, 8), this.childAspectRatio = 2 / 3.3, + this.firstItemFocusNode, }); @override @@ -35,39 +39,45 @@ class AdaptiveMediaGrid extends StatelessWidget { return Consumer( builder: (context, settingsProvider, child) { if (settingsProvider.viewMode == ViewMode.list) { - return ListView.builder( - padding: padding, - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - return MediaCard( - key: Key(item.ratingKey), - item: item, - onListRefresh: onRefresh, - ); - }, + return FocusTraversalGroup( + child: ListView.builder( + padding: padding, + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onListRefresh: onRefresh, + focusNode: index == 0 ? firstItemFocusNode : null, + ); + }, + ), ); } else { - return GridView.builder( - padding: padding, - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( - context, - settingsProvider.libraryDensity, + return FocusTraversalGroup( + child: GridView.builder( + padding: padding, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, + ), + childAspectRatio: childAspectRatio, + crossAxisSpacing: 0, + mainAxisSpacing: 0, ), - childAspectRatio: childAspectRatio, - crossAxisSpacing: 0, - mainAxisSpacing: 0, + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onListRefresh: onRefresh, + focusNode: index == 0 ? firstItemFocusNode : null, + ); + }, ), - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - return MediaCard( - key: Key(item.ratingKey), - item: item, - onListRefresh: onRefresh, - ); - }, ); } }, diff --git a/lib/widgets/filters_bottom_sheet.dart b/lib/widgets/filters_bottom_sheet.dart index 57f2a5d2..10d8fd06 100644 --- a/lib/widgets/filters_bottom_sheet.dart +++ b/lib/widgets/filters_bottom_sheet.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../models/plex_filter.dart'; import '../widgets/app_bar_back_button.dart'; import '../widgets/bottom_sheet_header.dart'; import '../utils/provider_extensions.dart'; +import '../utils/keyboard_utils.dart'; import '../i18n/strings.g.dart'; class FiltersBottomSheet extends StatefulWidget { @@ -30,12 +32,29 @@ class _FiltersBottomSheetState extends State { final Map _tempSelectedFilters = {}; final Map _filterDisplayNames = {}; // Cache for display names late List _sortedFilters; + final FocusNode _firstItemFocusNode = FocusNode( + debugLabel: 'FilterFirstItem', + ); + final FocusNode _filterValuesFocusNode = FocusNode( + debugLabel: 'FilterValuesFirstItem', + ); @override void initState() { super.initState(); _tempSelectedFilters.addAll(widget.selectedFilters); _sortFilters(); + // Focus the first item after build + WidgetsBinding.instance.addPostFrameCallback((_) { + _firstItemFocusNode.requestFocus(); + }); + } + + @override + void dispose() { + _firstItemFocusNode.dispose(); + _filterValuesFocusNode.dispose(); + super.dispose(); } void _sortFilters() { @@ -69,6 +88,10 @@ class _FiltersBottomSheetState extends State { _filterValues = values; _isLoadingValues = false; }); + // Focus the first filter value after loading + WidgetsBinding.instance.addPostFrameCallback((_) { + _filterValuesFocusNode.requestFocus(); + }); } catch (e) { setState(() { _filterValues = []; @@ -89,6 +112,21 @@ class _FiltersBottomSheetState extends State { Navigator.pop(context); } + /// Handle back key - go back to main view or close sheet + KeyEventResult _handleBackKey(FocusNode node, KeyEvent event) { + if (isBackKeyEvent(event)) { + if (_currentFilter != null) { + // Go back to main filters view + _goBack(); + } else { + // Close the bottom sheet + Navigator.pop(context); + } + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + } + String _extractFilterValue(String key, String filterName) { if (key.contains('?')) { final queryStart = key.indexOf('?'); @@ -103,174 +141,181 @@ class _FiltersBottomSheetState extends State { @override Widget build(BuildContext context) { - return DraggableScrollableSheet( - initialChildSize: 0.7, - minChildSize: 0.5, - maxChildSize: 0.95, - expand: false, - builder: (context, scrollController) { - if (_currentFilter != null) { - // Show filter options view - return Column( - children: [ - // Header with back button - BottomSheetHeader( - title: _currentFilter!.title, - leading: AppBarBackButton( - style: BackButtonStyle.plain, - onPressed: _goBack, + return Focus( + autofocus: true, + onKeyEvent: _handleBackKey, + child: DraggableScrollableSheet( + initialChildSize: 0.7, + minChildSize: 0.5, + maxChildSize: 0.95, + expand: false, + builder: (context, scrollController) { + if (_currentFilter != null) { + // Show filter options view + return Column( + children: [ + // Header with back button + BottomSheetHeader( + title: _currentFilter!.title, + leading: AppBarBackButton( + style: BackButtonStyle.plain, + onPressed: _goBack, + ), ), - ), - // Filter options list - if (_isLoadingValues) - const Expanded( - child: Center(child: CircularProgressIndicator()), - ) - else - Expanded( - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _filterValues.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - final isSelected = !_tempSelectedFilters.containsKey( + // Filter options list + if (_isLoadingValues) + const Expanded( + child: Center(child: CircularProgressIndicator()), + ) + else + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _filterValues.length + 1, + itemBuilder: (context, index) { + if (index == 0) { + final isSelected = !_tempSelectedFilters.containsKey( + _currentFilter!.filter, + ); + return ListTile( + focusNode: _filterValuesFocusNode, + title: Text(t.libraries.all), + selected: isSelected, + onTap: () { + setState(() { + _tempSelectedFilters.remove( + _currentFilter!.filter, + ); + }); + _applyFilters(); + }, + ); + } + + final value = _filterValues[index - 1]; + final filterValue = _extractFilterValue( + value.key, _currentFilter!.filter, ); + final isSelected = + _tempSelectedFilters[_currentFilter!.filter] == + filterValue; + return ListTile( - title: Text(t.libraries.all), + title: Text(value.title), selected: isSelected, onTap: () { setState(() { - _tempSelectedFilters.remove( - _currentFilter!.filter, - ); + _tempSelectedFilters[_currentFilter!.filter] = + filterValue; + // Cache the display name for this filter value + _filterDisplayNames['${_currentFilter!.filter}:$filterValue'] = + value.title; }); _applyFilters(); }, ); - } + }, + ), + ), + ], + ); + } - final value = _filterValues[index - 1]; - final filterValue = _extractFilterValue( - value.key, - _currentFilter!.filter, - ); - final isSelected = - _tempSelectedFilters[_currentFilter!.filter] == - filterValue; - - return ListTile( - title: Text(value.title), - selected: isSelected, - onTap: () { + // Show main filters view + return Column( + children: [ + // Header + BottomSheetHeader( + title: t.libraries.filters, + leading: const Icon(Icons.filter_alt), + action: _tempSelectedFilters.isNotEmpty + ? TextButton.icon( + onPressed: () { setState(() { - _tempSelectedFilters[_currentFilter!.filter] = - filterValue; - // Cache the display name for this filter value - _filterDisplayNames['${_currentFilter!.filter}:$filterValue'] = - value.title; + _tempSelectedFilters.clear(); }); _applyFilters(); }, + icon: const Icon(Icons.clear_all), + label: Text(t.libraries.clearAll), + ) + : null, + ), + + // All Filters (boolean toggles first, then regular filters) + Expanded( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _sortedFilters.length, + itemBuilder: (context, index) { + final filter = _sortedFilters[index]; + + // Handle boolean filters as switches (unwatched, inProgress, unmatched, hdr, etc.) + if (_isBooleanFilter(filter)) { + final isActive = + _tempSelectedFilters.containsKey(filter.filter) && + _tempSelectedFilters[filter.filter] == '1'; + return SwitchListTile( + focusNode: index == 0 ? _firstItemFocusNode : null, + value: isActive, + onChanged: (value) { + setState(() { + if (value) { + _tempSelectedFilters[filter.filter] = '1'; + } else { + _tempSelectedFilters.remove(filter.filter); + } + }); + _applyFilters(); + }, + title: Text(filter.title), ); - }, - ), + } + + // Regular navigable filters - show selected value instead of checkmark + final selectedValue = _tempSelectedFilters[filter.filter]; + String? displayValue; + if (selectedValue != null) { + // Try to get the cached display name, fall back to the value itself + displayValue = + _filterDisplayNames['${filter.filter}:$selectedValue'] ?? + selectedValue; + } + + return ListTile( + focusNode: index == 0 ? _firstItemFocusNode : null, + title: Text(filter.title), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (displayValue != null) + Flexible( + child: Text( + displayValue, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + if (displayValue != null) const SizedBox(width: 8), + const Icon(Icons.chevron_right), + ], + ), + onTap: () => _loadFilterValues(filter), + ); + }, ), + ), ], ); - } - - // Show main filters view - return Column( - children: [ - // Header - BottomSheetHeader( - title: t.libraries.filters, - leading: const Icon(Icons.filter_alt), - action: _tempSelectedFilters.isNotEmpty - ? TextButton.icon( - onPressed: () { - setState(() { - _tempSelectedFilters.clear(); - }); - _applyFilters(); - }, - icon: const Icon(Icons.clear_all), - label: Text(t.libraries.clearAll), - ) - : null, - ), - - // All Filters (boolean toggles first, then regular filters) - Expanded( - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _sortedFilters.length, - itemBuilder: (context, index) { - final filter = _sortedFilters[index]; - - // Handle boolean filters as switches (unwatched, inProgress, unmatched, hdr, etc.) - if (_isBooleanFilter(filter)) { - final isActive = - _tempSelectedFilters.containsKey(filter.filter) && - _tempSelectedFilters[filter.filter] == '1'; - return SwitchListTile( - value: isActive, - onChanged: (value) { - setState(() { - if (value) { - _tempSelectedFilters[filter.filter] = '1'; - } else { - _tempSelectedFilters.remove(filter.filter); - } - }); - _applyFilters(); - }, - title: Text(filter.title), - ); - } - - // Regular navigable filters - show selected value instead of checkmark - final selectedValue = _tempSelectedFilters[filter.filter]; - String? displayValue; - if (selectedValue != null) { - // Try to get the cached display name, fall back to the value itself - displayValue = - _filterDisplayNames['${filter.filter}:$selectedValue'] ?? - selectedValue; - } - - return ListTile( - title: Text(filter.title), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (displayValue != null) - Flexible( - child: Text( - displayValue, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w500, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (displayValue != null) const SizedBox(width: 8), - const Icon(Icons.chevron_right), - ], - ), - onTap: () => _loadFilterValues(filter), - ); - }, - ), - ), - ], - ); - }, + }, + ), ); } } diff --git a/lib/widgets/focus/focus_indicator.dart b/lib/widgets/focus/focus_indicator.dart new file mode 100644 index 00000000..b3ee61fd --- /dev/null +++ b/lib/widgets/focus/focus_indicator.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; + +/// A reusable focus indicator widget that wraps a child with visual feedback +/// when focused. Provides consistent focus appearance across the app for +/// keyboard/d-pad/controller navigation. +class FocusIndicator extends StatelessWidget { + final Widget child; + final bool isFocused; + final Color? borderColor; + final double borderWidth; + final double borderRadius; + final double scale; + final Duration animationDuration; + final Curve animationCurve; + + const FocusIndicator({ + super.key, + required this.child, + required this.isFocused, + this.borderColor, + this.borderWidth = 3.0, + this.borderRadius = 8.0, + this.scale = 1.02, + this.animationDuration = const Duration(milliseconds: 150), + this.animationCurve = Curves.easeOutCubic, + }); + + @override + Widget build(BuildContext context) { + final effectiveBorderColor = + borderColor ?? Theme.of(context).colorScheme.primary; + + // Use AnimatedScale for the scale effect (doesn't affect layout) + // and a positioned border overlay that also doesn't affect layout + return AnimatedScale( + scale: isFocused ? scale : 1.0, + duration: animationDuration, + curve: animationCurve, + child: Stack( + clipBehavior: Clip.none, + children: [ + child, + // Border overlay - doesn't affect layout + Positioned.fill( + child: IgnorePointer( + child: AnimatedContainer( + duration: animationDuration, + curve: animationCurve, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(borderRadius), + border: Border.all( + color: isFocused + ? effectiveBorderColor + : Colors.transparent, + width: borderWidth, + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +/// A wrapper widget that manages its own FocusNode and provides focus state +/// to its builder. Use this for items that need focus handling with +/// automatic FocusNode lifecycle management. +class FocusableWrapper extends StatefulWidget { + final Widget Function(BuildContext context, bool isFocused) builder; + final FocusNode? focusNode; + final bool autofocus; + final VoidCallback? onFocused; + final ValueChanged? onKeyEvent; + final String? debugLabel; + + const FocusableWrapper({ + super.key, + required this.builder, + this.focusNode, + this.autofocus = false, + this.onFocused, + this.onKeyEvent, + this.debugLabel, + }); + + @override + State createState() => _FocusableWrapperState(); +} + +class _FocusableWrapperState extends State { + late FocusNode _focusNode; + bool _isFocused = false; + + @override + void initState() { + super.initState(); + _focusNode = widget.focusNode ?? FocusNode(debugLabel: widget.debugLabel); + _focusNode.addListener(_handleFocusChange); + } + + @override + void dispose() { + _focusNode.removeListener(_handleFocusChange); + // Only dispose if we created the node + if (widget.focusNode == null) { + _focusNode.dispose(); + } + super.dispose(); + } + + void _handleFocusChange() { + final hasFocus = _focusNode.hasFocus; + if (_isFocused != hasFocus) { + setState(() { + _isFocused = hasFocus; + }); + if (hasFocus) { + widget.onFocused?.call(); + // Ensure the focused item is visible in scrollable containers + Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ); + } + } + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + widget.onKeyEvent?.call(event); + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + return Focus( + focusNode: _focusNode, + autofocus: widget.autofocus, + onKeyEvent: _handleKeyEvent, + child: widget.builder(context, _isFocused), + ); + } +} diff --git a/lib/widgets/hub_navigation_controller.dart b/lib/widgets/hub_navigation_controller.dart new file mode 100644 index 00000000..d479b67d --- /dev/null +++ b/lib/widgets/hub_navigation_controller.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; + +/// Controller that manages navigation between hub sections. +/// Hub sections register themselves, and MediaCards can request +/// to navigate to adjacent hub sections. +class HubNavigationController extends ChangeNotifier { + final List _registrations = []; + + /// Map of hub ID to last focused item index + final Map _focusMemory = {}; + + /// Register a hub section with the controller + /// If a hub with the same ID is already registered, it will be replaced + /// Registrations are kept sorted by order for consistent navigation + void register(HubSectionRegistration registration) { + // Remove any existing registration with the same hubId + _registrations.removeWhere((r) => r.hubId == registration.hubId); + _registrations.add(registration); + // Sort by order to maintain consistent navigation regardless of registration timing + _registrations.sort((a, b) => a.order.compareTo(b.order)); + } + + /// Unregister a hub section + void unregister(String hubId) { + _registrations.removeWhere((r) => r.hubId == hubId); + _focusMemory.remove(hubId); + } + + /// Remember the focused item index for a hub + void rememberFocusedIndex(String hubId, int index) { + _focusMemory[hubId] = index; + } + + /// Get the remembered focused index for a hub (or 0 if none) + int getRememberedIndex(String hubId) { + return _focusMemory[hubId] ?? 0; + } + + /// Navigate to the next hub section (direction: 1 for down, -1 for up) + /// Returns true if navigation was handled + bool navigateToAdjacentHub(String currentHubId, int direction) { + final currentIndex = _registrations.indexWhere( + (r) => r.hubId == currentHubId, + ); + if (currentIndex == -1) return false; + + final targetIndex = currentIndex + direction; + if (targetIndex < 0 || targetIndex >= _registrations.length) return false; + + final targetHub = _registrations[targetIndex]; + if (targetHub.itemCount == 0) { + // Nothing to focus in the target hub + return false; + } + final rememberedIndex = getRememberedIndex(targetHub.hubId); + + // Focus the remembered item or first item + targetHub.focusItem(rememberedIndex.clamp(0, targetHub.itemCount - 1)); + return true; + } + + /// Focus a specific hub and item by order index + /// [hubIndex] is the index in the sorted list (0 = first hub) + /// [itemIndex] is the item within that hub (0 = first item) + void focusHub(int hubIndex, int itemIndex) { + if (hubIndex < 0 || hubIndex >= _registrations.length) return; + + final hub = _registrations[hubIndex]; + if (hub.itemCount > 0) { + hub.focusItem(itemIndex.clamp(0, hub.itemCount - 1)); + } + } +} + +/// Registration info for a hub section +class HubSectionRegistration { + final String hubId; + final int itemCount; + final int order; // Visual order on screen (lower = higher on screen) + final void Function(int index) focusItem; + + HubSectionRegistration({ + required this.hubId, + required this.itemCount, + required this.focusItem, + this.order = 1000, // Default high order for dynamic hubs + }); +} + +/// InheritedWidget to provide the HubNavigationController down the tree +class HubNavigationScope extends InheritedWidget { + final HubNavigationController controller; + + const HubNavigationScope({ + super.key, + required this.controller, + required super.child, + }); + + static HubNavigationController? of(BuildContext context) { + final scope = context + .dependOnInheritedWidgetOfExactType(); + return scope?.controller; + } + + static HubNavigationController? maybeOf(BuildContext context) { + final scope = context.getInheritedWidgetOfExactType(); + return scope?.controller; + } + + @override + bool updateShouldNotify(HubNavigationScope oldWidget) { + return controller != oldWidget.controller; + } +} diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index ce7f7461..9c2a0136 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -1,19 +1,25 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../models/plex_hub.dart'; import '../screens/hub_detail_screen.dart'; import 'media_card.dart'; import 'horizontal_scroll_with_arrows.dart'; +import 'hub_navigation_controller.dart'; import '../i18n/strings.g.dart'; +import 'focus/focus_indicator.dart'; /// Shared hub section widget used in both discover and library screens /// Displays a hub title with icon and a horizontal scrollable list of items -class HubSection extends StatelessWidget { +class HubSection extends StatefulWidget { final PlexHub hub; final IconData icon; final void Function(String)? onRefresh; final VoidCallback? onRemoveFromContinueWatching; final bool isInContinueWatching; + /// Order for navigation (lower = higher on screen). Default is 1000 for dynamic hubs. + final int navigationOrder; + const HubSection({ super.key, required this.hub, @@ -21,113 +27,283 @@ class HubSection extends StatelessWidget { this.onRefresh, this.onRemoveFromContinueWatching, this.isInContinueWatching = false, + this.navigationOrder = 1000, }); + @override + State createState() => _HubSectionState(); +} + +class _HubSectionState extends State { + late final FocusNode _headerFocusNode; + bool _headerIsFocused = false; + HubNavigationController? _controller; + + /// Focus nodes for each item in the hub + List _itemFocusNodes = []; + String? _registeredHubId; + int? _registeredItemCount; + int? _registeredOrder; + + String get _hubId => widget.hub.hubIdentifier ?? widget.hub.title; + + @override + void initState() { + super.initState(); + _headerFocusNode = FocusNode(); + _headerFocusNode.addListener(_handleHeaderFocusChange); + _createItemFocusNodes(); + } + + void _createItemFocusNodes() { + // Create focus nodes for each item + _itemFocusNodes = List.generate( + widget.hub.items.length, + (index) => FocusNode(debugLabel: 'HubItem_${_hubId}_$index'), + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _registerWithController(); + } + + @override + void didUpdateWidget(HubSection oldWidget) { + super.didUpdateWidget(oldWidget); + // If items changed, recreate focus nodes + if (widget.hub.items.length != _itemFocusNodes.length) { + _disposeItemFocusNodes(); + _createItemFocusNodes(); + } + _registerWithController(); + } + + void _unregisterFromController() { + if (_controller != null && _registeredHubId != null) { + _controller!.unregister(_registeredHubId!); + } + _registeredHubId = null; + _registeredItemCount = null; + _registeredOrder = null; + } + + void _registerWithController() { + final controller = HubNavigationScope.maybeOf(context); + final hubId = _hubId; + final itemCount = widget.hub.items.length; + final order = widget.navigationOrder; + + if (controller != _controller) { + _unregisterFromController(); + _controller = controller; + } + + if (controller == null) return; + + final registrationChanged = + _registeredHubId != hubId || + _registeredItemCount != itemCount || + _registeredOrder != order; + + if (registrationChanged) { + _unregisterFromController(); + controller.register( + HubSectionRegistration( + hubId: hubId, + itemCount: itemCount, + focusItem: _focusItem, + order: order, + ), + ); + _registeredHubId = hubId; + _registeredItemCount = itemCount; + _registeredOrder = order; + } + } + + void _focusItem(int index) { + if (index >= 0 && index < _itemFocusNodes.length) { + _itemFocusNodes[index].requestFocus(); + } + } + + void _disposeItemFocusNodes() { + for (final node in _itemFocusNodes) { + node.dispose(); + } + _itemFocusNodes = []; + } + + @override + void dispose() { + _unregisterFromController(); + _headerFocusNode.removeListener(_handleHeaderFocusChange); + _headerFocusNode.dispose(); + _disposeItemFocusNodes(); + super.dispose(); + } + + void _handleHeaderFocusChange() { + if (_headerIsFocused != _headerFocusNode.hasFocus) { + setState(() { + _headerIsFocused = _headerFocusNode.hasFocus; + }); + } + } + + void _navigateToHubDetail() { + Navigator.push( + context, + MaterialPageRoute(builder: (context) => HubDetailScreen(hub: widget.hub)), + ); + } + + KeyEventResult _handleHeaderKeyEvent(FocusNode node, KeyEvent event) { + if (event is KeyDownEvent && widget.hub.more) { + if (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.space || + event.logicalKey == LogicalKeyboardKey.select || + event.logicalKey == LogicalKeyboardKey.gameButtonA) { + _navigateToHubDetail(); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + } + @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // Hub header - Padding( - padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), - child: InkWell( - onTap: hub.more - ? () { - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => HubDetailScreen(hub: hub), - ), - ); - } - : null, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: Row( - children: [ - Icon(icon), - const SizedBox(width: 8), - Text( - hub.title, - style: Theme.of(context).textTheme.titleLarge, + return FocusTraversalGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Hub header + Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: Focus( + focusNode: _headerFocusNode, + onKeyEvent: _handleHeaderKeyEvent, + canRequestFocus: widget.hub.more, // Only focusable if has "more" + child: FocusIndicator( + isFocused: _headerIsFocused && widget.hub.more, + borderRadius: 8, + child: InkWell( + onTap: widget.hub.more ? _navigateToHubDetail : null, + borderRadius: BorderRadius.circular(8), + focusColor: Colors.transparent, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(widget.icon), + const SizedBox(width: 8), + Text( + widget.hub.title, + style: Theme.of(context).textTheme.titleLarge, + ), + if (widget.hub.more) ...[ + const SizedBox(width: 4), + const Icon(Icons.chevron_right, size: 20), + ], + ], + ), ), - if (hub.more) ...[ - const SizedBox(width: 4), - const Icon(Icons.chevron_right, size: 20), - ], - ], + ), ), ), ), - ), - // Hub items (horizontal scroll) - if (hub.items.isNotEmpty) - LayoutBuilder( - builder: (context, constraints) { - // Responsive card width based on screen size - final screenWidth = constraints.maxWidth; - final cardWidth = screenWidth > 1600 - ? 220.0 - : screenWidth > 1200 - ? 200.0 - : screenWidth > 800 - ? 190.0 - : 160.0; + // Hub items (horizontal scroll) + if (widget.hub.items.isNotEmpty) + LayoutBuilder( + builder: (context, constraints) { + // Responsive card width based on screen size + final screenWidth = constraints.maxWidth; + final cardWidth = screenWidth > 1600 + ? 220.0 + : screenWidth > 1200 + ? 200.0 + : screenWidth > 800 + ? 190.0 + : 160.0; - // MediaCard has 8px padding on all sides (16px total horizontally) - // So actual poster width is cardWidth - 16 - final posterWidth = cardWidth - 16; - // 2:3 poster aspect ratio (height is 1.5x width) - final posterHeight = posterWidth * 1.5; - // Container height = poster + padding + spacing + text - // 8px top padding + posterHeight + 4px spacing + ~26px text + 8px bottom padding - final containerHeight = posterHeight + 46; + // MediaCard has 8px padding on all sides (16px total horizontally) + // So actual poster width is cardWidth - 16 + final posterWidth = cardWidth - 16; + // 2:3 poster aspect ratio (height is 1.5x width) + final posterHeight = posterWidth * 1.5; + // Container height = poster + padding + spacing + text + focus indicator headroom + // 8px top padding + posterHeight + 4px spacing + ~26px text + 8px bottom padding + // + 10px extra for focus indicator border (3px) and scale effect (1.02x) + final containerHeight = posterHeight + 46 + 10; - return SizedBox( - height: containerHeight, - child: HorizontalScrollWithArrows( - builder: (scrollController) => ListView.builder( - controller: scrollController, - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 12), - itemCount: hub.items.length, - itemBuilder: (context, index) { - final item = hub.items[index]; - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 2), - child: MediaCard( - key: Key(item.ratingKey), - item: item, - width: cardWidth, - height: posterHeight, - onRefresh: onRefresh, - onRemoveFromContinueWatching: - onRemoveFromContinueWatching, - forceGridMode: true, - isInContinueWatching: isInContinueWatching, + return SizedBox( + height: containerHeight, + child: ClipRect( + clipBehavior: Clip.none, + child: HorizontalScrollWithArrows( + builder: (scrollController) => FocusTraversalGroup( + child: ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + clipBehavior: + Clip.none, // Allow focus indicator to overflow + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 5, + ), + itemCount: widget.hub.items.length, + itemBuilder: (context, index) { + final item = widget.hub.items[index]; + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 2, + ), + child: MediaCard( + key: Key(item.ratingKey), + item: item, + width: cardWidth, + height: posterHeight, + onRefresh: widget.onRefresh, + onRemoveFromContinueWatching: + widget.onRemoveFromContinueWatching, + forceGridMode: true, + isInContinueWatching: + widget.isInContinueWatching, + focusNode: _itemFocusNodes.length > index + ? _itemFocusNodes[index] + : null, + hubId: _hubId, + itemIndex: index, + ), + ); + }, ), - ); - }, + ), + ), ), - ), - ); - }, - ) - else - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Text( - t.messages.noItemsAvailable, - style: Theme.of( - context, - ).textTheme.bodySmall?.copyWith(color: Colors.grey), + ); + }, + ) + else + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + t.messages.noItemsAvailable, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: Colors.grey), + ), ), - ), - ], + ], + ), ); } } diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 2b4f3552..030ae3f8 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -1,7 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:provider/provider.dart'; +import 'focus/focus_indicator.dart'; +import 'hub_navigation_controller.dart'; import '../client/plex_client.dart'; +import '../mixins/keyboard_long_press_mixin.dart'; import '../models/plex_metadata.dart'; import '../models/plex_playlist.dart'; import '../providers/multi_server_provider.dart'; @@ -32,6 +36,15 @@ class MediaCard extends StatefulWidget { final String? collectionId; // The collection ID if displaying within a collection + /// External FocusNode for hub navigation (provided by HubSection) + final FocusNode? focusNode; + + /// Hub section ID for focus memory tracking + final String? hubId; + + /// Item index within the hub section + final int? itemIndex; + const MediaCard({ super.key, required this.item, @@ -43,6 +56,9 @@ class MediaCard extends StatefulWidget { this.forceGridMode = false, this.isInContinueWatching = false, this.collectionId, + this.focusNode, + this.hubId, + this.itemIndex, }); @override @@ -50,6 +66,12 @@ class MediaCard extends StatefulWidget { } class _MediaCardState extends State { + final _contextMenuKey = GlobalKey(); + + void _showContextMenu() { + _contextMenuKey.currentState?.showContextMenu(context); + } + String _buildSemanticLabel() { final item = widget.item; final itemType = item.type.toLowerCase(); @@ -189,16 +211,22 @@ class _MediaCardState extends State { height: widget.height, semanticLabel: semanticLabel, onTap: () => _handleTap(context), + onLongPress: _showContextMenu, + focusNode: widget.focusNode, + hubId: widget.hubId, + itemIndex: widget.itemIndex, ) : _MediaCardList( item: widget.item, semanticLabel: semanticLabel, onTap: () => _handleTap(context), + onLongPress: _showContextMenu, density: settingsProvider.libraryDensity, ); // Use context menu for both PlexMetadata and PlexPlaylist items return MediaContextMenu( + key: _contextMenuKey, item: widget.item, onRefresh: widget.onRefresh, onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, @@ -212,12 +240,22 @@ class _MediaCardState extends State { } /// Grid layout for media cards -class _MediaCardGrid extends StatelessWidget { +class _MediaCardGrid extends StatefulWidget { final dynamic item; // Can be PlexMetadata or PlexPlaylist final double? width; final double? height; final String semanticLabel; final VoidCallback onTap; + final VoidCallback onLongPress; + + /// External FocusNode for hub navigation (provided by HubSection) + final FocusNode? focusNode; + + /// Hub section ID for focus memory tracking + final String? hubId; + + /// Item index within the hub section + final int? itemIndex; const _MediaCardGrid({ required this.item, @@ -225,139 +263,309 @@ class _MediaCardGrid extends StatelessWidget { this.height, required this.semanticLabel, required this.onTap, + required this.onLongPress, + this.focusNode, + this.hubId, + this.itemIndex, }); + @override + State<_MediaCardGrid> createState() => _MediaCardGridState(); +} + +class _MediaCardGridState extends State<_MediaCardGrid> + with KeyboardLongPressMixin { + FocusNode? _ownFocusNode; + bool _isFocused = false; + + @override + void onKeyboardTap() => widget.onTap(); + + @override + void onKeyboardLongPress() => widget.onLongPress(); + + /// Returns the effective focus node (external if provided, otherwise our own) + FocusNode get _focusNode { + if (widget.focusNode != null) return widget.focusNode!; + _ownFocusNode ??= FocusNode(); + return _ownFocusNode!; + } + + @override + void initState() { + super.initState(); + _focusNode.addListener(_handleFocusChange); + } + + @override + void didUpdateWidget(_MediaCardGrid oldWidget) { + super.didUpdateWidget(oldWidget); + // If focusNode changed, update listener + if (oldWidget.focusNode != widget.focusNode) { + oldWidget.focusNode?.removeListener(_handleFocusChange); + _focusNode.addListener(_handleFocusChange); + } + } + + @override + void dispose() { + _focusNode.removeListener(_handleFocusChange); + // Only dispose if we created the node + _ownFocusNode?.dispose(); + super.dispose(); + } + + void _handleFocusChange() { + if (_isFocused != _focusNode.hasFocus) { + setState(() { + _isFocused = _focusNode.hasFocus; + }); + if (_focusNode.hasFocus) { + // Update focus memory if we're in a hub section + if (widget.hubId != null && widget.itemIndex != null) { + final controller = HubNavigationScope.maybeOf(context); + controller?.rememberFocusedIndex(widget.hubId!, widget.itemIndex!); + } + + // Scroll to center only if item is not fully visible + _scrollToCenterIfNeeded(); + } + } + } + + /// Scrolls to center the item only if it's not already fully visible. + /// This prevents unnecessary scrolling when navigating horizontally + /// within the same row while still centering when scrolling is needed. + void _scrollToCenterIfNeeded() { + // For hub sections (nested scrollables), use simple centering + // The smart scroll logic doesn't work well with horizontal+vertical nesting + if (widget.hubId != null) { + Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ); + return; + } + + // For non-hub contexts (like library browse), use smart scrolling + final scrollable = Scrollable.maybeOf(context); + if (scrollable == null) { + // Fallback to simple centering + Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ); + return; + } + + final renderObject = context.findRenderObject(); + if (renderObject == null || renderObject is! RenderBox) return; + + final box = renderObject; + final scrollRenderObject = scrollable.context.findRenderObject(); + if (scrollRenderObject == null) return; + + // Get item's position relative to the scroll view + final transform = box.getTransformTo(scrollRenderObject); + final itemRect = MatrixUtils.transformRect( + transform, + Offset.zero & box.size, + ); + + // Get viewport bounds + final position = scrollable.position; + final viewportHeight = position.viewportDimension; + + // Check if item is fully visible (with margin for focus indicator) + const focusMargin = 4.0; + final isFullyVisible = + itemRect.top >= focusMargin && + itemRect.bottom <= viewportHeight - focusMargin; + + if (!isFullyVisible) { + // Item is not fully visible, scroll to center it + Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ); + } + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + // Handle long-press detection for activation keys + final longPressResult = handleKeyboardLongPress(event); + if (longPressResult == KeyEventResult.handled) { + return longPressResult; + } + + if (event is KeyDownEvent) { + // Handle up/down for hub navigation + if (widget.hubId != null) { + final controller = HubNavigationScope.maybeOf(context); + if (controller != null) { + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + if (controller.navigateToAdjacentHub(widget.hubId!, -1)) { + return KeyEventResult.handled; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + if (controller.navigateToAdjacentHub(widget.hubId!, 1)) { + return KeyEventResult.handled; + } + } + } + } + } + return KeyEventResult.ignored; + } + @override Widget build(BuildContext context) { - return SizedBox( - width: width, - child: Semantics( - label: semanticLabel, - button: true, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Poster - if (height != null) - SizedBox( - width: double.infinity, - height: height, - child: _buildPosterWithOverlay(context), - ) - else - Expanded(child: _buildPosterWithOverlay(context)), - const SizedBox(height: 4), - // Text content - Column( + return Focus( + focusNode: _focusNode, + onKeyEvent: _handleKeyEvent, + child: FocusIndicator( + isFocused: _isFocused, + borderRadius: 8, + child: SizedBox( + width: widget.width, + child: Semantics( + label: widget.semanticLabel, + button: true, + child: InkWell( + onTap: widget.onTap, + borderRadius: BorderRadius.circular(8), + focusColor: Colors.transparent, // We use our own focus indicator + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, children: [ - Text( - item is PlexPlaylist - ? (item as PlexPlaylist).title - : (item as PlexMetadata).displayTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontWeight: FontWeight.w600, - fontSize: 13, - height: 1.1, - ), - ), - if (item is PlexPlaylist) - Builder( - builder: (context) { - final playlist = item as PlexPlaylist; - if (playlist.leafCount != null && - playlist.leafCount! > 0) { - return Text( - t.playlists.itemCount(count: playlist.leafCount!), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), - ); - } - return const SizedBox.shrink(); - }, + // Poster + if (widget.height != null) + SizedBox( + width: double.infinity, + height: widget.height, + child: _buildPosterWithOverlay(context), ) - else if (item is PlexMetadata) ...[ - Builder( - builder: (context) { - final metadata = item as PlexMetadata; - - // For collections, show item count - if (metadata.type.toLowerCase() == 'collection') { - final count = - metadata.childCount ?? metadata.leafCount; - if (count != null && count > 0) { - return Text( - t.playlists.itemCount(count: count), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), - ); - } - } - - // For other media types, show subtitle/parent/year - if (metadata.displaySubtitle != null) { - return Text( - metadata.displaySubtitle!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, + else + Expanded(child: _buildPosterWithOverlay(context)), + const SizedBox(height: 4), + // Text content + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + widget.item is PlexPlaylist + ? (widget.item as PlexPlaylist).title + : (widget.item as PlexMetadata).displayTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + height: 1.1, + ), + ), + if (widget.item is PlexPlaylist) + Builder( + builder: (context) { + final playlist = widget.item as PlexPlaylist; + if (playlist.leafCount != null && + playlist.leafCount! > 0) { + return Text( + t.playlists.itemCount( + count: playlist.leafCount!, ), - ); - } else if (metadata.parentTitle != null) { - return Text( - metadata.parentTitle!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), - ); - } else if (metadata.year != null) { - return Text( - '${metadata.year}', - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: tokens(context).textMuted, - fontSize: 11, - height: 1.1, - ), - ); - } + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), + ); + } + return const SizedBox.shrink(); + }, + ) + else if (widget.item is PlexMetadata) ...[ + Builder( + builder: (context) { + final metadata = widget.item as PlexMetadata; - return const SizedBox.shrink(); - }, - ), - ], + // For collections, show item count + if (metadata.type.toLowerCase() == 'collection') { + final count = + metadata.childCount ?? metadata.leafCount; + if (count != null && count > 0) { + return Text( + t.playlists.itemCount(count: count), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), + ); + } + } + + // For other media types, show subtitle/parent/year + if (metadata.displaySubtitle != null) { + return Text( + metadata.displaySubtitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), + ); + } else if (metadata.parentTitle != null) { + return Text( + metadata.parentTitle!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), + ); + } else if (metadata.year != null) { + return Text( + '${metadata.year}', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens(context).textMuted, + fontSize: 11, + height: 1.1, + ), + ); + } + + return const SizedBox.shrink(); + }, + ), + ], + ], + ), ], ), - ], + ), ), ), ), @@ -370,30 +578,129 @@ class _MediaCardGrid extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(8), - child: _buildPosterImage(context, item), + child: _buildPosterImage(context, widget.item), ), - _PosterOverlay(item: item), + _PosterOverlay(item: widget.item), ], ); } } /// List layout for media cards -class _MediaCardList extends StatelessWidget { +class _MediaCardList extends StatefulWidget { final dynamic item; // Can be PlexMetadata or PlexPlaylist final String semanticLabel; final VoidCallback onTap; + final VoidCallback onLongPress; final LibraryDensity density; const _MediaCardList({ required this.item, required this.semanticLabel, required this.onTap, + required this.onLongPress, required this.density, }); + @override + State<_MediaCardList> createState() => _MediaCardListState(); +} + +class _MediaCardListState extends State<_MediaCardList> + with KeyboardLongPressMixin { + late final FocusNode _focusNode; + bool _isFocused = false; + + @override + void onKeyboardTap() => widget.onTap(); + + @override + void onKeyboardLongPress() => widget.onLongPress(); + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(_handleFocusChange); + } + + @override + void dispose() { + _focusNode.removeListener(_handleFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + void _handleFocusChange() { + if (_isFocused != _focusNode.hasFocus) { + setState(() { + _isFocused = _focusNode.hasFocus; + }); + if (_focusNode.hasFocus) { + // Scroll to center only if item is not fully visible + _scrollToCenterIfNeeded(); + } + } + } + + /// Scrolls to center the item only if it's not already fully visible. + /// This prevents unnecessary scrolling when navigating horizontally + /// within the same row while still centering when scrolling is needed. + void _scrollToCenterIfNeeded() { + final scrollable = Scrollable.maybeOf(context); + if (scrollable == null) { + // Fallback to simple centering + Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ); + return; + } + + final renderObject = context.findRenderObject(); + if (renderObject == null || renderObject is! RenderBox) return; + + final box = renderObject; + final scrollRenderObject = scrollable.context.findRenderObject(); + if (scrollRenderObject == null) return; + + // Get item's position relative to the scroll view + final transform = box.getTransformTo(scrollRenderObject); + final itemRect = MatrixUtils.transformRect( + transform, + Offset.zero & box.size, + ); + + // Get viewport bounds + final position = scrollable.position; + final viewportHeight = position.viewportDimension; + + // Check if item is fully visible (with margin for focus indicator) + const focusMargin = 4.0; + final isFullyVisible = + itemRect.top >= focusMargin && + itemRect.bottom <= viewportHeight - focusMargin; + + if (!isFullyVisible) { + // Item is not fully visible, scroll to center it + Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ); + } + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + // Handle long-press detection for activation keys + return handleKeyboardLongPress(event); + } + double get _posterWidth { - switch (density) { + switch (widget.density) { case LibraryDensity.compact: return 80; case LibraryDensity.normal: @@ -408,7 +715,7 @@ class _MediaCardList extends StatelessWidget { } double get _titleFontSize { - switch (density) { + switch (widget.density) { case LibraryDensity.compact: return 14; case LibraryDensity.normal: @@ -419,7 +726,7 @@ class _MediaCardList extends StatelessWidget { } double get _metadataFontSize { - switch (density) { + switch (widget.density) { case LibraryDensity.compact: return 11; case LibraryDensity.normal: @@ -430,7 +737,7 @@ class _MediaCardList extends StatelessWidget { } double get _subtitleFontSize { - switch (density) { + switch (widget.density) { case LibraryDensity.compact: return 12; case LibraryDensity.normal: @@ -446,7 +753,7 @@ class _MediaCardList extends StatelessWidget { } int get _summaryMaxLines { - switch (density) { + switch (widget.density) { case LibraryDensity.compact: return 2; case LibraryDensity.normal: @@ -459,8 +766,8 @@ class _MediaCardList extends StatelessWidget { String _buildMetadataLine() { final parts = []; - if (item is PlexPlaylist) { - final playlist = item as PlexPlaylist; + if (widget.item is PlexPlaylist) { + final playlist = widget.item as PlexPlaylist; // Add item count if (playlist.leafCount != null && playlist.leafCount! > 0) { parts.add(t.playlists.itemCount(count: playlist.leafCount!)); @@ -475,8 +782,8 @@ class _MediaCardList extends StatelessWidget { if (playlist.smart) { parts.add(t.playlists.smartPlaylist); } - } else if (item is PlexMetadata) { - final metadata = item as PlexMetadata; + } else if (widget.item is PlexMetadata) { + final metadata = widget.item as PlexMetadata; // For collections, show item count if (metadata.type.toLowerCase() == 'collection') { @@ -521,11 +828,11 @@ class _MediaCardList extends StatelessWidget { } String? _buildSubtitleText() { - if (item is PlexPlaylist) { + if (widget.item is PlexPlaylist) { // Playlists don't have subtitles return null; - } else if (item is PlexMetadata) { - final metadata = item as PlexMetadata; + } else if (widget.item is PlexMetadata) { + final metadata = widget.item as PlexMetadata; // For TV episodes, show S#E# format if (metadata.parentIndex != null && metadata.index != null) { @@ -549,100 +856,112 @@ class _MediaCardList extends StatelessWidget { final metadataLine = _buildMetadataLine(); final subtitle = _buildSubtitleText(); - return Semantics( - label: semanticLabel, - button: true, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.all(8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Poster (responsive size based on density) - SizedBox( - width: _posterWidth, - height: _posterHeight, - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: _buildPosterImage(context, item), + return Focus( + focusNode: _focusNode, + onKeyEvent: _handleKeyEvent, + child: FocusIndicator( + isFocused: _isFocused, + borderRadius: 8, + child: Semantics( + label: widget.semanticLabel, + button: true, + child: InkWell( + onTap: widget.onTap, + borderRadius: BorderRadius.circular(8), + focusColor: Colors.transparent, // We use our own focus indicator + child: Padding( + padding: const EdgeInsets.all(8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Poster (responsive size based on density) + SizedBox( + width: _posterWidth, + height: _posterHeight, + child: Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: _buildPosterImage(context, widget.item), + ), + _PosterOverlay(item: widget.item), + ], ), - _PosterOverlay(item: item), - ], - ), - ), - const SizedBox(width: 12), - // Metadata - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.start, - children: [ - // Title - Text( - item.displayTitle, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontWeight: FontWeight.w600, - fontSize: _titleFontSize, - height: 1.2, - ), + ), + const SizedBox(width: 12), + // Metadata + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + // Title + Text( + widget.item.displayTitle, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: _titleFontSize, + height: 1.2, + ), + ), + const SizedBox(height: 4), + // Metadata info line (rating, duration, score, studio) + if (metadataLine.isNotEmpty) ...[ + Text( + metadataLine, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens( + context, + ).textMuted.withValues(alpha: 0.9), + fontSize: _metadataFontSize, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + ], + // Subtitle (S#E# or year/parent title) + if (subtitle != null) ...[ + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens( + context, + ).textMuted.withValues(alpha: 0.85), + fontSize: _subtitleFontSize, + ), + ), + const SizedBox(height: 4), + ], + // Summary + if (widget.item.summary != null) ...[ + Text( + widget.item.summary!, + maxLines: _summaryMaxLines, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: tokens( + context, + ).textMuted.withValues(alpha: 0.7), + fontSize: _summaryFontSize, + height: 1.3, + ), + ), + ], + ], ), - const SizedBox(height: 4), - // Metadata info line (rating, duration, score, studio) - if (metadataLine.isNotEmpty) ...[ - Text( - metadataLine, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens( - context, - ).textMuted.withValues(alpha: 0.9), - fontSize: _metadataFontSize, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - ], - // Subtitle (S#E# or year/parent title) - if (subtitle != null) ...[ - Text( - subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens( - context, - ).textMuted.withValues(alpha: 0.85), - fontSize: _subtitleFontSize, - ), - ), - const SizedBox(height: 4), - ], - // Summary - if (item.summary != null) ...[ - Text( - item.summary!, - maxLines: _summaryMaxLines, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens( - context, - ).textMuted.withValues(alpha: 0.7), - fontSize: _summaryFontSize, - height: 1.3, - ), - ), - ], - ], - ), + ), + ], ), - ], + ), ), ), ), diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index d43fe82f..5d36864e 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../client/plex_client.dart'; import '../models/plex_metadata.dart'; @@ -9,6 +10,7 @@ import '../providers/playback_state_provider.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; import '../utils/collection_playlist_play_helper.dart'; +import '../utils/keyboard_utils.dart'; import '../utils/library_refresh_notifier.dart'; import '../utils/video_player_navigation.dart'; import '../screens/media_detail_screen.dart'; @@ -51,16 +53,40 @@ class MediaContextMenu extends StatefulWidget { }); @override - State createState() => _MediaContextMenuState(); + State createState() => MediaContextMenuState(); } -class _MediaContextMenuState extends State { +class MediaContextMenuState extends State { Offset? _tapPosition; void _storeTapPosition(TapDownDetails details) { _tapPosition = details.globalPosition; } + bool _openedFromKeyboard = false; + + /// Show the context menu programmatically. + /// Used for keyboard/gamepad long-press activation. + /// If [position] is null, the menu will appear at the center of this widget. + void showContextMenu(BuildContext menuContext, {Offset? position}) { + _openedFromKeyboard = true; + if (position != null) { + _tapPosition = position; + } else { + // Calculate center of the widget for keyboard activation + final RenderBox? renderBox = context.findRenderObject() as RenderBox?; + if (renderBox != null) { + final size = renderBox.size; + final topLeft = renderBox.localToGlobal(Offset.zero); + _tapPosition = Offset( + topLeft.dx + size.width / 2, + topLeft.dy + size.height / 2, + ); + } + } + _showContextMenu(menuContext); + } + /// Get the correct PlexClient for this item's server PlexClient _getClientForItem() { String? serverId; @@ -243,51 +269,21 @@ class _MediaContextMenuState extends State { String? selected; + final openedFromKeyboard = _openedFromKeyboard; + _openedFromKeyboard = false; + if (useBottomSheet) { // Show bottom sheet on mobile selected = await showModalBottomSheet( context: context, - builder: (context) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(16.0), - child: Text( - widget.item.title, - style: Theme.of(context).textTheme.titleMedium, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ...menuActions.map( - (action) => ListTile( - leading: Icon(action.icon), - title: Text(action.label), - onTap: () => Navigator.pop(context, action.value), - ), - ), - ], - ), + builder: (context) => _FocusableContextMenuSheet( + title: widget.item.title, + actions: menuActions, + focusFirstItem: openedFromKeyboard, ), ); } else { - // Show popup menu on larger screens - final menuItems = menuActions - .map( - (action) => PopupMenuItem( - value: action.value, - child: Row( - children: [ - Icon(action.icon), - const SizedBox(width: 12), - Expanded(child: Text(action.label)), - ], - ), - ), - ) - .toList(); - + // Show custom focusable popup menu on larger screens // Use stored tap position or fallback to widget position final RenderBox? overlay = Overlay.of(context).context.findRenderObject() as RenderBox?; @@ -300,27 +296,13 @@ class _MediaContextMenuState extends State { position = renderBox.localToGlobal(Offset.zero, ancestor: overlay); } - // Calculate position for menu using RelativeRect - final overlayRect = RelativeRect.fromLTRB( - position.dx, - position.dy, - position.dx + 1, - position.dy + 1, - ); - - // Use showMenu with fast animations via PopupMenuTheme - selected = await showMenu( + selected = await showDialog( context: context, - position: overlayRect, - items: menuItems, - elevation: 8, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), - menuPadding: EdgeInsets.zero, - // Override animation duration for faster animations - popUpAnimationStyle: AnimationStyle( - duration: const Duration(milliseconds: 150), - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeIn, + barrierColor: Colors.transparent, + builder: (dialogContext) => _FocusablePopupMenu( + actions: menuActions, + position: position, + focusFirstItem: openedFromKeyboard, ), ); } @@ -1441,3 +1423,312 @@ class _CreateCollectionDialogState extends State<_CreateCollectionDialog> { ); } } + +/// Focusable context menu sheet for keyboard/gamepad navigation (mobile) +class _FocusableContextMenuSheet extends StatefulWidget { + final String title; + final List<_MenuAction> actions; + final bool focusFirstItem; + + const _FocusableContextMenuSheet({ + required this.title, + required this.actions, + this.focusFirstItem = false, + }); + + @override + State<_FocusableContextMenuSheet> createState() => + _FocusableContextMenuSheetState(); +} + +class _FocusableContextMenuSheetState + extends State<_FocusableContextMenuSheet> { + late List _focusNodes; + int _focusedIndex = 0; + + @override + void initState() { + super.initState(); + _focusNodes = List.generate( + widget.actions.length, + (index) => FocusNode(debugLabel: 'ContextMenuItem$index'), + ); + + if (widget.focusFirstItem && widget.actions.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNodes[0].requestFocus(); + }); + } + } + + @override + void dispose() { + for (final node in _focusNodes) { + node.dispose(); + } + super.dispose(); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + // Close on back keys + if (isBackKey(event.logicalKey)) { + Navigator.pop(context); + return KeyEventResult.handled; + } + + // Navigate with arrow keys + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + if (_focusedIndex > 0) { + _focusedIndex--; + _focusNodes[_focusedIndex].requestFocus(); + } + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + if (_focusedIndex < widget.actions.length - 1) { + _focusedIndex++; + _focusNodes[_focusedIndex].requestFocus(); + } + return KeyEventResult.handled; + } + + // Select with Enter/Space + if (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.space || + event.logicalKey == LogicalKeyboardKey.select || + event.logicalKey == LogicalKeyboardKey.gameButtonA) { + Navigator.pop(context, widget.actions[_focusedIndex].value); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + return Focus( + onKeyEvent: _handleKeyEvent, + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + widget.title, + style: Theme.of(context).textTheme.titleMedium, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ...widget.actions.asMap().entries.map((entry) { + final index = entry.key; + final action = entry.value; + return Focus( + focusNode: _focusNodes[index], + onFocusChange: (hasFocus) { + if (hasFocus) { + setState(() => _focusedIndex = index); + } + }, + child: Builder( + builder: (context) { + final isFocused = Focus.of(context).hasFocus; + return ListTile( + leading: Icon(action.icon), + title: Text(action.label), + onTap: () => Navigator.pop(context, action.value), + selected: isFocused, + selectedTileColor: Theme.of( + context, + ).colorScheme.primary.withOpacity(0.1), + ); + }, + ), + ); + }), + ], + ), + ), + ); + } +} + +/// Focusable popup menu for keyboard/gamepad navigation (desktop) +class _FocusablePopupMenu extends StatefulWidget { + final List<_MenuAction> actions; + final Offset position; + final bool focusFirstItem; + + const _FocusablePopupMenu({ + required this.actions, + required this.position, + this.focusFirstItem = false, + }); + + @override + State<_FocusablePopupMenu> createState() => _FocusablePopupMenuState(); +} + +class _FocusablePopupMenuState extends State<_FocusablePopupMenu> { + late List _focusNodes; + int _focusedIndex = 0; + + @override + void initState() { + super.initState(); + _focusNodes = List.generate( + widget.actions.length, + (index) => FocusNode(debugLabel: 'PopupMenuItem$index'), + ); + + if (widget.focusFirstItem && widget.actions.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _focusNodes[0].requestFocus(); + }); + } + } + + @override + void dispose() { + for (final node in _focusNodes) { + node.dispose(); + } + super.dispose(); + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + + // Close on back keys + if (isBackKey(event.logicalKey)) { + Navigator.pop(context); + return KeyEventResult.handled; + } + + // Navigate with arrow keys + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + if (_focusedIndex > 0) { + _focusedIndex--; + _focusNodes[_focusedIndex].requestFocus(); + } + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + if (_focusedIndex < widget.actions.length - 1) { + _focusedIndex++; + _focusNodes[_focusedIndex].requestFocus(); + } + return KeyEventResult.handled; + } + + // Select with Enter/Space + if (event.logicalKey == LogicalKeyboardKey.enter || + event.logicalKey == LogicalKeyboardKey.space || + event.logicalKey == LogicalKeyboardKey.select || + event.logicalKey == LogicalKeyboardKey.gameButtonA) { + Navigator.pop(context, widget.actions[_focusedIndex].value); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) { + final screenSize = MediaQuery.of(context).size; + const menuWidth = 220.0; + + // Calculate menu position, keeping it on screen + double left = widget.position.dx; + double top = widget.position.dy; + + // Adjust if menu would go off right edge + if (left + menuWidth > screenSize.width) { + left = screenSize.width - menuWidth - 8; + } + + // Estimate menu height and adjust if would go off bottom + final estimatedHeight = widget.actions.length * 48.0 + 16; + if (top + estimatedHeight > screenSize.height) { + top = screenSize.height - estimatedHeight - 8; + } + + return Stack( + children: [ + // Barrier to close menu when clicking outside + Positioned.fill( + child: GestureDetector( + onTap: () => Navigator.pop(context), + behavior: HitTestBehavior.opaque, + child: Container(color: Colors.transparent), + ), + ), + // Menu + Positioned( + left: left, + top: top, + child: Focus( + autofocus: !widget.focusFirstItem, + onKeyEvent: _handleKeyEvent, + child: Material( + elevation: 8, + borderRadius: BorderRadius.circular(8), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: const BoxConstraints( + minWidth: menuWidth, + maxWidth: menuWidth, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: widget.actions.asMap().entries.map((entry) { + final index = entry.key; + final action = entry.value; + return Focus( + focusNode: _focusNodes[index], + onFocusChange: (hasFocus) { + if (hasFocus) { + setState(() => _focusedIndex = index); + } + }, + child: Builder( + builder: (context) { + final isFocused = Focus.of(context).hasFocus; + return InkWell( + onTap: () => Navigator.pop(context, action.value), + child: Container( + color: isFocused + ? Theme.of( + context, + ).colorScheme.primary.withOpacity(0.1) + : null, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + child: Row( + children: [ + Icon(action.icon, size: 20), + const SizedBox(width: 12), + Expanded(child: Text(action.label)), + ], + ), + ), + ); + }, + ), + ); + }).toList(), + ), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/playlist_item_card.dart b/lib/widgets/playlist_item_card.dart index f2894d0e..f80e983d 100644 --- a/lib/widgets/playlist_item_card.dart +++ b/lib/widgets/playlist_item_card.dart @@ -1,18 +1,23 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:cached_network_image/cached_network_image.dart'; import '../client/plex_client.dart'; +import '../mixins/keyboard_long_press_mixin.dart'; import '../models/plex_metadata.dart'; import '../utils/duration_formatter.dart'; import '../utils/provider_extensions.dart'; import '../i18n/strings.g.dart'; +import 'focus/focus_indicator.dart'; +import 'media_context_menu.dart'; /// Custom list item widget for playlist items /// Shows drag handle, poster, title/metadata, duration, and remove button -class PlaylistItemCard extends StatelessWidget { +class PlaylistItemCard extends StatefulWidget { final PlexMetadata item; final int index; final VoidCallback onRemove; final VoidCallback? onTap; + final void Function(String ratingKey)? onRefresh; final bool canReorder; // Whether drag handle should be shown const PlaylistItemCard({ @@ -21,97 +26,170 @@ class PlaylistItemCard extends StatelessWidget { required this.index, required this.onRemove, this.onTap, + this.onRefresh, this.canReorder = true, }); + @override + State createState() => _PlaylistItemCardState(); +} + +class _PlaylistItemCardState extends State + with KeyboardLongPressMixin { + late final FocusNode _focusNode; + bool _isFocused = false; + final _contextMenuKey = GlobalKey(); + + @override + void onKeyboardTap() => widget.onTap?.call(); + + @override + void onKeyboardLongPress() { + _contextMenuKey.currentState?.showContextMenu(context); + } + + @override + void initState() { + super.initState(); + _focusNode = FocusNode(); + _focusNode.addListener(_handleFocusChange); + } + + @override + void dispose() { + _focusNode.removeListener(_handleFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + void _handleFocusChange() { + if (_isFocused != _focusNode.hasFocus) { + setState(() { + _isFocused = _focusNode.hasFocus; + }); + if (_focusNode.hasFocus) { + Scrollable.ensureVisible( + context, + alignment: 0.5, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + ); + } + } + } + + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + // Handle long-press detection for activation keys + return handleKeyboardLongPress(event); + } + @override Widget build(BuildContext context) { - return Card( - margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: InkWell( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - // Drag handle (if reorderable) - if (canReorder) - ReorderableDragStartListener( - index: index, - child: const Padding( - padding: EdgeInsets.only(right: 12), - child: Icon(Icons.drag_indicator, color: Colors.grey), - ), - ), - - // Poster thumbnail - _buildPosterImage(context), - - const SizedBox(width: 12), - - // Title and metadata - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, + final item = widget.item; + return MediaContextMenu( + key: _contextMenuKey, + item: item, + onRefresh: widget.onRefresh, + onTap: widget.onTap, + child: Focus( + focusNode: _focusNode, + onKeyEvent: _handleKeyEvent, + child: FocusIndicator( + isFocused: _isFocused, + borderRadius: 12, + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: InkWell( + onTap: widget.onTap, + focusColor: Colors.transparent, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( children: [ - // Title - Text( - item.displayTitle, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - - const SizedBox(height: 4), - - // Subtitle (episode info or type) - Text( - _buildSubtitle(), - style: TextStyle(fontSize: 13, color: Colors.grey[400]), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - - // Progress indicator if partially watched - if (item.viewOffset != null && item.duration != null) - Padding( - padding: const EdgeInsets.only(top: 6), - child: LinearProgressIndicator( - value: item.viewOffset! / item.duration!, - backgroundColor: Colors.grey[800], - valueColor: AlwaysStoppedAnimation( - Theme.of(context).colorScheme.primary, - ), - minHeight: 3, + // Drag handle (if reorderable) + if (widget.canReorder) + ReorderableDragStartListener( + index: widget.index, + child: const Padding( + padding: EdgeInsets.only(right: 12), + child: Icon(Icons.drag_indicator, color: Colors.grey), ), ), + + // Poster thumbnail + _buildPosterImage(context), + + const SizedBox(width: 12), + + // Title and metadata + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Title + Text( + item.displayTitle, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + + const SizedBox(height: 4), + + // Subtitle (episode info or type) + Text( + _buildSubtitle(), + style: TextStyle( + fontSize: 13, + color: Colors.grey[400], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + + // Progress indicator if partially watched + if (item.viewOffset != null && item.duration != null) + Padding( + padding: const EdgeInsets.only(top: 6), + child: LinearProgressIndicator( + value: item.viewOffset! / item.duration!, + backgroundColor: Colors.grey[800], + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.primary, + ), + minHeight: 3, + ), + ), + ], + ), + ), + + const SizedBox(width: 12), + + // Duration + if (item.duration != null) + Text( + formatDurationTextual(item.duration!), + style: TextStyle(fontSize: 13, color: Colors.grey[400]), + ), + + const SizedBox(width: 8), + + // Remove button + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: widget.onRemove, + tooltip: t.playlists.removeItem, + color: Colors.grey[400], + ), ], ), ), - - const SizedBox(width: 12), - - // Duration - if (item.duration != null) - Text( - formatDurationTextual(item.duration!), - style: TextStyle(fontSize: 13, color: Colors.grey[400]), - ), - - const SizedBox(width: 8), - - // Remove button - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: onRemove, - tooltip: t.playlists.removeItem, - color: Colors.grey[400], - ), - ], + ), ), ), ), @@ -120,11 +198,11 @@ class PlaylistItemCard extends StatelessWidget { /// Get the correct PlexClient for this item's server PlexClient _getClientForItem(BuildContext context) { - return context.getClientForServer(item.serverId!); + return context.getClientForServer(widget.item.serverId!); } Widget _buildPosterImage(BuildContext context) { - final posterUrl = item.posterThumb(); + final posterUrl = widget.item.posterThumb(); if (posterUrl != null) { return Builder( builder: (context) { @@ -160,6 +238,7 @@ class PlaylistItemCard extends StatelessWidget { } String _buildSubtitle() { + final item = widget.item; final itemType = item.type.toLowerCase(); if (itemType == 'episode') { diff --git a/lib/widgets/sort_bottom_sheet.dart b/lib/widgets/sort_bottom_sheet.dart index 1606b430..ffe3ff87 100644 --- a/lib/widgets/sort_bottom_sheet.dart +++ b/lib/widgets/sort_bottom_sheet.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import '../models/plex_sort.dart'; import '../widgets/bottom_sheet_header.dart'; +import '../utils/keyboard_utils.dart'; import '../i18n/strings.g.dart'; class SortBottomSheet extends StatefulWidget { @@ -26,12 +28,23 @@ class SortBottomSheet extends StatefulWidget { class _SortBottomSheetState extends State { late PlexSort? _currentSort; late bool _currentDescending; + final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'SortFirstItem'); @override void initState() { super.initState(); _currentSort = widget.selectedSort; _currentDescending = widget.isSortDescending; + // Focus the first item after build + WidgetsBinding.instance.addPostFrameCallback((_) { + _firstItemFocusNode.requestFocus(); + }); + } + + @override + void dispose() { + _firstItemFocusNode.dispose(); + super.dispose(); } void _handleSortChange(PlexSort sort, bool descending) { @@ -54,46 +67,68 @@ class _SortBottomSheetState extends State { @override Widget build(BuildContext context) { - return DraggableScrollableSheet( - initialChildSize: 0.6, - minChildSize: 0.4, - maxChildSize: 0.9, - expand: false, - builder: (context, scrollController) { - return Column( - children: [ - BottomSheetHeader( - title: t.libraries.sortBy, - action: widget.onClear != null - ? TextButton( - onPressed: _handleClear, - child: Text(t.common.clear), - ) - : null, - ), - Expanded( - child: RadioGroup( - groupValue: _currentSort, - onChanged: (PlexSort? value) { - if (value != null) { - _handleSortChange(value, value.isDefaultDescending); - } - }, - child: ListView.builder( - controller: scrollController, - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: widget.sortOptions.length, - itemBuilder: (context, index) { - final sort = widget.sortOptions[index]; - final isSelected = _currentSort?.key == sort.key; + return Focus( + autofocus: true, + onKeyEvent: (node, event) { + if (isBackKeyEvent(event)) { + Navigator.pop(context); + return KeyEventResult.handled; + } + // Left/right arrows toggle sort direction when a sort is selected + if (event is KeyDownEvent && _currentSort != null) { + if (event.logicalKey == LogicalKeyboardKey.arrowLeft && + _currentDescending) { + _handleSortChange(_currentSort!, false); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowRight && + !_currentDescending) { + _handleSortChange(_currentSort!, true); + return KeyEventResult.handled; + } + } + return KeyEventResult.ignored; + }, + child: DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.4, + maxChildSize: 0.9, + expand: false, + builder: (context, scrollController) { + return Column( + children: [ + BottomSheetHeader( + title: t.libraries.sortBy, + action: widget.onClear != null + ? TextButton( + onPressed: _handleClear, + child: Text(t.common.clear), + ) + : null, + ), + Expanded( + child: FocusTraversalGroup( + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: widget.sortOptions.length, + itemBuilder: (context, index) { + final sort = widget.sortOptions[index]; + final isSelected = _currentSort?.key == sort.key; - return ListTile( - title: Text(sort.title), - trailing: isSelected - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - SegmentedButton( + return RadioListTile( + focusNode: index == 0 ? _firstItemFocusNode : null, + title: Text(sort.title), + value: sort, + groupValue: _currentSort, + onChanged: (value) { + if (value != null) { + _handleSortChange(value, value.isDefaultDescending); + } + }, + secondary: isSelected + ? ExcludeFocus( + child: SegmentedButton( showSelectedIcon: false, segments: const [ ButtonSegment( @@ -113,21 +148,17 @@ class _SortBottomSheetState extends State { _handleSortChange(sort, newSelection.first); }, ), - ], - ) - : null, - leading: Radio(value: sort, toggleable: false), - onTap: () { - _handleSortChange(sort, sort.isDefaultDescending); - }, - ); - }, + ) + : null, + ); + }, + ), ), ), - ), - ], - ); - }, + ], + ); + }, + ), ); } } diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 59de7720..b8216faa 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -584,6 +584,7 @@ class _PlexVideoControlsState extends State _nextSubtitleTrack, _nextChapter, _previousChapter, + onBack: () => Navigator.of(context).pop(true), ); }, child: MouseRegion(