From 7b0aa12c38be8d5050ccecaf5555bfe93c98c262 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Fri, 5 Dec 2025 23:29:37 +0100 Subject: [PATCH] refactor: remove keyboard navigation --- lib/mixins/keyboard_long_press_mixin.dart | 102 -- lib/screens/discover_screen.dart | 730 ++++---- .../libraries/adaptive_media_grid.dart | 68 +- .../libraries/filters_bottom_sheet.dart | 344 ++-- lib/screens/libraries/folder_tree_item.dart | 173 +- lib/screens/libraries/folder_tree_view.dart | 13 +- lib/screens/libraries/libraries_screen.dart | 1181 +++--------- lib/screens/libraries/sort_bottom_sheet.dart | 163 +- .../libraries/tabs/base_library_tab.dart | 13 - .../libraries/tabs/library_browse_tab.dart | 147 +- .../tabs/library_collections_tab.dart | 19 - .../libraries/tabs/library_playlists_tab.dart | 19 - .../tabs/library_recommended_tab.dart | 54 +- lib/screens/main_screen.dart | 172 +- lib/screens/media_detail_screen.dart | 1603 ++++++++--------- lib/screens/playlist/playlist_item_card.dart | 180 +- lib/screens/search_screen.dart | 293 ++- lib/screens/season_detail_screen.dart | 597 +++--- lib/screens/settings/settings_screen.dart | 60 +- lib/services/keyboard_shortcuts_service.dart | 5 +- lib/utils/keyboard_utils.dart | 23 - lib/widgets/focus/focus_indicator.dart | 151 -- lib/widgets/hub_navigation_controller.dart | 115 -- lib/widgets/hub_section.dart | 365 +--- lib/widgets/media_card.dart | 754 +++----- lib/widgets/media_context_menu.dart | 271 +-- 26 files changed, 2600 insertions(+), 5015 deletions(-) delete mode 100644 lib/mixins/keyboard_long_press_mixin.dart delete mode 100644 lib/utils/keyboard_utils.dart delete mode 100644 lib/widgets/focus/focus_indicator.dart delete 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 deleted file mode 100644 index cc89c1fe..00000000 --- a/lib/mixins/keyboard_long_press_mixin.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import '../utils/keyboard_utils.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(); - - /// 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 (!isKeyboardActivationKey(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 e7b3341e..9f7e405b 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:cached_network_image/cached_network_image.dart'; import '../../services/plex_client.dart'; @@ -14,7 +13,6 @@ import '../providers/playback_state_provider.dart'; import '../widgets/desktop_app_bar.dart'; import 'profile/user_avatar_widget.dart'; import '../widgets/hub_section.dart'; -import '../widgets/hub_navigation_controller.dart'; import 'profile/profile_switch_screen.dart'; import '../providers/user_profile_provider.dart'; import '../providers/settings_provider.dart'; @@ -22,12 +20,10 @@ 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; @@ -59,7 +55,6 @@ class _DiscoverScreenState extends State List _onDeck = []; List _hubs = []; bool _isLoading = true; - bool _isInitialLoad = true; bool _areHubsLoading = true; String? _errorMessage; final PageController _heroController = PageController(); @@ -68,10 +63,6 @@ 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) { @@ -99,89 +90,16 @@ 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 (isKeyboardActivationKey(event.logicalKey)) { - 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(); } @@ -307,16 +225,6 @@ class _DiscoverScreenState extends State _currentHeroIndex = 0; }); - // Focus the hero on initial load - if (_isInitialLoad && onDeck.isNotEmpty) { - _isInitialLoad = false; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _heroFocusNode.requestFocus(); - } - }); - } - // Sync PageController to first page after OnDeck loads if (_heroController.hasClients && onDeck.isNotEmpty) { _heroController.jumpToPage(0); @@ -417,13 +325,6 @@ 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(); @@ -604,360 +505,337 @@ class _DiscoverScreenState extends State Widget build(BuildContext context) { return Scaffold( body: SafeArea( - 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), - ], - ), - ), - ], - ); - }, - ), - ], + 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, ), - if (_isLoading) - const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), + 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), + ], + ), + ), + ], + ); + }, + ), + ], + ), + 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 (_errorMessage != null) - SliverFillRemaining( - child: Center( + ), + ), + 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, + ), + ), + + // 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, + ), + ), + + // Show loading skeleton for hubs while they're loading + if (_areHubsLoading && _hubs.isEmpty) + for (int i = 0; i < 3; i++) + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.all(16), child: Column( - mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon( - Icons.error_outline, - size: 48, - color: Colors.red, + // Hub title skeleton + Container( + width: 200, + height: 24, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), ), const SizedBox(height: 16), - Text(_errorMessage!), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadContent, - child: Text(t.common.retry), + // Hub items skeleton + SizedBox( + height: 200, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: 5, + itemBuilder: (context, index) { + return Container( + margin: const EdgeInsets.only(right: 12), + width: 140, + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + ); + }, + ), ), ], ), ), ), - 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()); - }, + + if (_onDeck.isEmpty && _hubs.isEmpty && !_areHubsLoading) + 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), + ), + ], + ), ), + ), - // 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 - ), - ), - - // Show loading skeleton for hubs while they're loading - if (_areHubsLoading && _hubs.isEmpty) - for (int i = 0; i < 3; i++) - SliverToBoxAdapter( - child: Container( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Hub title skeleton - Container( - width: 200, - height: 24, - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(height: 16), - // Hub items skeleton - SizedBox( - height: 200, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: 5, - itemBuilder: (context, index) { - return Container( - margin: const EdgeInsets.only(right: 12), - width: 140, - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - ); - }, - ), - ), - ], - ), - ), - ), - - if (_onDeck.isEmpty && _hubs.isEmpty && !_areHubsLoading) - 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)), - ], - ], - ), - ), + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], + ], ), ), ); } Widget _buildHeroSection() { - // 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 - ), - ); - 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', - ), + 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, - ); + ), + // 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, + 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, ), - 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, - ), + ), + 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/adaptive_media_grid.dart b/lib/screens/libraries/adaptive_media_grid.dart index 9a9048ca..0cdae438 100644 --- a/lib/screens/libraries/adaptive_media_grid.dart +++ b/lib/screens/libraries/adaptive_media_grid.dart @@ -22,16 +22,12 @@ 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 @@ -39,45 +35,39 @@ class AdaptiveMediaGrid extends StatelessWidget { return Consumer( builder: (context, settingsProvider, child) { if (settingsProvider.viewMode == ViewMode.list) { - 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, - ); - }, - ), + 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, + ); + }, ); } else { - return FocusTraversalGroup( - child: GridView.builder( - padding: padding, - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( - context, - settingsProvider.libraryDensity, - ), - childAspectRatio: childAspectRatio, - crossAxisSpacing: 0, - mainAxisSpacing: 0, + return GridView.builder( + padding: padding, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, ), - 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, - ); - }, + 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, + ); + }, ); } }, diff --git a/lib/screens/libraries/filters_bottom_sheet.dart b/lib/screens/libraries/filters_bottom_sheet.dart index 0c99bf34..3f4097f2 100644 --- a/lib/screens/libraries/filters_bottom_sheet.dart +++ b/lib/screens/libraries/filters_bottom_sheet.dart @@ -3,7 +3,6 @@ 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 { @@ -31,29 +30,12 @@ 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() { @@ -87,10 +69,6 @@ class _FiltersBottomSheetState extends State { _filterValues = values; _isLoadingValues = false; }); - // Focus the first filter value after loading - WidgetsBinding.instance.addPostFrameCallback((_) { - _filterValuesFocusNode.requestFocus(); - }); } catch (e) { setState(() { _filterValues = []; @@ -111,21 +89,6 @@ 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('?'); @@ -140,181 +103,174 @@ class _FiltersBottomSheetState extends State { @override Widget build(BuildContext context) { - 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, - ), + 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, ), + ), - // 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, + // 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, ); - final isSelected = - _tempSelectedFilters[_currentFilter!.filter] == - filterValue; - return ListTile( - title: Text(value.title), + title: Text(t.libraries.all), selected: isSelected, onTap: () { setState(() { - _tempSelectedFilters[_currentFilter!.filter] = - filterValue; - // Cache the display name for this filter value - _filterDisplayNames['${_currentFilter!.filter}:$filterValue'] = - value.title; + _tempSelectedFilters.remove( + _currentFilter!.filter, + ); }); _applyFilters(); }, ); - }, - ), - ), - ], - ); - } + } - // 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( - 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), + final value = _filterValues[index - 1]; + final filterValue = _extractFilterValue( + value.key, + _currentFilter!.filter, ); - } + final isSelected = + _tempSelectedFilters[_currentFilter!.filter] == + filterValue; - // 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), - ); - }, + return ListTile( + title: Text(value.title), + selected: isSelected, + onTap: () { + setState(() { + _tempSelectedFilters[_currentFilter!.filter] = + filterValue; + // Cache the display name for this filter value + _filterDisplayNames['${_currentFilter!.filter}:$filterValue'] = + value.title; + }); + _applyFilters(); + }, + ); + }, + ), ), - ), ], ); - }, - ), + } + + // 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/screens/libraries/folder_tree_item.dart b/lib/screens/libraries/folder_tree_item.dart index 97f0bd8a..4409b38a 100644 --- a/lib/screens/libraries/folder_tree_item.dart +++ b/lib/screens/libraries/folder_tree_item.dart @@ -1,16 +1,14 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import '../../models/plex_metadata.dart'; -import '../../utils/keyboard_utils.dart'; /// Individual item in the folder tree /// Can be either a folder (expandable) or a file (tappable) -class FolderTreeItem extends StatefulWidget { +class FolderTreeItem extends StatelessWidget { final PlexMetadata item; final int depth; final bool isExpanded; final bool isFolder; - final void Function({bool isKeyboard})? onTap; + final VoidCallback? onTap; final VoidCallback? onExpand; final bool isLoading; @@ -25,20 +23,13 @@ class FolderTreeItem extends StatefulWidget { this.isLoading = false, }); - @override - State createState() => _FolderTreeItemState(); -} - -class _FolderTreeItemState extends State { - bool _isKeyboardActivation = false; - IconData _getIcon() { - if (widget.isFolder) { + if (isFolder) { return Icons.folder; } // File icons based on type - final type = widget.item.type.toLowerCase(); + final type = item.type.toLowerCase(); switch (type) { case 'movie': return Icons.movie; @@ -55,103 +46,89 @@ class _FolderTreeItemState extends State { } } - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { - if (event is KeyDownEvent && isKeyboardActivationKey(event.logicalKey)) { - _isKeyboardActivation = true; - return KeyEventResult.ignored; // Let InkWell handle the activation - } - return KeyEventResult.ignored; - } - void _handleTap() { - if (widget.isFolder) { - widget.onExpand?.call(); + if (isFolder) { + onExpand?.call(); } else { - widget.onTap?.call(isKeyboard: _isKeyboardActivation); + onTap?.call(); } - _isKeyboardActivation = false; } @override Widget build(BuildContext context) { - final indentation = widget.depth * 24.0; + final indentation = depth * 24.0; - return Focus( - onKeyEvent: _handleKeyEvent, - child: InkWell( - onTap: _handleTap, - child: Container( - padding: EdgeInsets.only( - left: 16.0 + indentation, - right: 16.0, - top: 12.0, - bottom: 12.0, - ), - child: Row( - children: [ - // Expand/collapse icon for folders - if (widget.isFolder) - SizedBox( - width: 24, - child: widget.isLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Icon( - widget.isExpanded - ? Icons.keyboard_arrow_down - : Icons.keyboard_arrow_right, - size: 20, - ), - ) - else - const SizedBox(width: 24), + return InkWell( + onTap: _handleTap, + child: Container( + padding: EdgeInsets.only( + left: 16.0 + indentation, + right: 16.0, + top: 12.0, + bottom: 12.0, + ), + child: Row( + children: [ + // Expand/collapse icon for folders + if (isFolder) + SizedBox( + width: 24, + child: isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon( + isExpanded + ? Icons.keyboard_arrow_down + : Icons.keyboard_arrow_right, + size: 20, + ), + ) + else + const SizedBox(width: 24), - const SizedBox(width: 8), + const SizedBox(width: 8), - // File/folder icon - Icon( - _getIcon(), - size: 20, - color: widget.isFolder - ? Theme.of(context).colorScheme.primary - : Theme.of( - context, - ).colorScheme.onSurface.withValues(alpha: 0.7), - ), - - const SizedBox(width: 12), - - // Item title - Expanded( - child: Text( - widget.item.title, - style: TextStyle( - fontSize: 14, - fontWeight: widget.isFolder - ? FontWeight.w500 - : FontWeight.w400, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - - // Additional metadata for files - if (!widget.isFolder && widget.item.year != null) - Text( - widget.item.year.toString(), - style: TextStyle( - fontSize: 12, - color: Theme.of( + // File/folder icon + Icon( + _getIcon(), + size: 20, + color: isFolder + ? Theme.of(context).colorScheme.primary + : Theme.of( context, - ).colorScheme.onSurface.withValues(alpha: 0.6), - ), + ).colorScheme.onSurface.withValues(alpha: 0.7), + ), + + const SizedBox(width: 12), + + // Item title + Expanded( + child: Text( + item.title, + style: TextStyle( + fontSize: 14, + fontWeight: isFolder ? FontWeight.w500 : FontWeight.w400, ), - ], - ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + + // Additional metadata for files + if (!isFolder && item.year != null) + Text( + item.year.toString(), + style: TextStyle( + fontSize: 12, + color: Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.6), + ), + ), + ], ), ), ); diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index fc1de621..01158a7c 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -150,10 +150,7 @@ class _FolderTreeViewState extends State { } } - Future _handleItemTap( - PlexMetadata item, { - bool isKeyboard = false, - }) async { + Future _handleItemTap(PlexMetadata item) async { final itemType = item.type.toLowerCase(); // For episodes, start playback directly @@ -166,8 +163,7 @@ class _FolderTreeViewState extends State { await Navigator.push( context, MaterialPageRoute( - builder: (context) => - SeasonDetailScreen(season: item, focusFirstEpisode: isKeyboard), + builder: (context) => SeasonDetailScreen(season: item), ), ); widget.onRefresh?.call(item.ratingKey); @@ -219,10 +215,7 @@ class _FolderTreeViewState extends State { isExpanded: isExpanded, isLoading: isLoading, onExpand: isFolder ? () => _toggleFolder(item) : null, - onTap: !isFolder - ? ({bool isKeyboard = false}) => - _handleItemTap(item, isKeyboard: isKeyboard) - : null, + onTap: !isFolder ? () => _handleItemTap(item) : null, ), ); diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 81f0c0bc..14a6f815 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:dio/dio.dart'; import '../../../services/plex_client.dart'; @@ -9,7 +8,6 @@ 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 'context_menu_wrapper.dart'; @@ -24,7 +22,6 @@ import 'tabs/library_browse_tab.dart'; import 'tabs/library_recommended_tab.dart'; import 'tabs/library_collections_tab.dart'; import 'tabs/library_playlists_tab.dart'; -import '../main_screen.dart'; class LibrariesScreen extends StatefulWidget { const LibrariesScreen({super.key}); @@ -74,33 +71,14 @@ 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(); } @@ -122,76 +100,10 @@ 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); @@ -732,9 +644,6 @@ 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, @@ -755,7 +664,6 @@ class _LibrariesScreenState extends State onToggleVisibility: _toggleLibraryVisibility, getLibraryMenuItems: _getLibraryMenuItems, onLibraryMenuAction: _handleLibraryMenuAction, - autoFocusFirstHandle: openedViaKeyboard, ), ); } @@ -954,45 +862,6 @@ 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); @@ -1024,150 +893,49 @@ class _LibrariesScreenState extends State orElse: () => visibleLibraries.first, ); - return Focus( - focusNode: _libraryDropdownFocusNode, - onKeyEvent: (node, event) { - if (event is KeyDownEvent) { - if (isKeyboardActivationKey(event.logicalKey)) { - _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; + return PopupMenuButton( + key: _libraryDropdownKey, + offset: const Offset(0, 48), + tooltip: t.libraries.selectLibrary, + onSelected: (libraryGlobalKey) { + _loadLibraryContent(libraryGlobalKey); }, - 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( + itemBuilder: (context) => _buildGroupedLibraryMenuItems(visibleLibraries), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + 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: [ - 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, + 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), ), - const SizedBox(width: 4), - const Icon(Icons.arrow_drop_down, size: 24), + ), ], + ) + else + Text( + selectedLibrary.title, + style: Theme.of(context).textTheme.titleLarge, ), - ), - ); - }, - ), - ); - } - - /// 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 (isKeyboardActivationKey(event.logicalKey)) { - 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, - ); - }, + const SizedBox(width: 4), + const Icon(Icons.arrow_drop_down, size: 24), + ], + ), ), ); } @@ -1184,161 +952,137 @@ class _LibrariesScreenState extends State .toList(); return Scaffold( - 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(), - ), - _buildFocusableIconButton( - focusNode: _refreshButtonFocusNode, - icon: Icons.refresh, - semanticLabel: t.common.refresh, - onPressed: _refreshCurrentTab, - onLeft: () { - if (_allLibraries.isNotEmpty) { - _editButtonFocusNode.requestFocus(); - } else { - _libraryDropdownFocusNode.requestFocus(); - } - }, - onDown: () => _tabChipsFocusNode.requestFocus(), - ), - ], - ), - 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: 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, - ), - ), - ], - ), + 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: const Icon(Icons.edit), + tooltip: t.libraries.manageLibraries, + onPressed: _showLibraryManagementSheet, ), + IconButton( + icon: const Icon(Icons.refresh), + tooltip: 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) + 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, + ), + ), + ], + ), + ), ], - ), + ], ), ); } @@ -1366,7 +1110,6 @@ 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, @@ -1375,7 +1118,6 @@ class _LibraryManagementSheet extends StatefulWidget { required this.onToggleVisibility, required this.getLibraryMenuItems, required this.onLibraryMenuAction, - this.autoFocusFirstHandle = false, }); @override @@ -1387,94 +1129,11 @@ 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 @@ -1541,247 +1200,6 @@ 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 (isKeyboardActivationKey(event.logicalKey)) { - onEndMove(); - return KeyEventResult.handled; - } - if (isBackKey(event.logicalKey)) { - onCancelMove(); - return KeyEventResult.handled; - } - } else { - // Not moving - Enter/Space starts move - if (isKeyboardActivationKey(event.logicalKey)) { - 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 (isKeyboardActivationKey(event.logicalKey)) { - 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) { @@ -1801,39 +1219,28 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { final menuItems = widget.getLibraryMenuItems(library); final selected = await showModalBottomSheet( 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, - ), + 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, ), ), - ...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), - ), + ), + ...menuItems.indexed.map( + (entry) => ListTile( + leading: Icon(entry.$2.icon), + title: Text(entry.$2.label), + onTap: () => Navigator.pop(context, entry.$2.value), ), - ], - ), + ), + ], ), ), ); @@ -1906,53 +1313,47 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { maxChildSize: 0.95, expand: false, builder: (context, scrollController) { - 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), + return 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), + ), + ], ); }, ); @@ -2011,45 +1412,31 @@ 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 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, + // Server header with drag handle + ListTile( + leading: ReorderableDragStartListener( + index: serverIndex, + child: Icon( + Icons.drag_indicator, + color: IconTheme.of(context).color?.withValues(alpha: 0.5), ), - 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, ), ), ), - // Libraries for this server (reorderable within server) + // Libraries for this server (not reorderable) ...libraries.asMap().entries.map((entry) { final index = entry.key; final library = entry.value; @@ -2058,7 +1445,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { index, hiddenLibraryKeys, showServerBadge: false, - enableDrag: false, // Disable drag for individual libraries + enableDrag: false, ); }), ], @@ -2076,64 +1463,52 @@ 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: 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, + child: ListTile( + leading: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (enableDrag) + ReorderableDragStartListener( + index: index, + child: Icon( + Icons.drag_indicator, + color: IconTheme.of(context).color?.withValues(alpha: 0.5), ), - 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, ), - _buildNavigableIconButton( - iconData: Icons.more_vert, - onPressed: () => _showLibraryMenuBottomSheet(context, library), - tooltip: t.libraries.libraryOptions, - ), - ], - ), + 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), + tooltip: isHidden + ? t.libraries.showLibrary + : t.libraries.hideLibrary, + onPressed: () => widget.onToggleVisibility(library), + ), + IconButton( + icon: const Icon(Icons.more_vert), + tooltip: t.libraries.libraryOptions, + onPressed: () => _showLibraryMenuBottomSheet(context, library), + ), + ], ), ), ); diff --git a/lib/screens/libraries/sort_bottom_sheet.dart b/lib/screens/libraries/sort_bottom_sheet.dart index ce7eba6d..d04f60df 100644 --- a/lib/screens/libraries/sort_bottom_sheet.dart +++ b/lib/screens/libraries/sort_bottom_sheet.dart @@ -1,8 +1,6 @@ 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 { @@ -28,23 +26,12 @@ 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) { @@ -67,98 +54,70 @@ class _SortBottomSheetState extends State { @override Widget build(BuildContext context) { - 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 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: 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 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( - value: false, - icon: Icon(Icons.arrow_upward, size: 16), - ), - ButtonSegment( - value: true, - icon: Icon( - Icons.arrow_downward, - size: 16, - ), - ), - ], - selected: {_currentDescending}, - onSelectionChanged: (Set newSelection) { - _handleSortChange(sort, newSelection.first); - }, - ), - ) - : null, - ); + return RadioListTile( + title: Text(sort.title), + value: sort, + groupValue: _currentSort, + onChanged: (value) { + if (value != null) { + _handleSortChange(value, value.isDefaultDescending); + } }, - ), - ), + secondary: isSelected + ? SegmentedButton( + showSelectedIcon: false, + segments: const [ + ButtonSegment( + value: false, + icon: Icon(Icons.arrow_upward, size: 16), + ), + ButtonSegment( + value: true, + icon: Icon( + Icons.arrow_downward, + size: 16, + ), + ), + ], + selected: {_currentDescending}, + onSelectionChanged: (Set newSelection) { + _handleSortChange(sort, newSelection.first); + }, + ) + : null, + ); + }, ), - ], - ); - }, - ), + ), + ], + ); + }, ); } } diff --git a/lib/screens/libraries/tabs/base_library_tab.dart b/lib/screens/libraries/tabs/base_library_tab.dart index 41e11d24..de4b5d4b 100644 --- a/lib/screens/libraries/tabs/base_library_tab.dart +++ b/lib/screens/libraries/tabs/base_library_tab.dart @@ -139,19 +139,6 @@ 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/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index b5c4eab6..e122804c 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:dio/dio.dart'; import '../../../../services/plex_client.dart'; @@ -10,7 +9,6 @@ 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 '../folder_tree_view.dart'; import '../filters_bottom_sheet.dart'; @@ -89,11 +87,6 @@ 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(); @@ -112,17 +105,9 @@ 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(); @@ -328,48 +313,35 @@ class _LibraryBrowseTabState extends State context: context, 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; - }); + return ListView.builder( + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (context, index) { + final grouping = options[index]; + return RadioListTile( + 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 (!sheetContext.mounted) return; + if (!sheetContext.mounted) return; - Navigator.pop(sheetContext); - _loadItems(); - } - }, - ); + Navigator.pop(sheetContext); + _loadItems(); + } }, - ), - ), + ); + }, ); }, ); @@ -435,52 +407,30 @@ class _LibraryBrowseTabState extends State required String label, required VoidCallback onPressed, }) { - return Focus( - onKeyEvent: (node, event) { - if (event is KeyDownEvent) { - if (isKeyboardActivationKey(event.logicalKey)) { - 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), - ), - ], - ), + final colorScheme = Theme.of(context).colorScheme; + return InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(20), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: colorScheme.onSurfaceVariant), + const SizedBox(width: 6), + Text( + label, + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), ), - ); - }, + ], + ), ), ); } @@ -627,7 +577,6 @@ class _LibraryBrowseTabState extends State key: Key(item.ratingKey), item: item, onRefresh: updateItem, - focusNode: index == 0 ? _firstItemFocusNode : null, ); } } diff --git a/lib/screens/libraries/tabs/library_collections_tab.dart b/lib/screens/libraries/tabs/library_collections_tab.dart index 77265a71..d0fcfe6b 100644 --- a/lib/screens/libraries/tabs/library_collections_tab.dart +++ b/lib/screens/libraries/tabs/library_collections_tab.dart @@ -21,17 +21,6 @@ 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; @@ -54,19 +43,11 @@ 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, - firstItemFocusNode: _firstItemFocusNode, ); } } diff --git a/lib/screens/libraries/tabs/library_playlists_tab.dart b/lib/screens/libraries/tabs/library_playlists_tab.dart index 1efe90f9..71b898f5 100644 --- a/lib/screens/libraries/tabs/library_playlists_tab.dart +++ b/lib/screens/libraries/tabs/library_playlists_tab.dart @@ -25,17 +25,6 @@ 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; @@ -60,13 +49,6 @@ class _LibraryPlaylistsTabState ); } - @override - void focusFirstItem() { - if (items.isNotEmpty) { - _firstItemFocusNode.requestFocus(); - } - } - @override Widget buildContent(List items) { return Consumer( @@ -104,7 +86,6 @@ class _LibraryPlaylistsTabState key: Key(playlist.ratingKey), item: playlist, onListRefresh: loadItems, - focusNode: index == 0 ? _firstItemFocusNode : null, ); } } diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index d4df4d5a..ca0a527a 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -5,7 +5,6 @@ import '../../../i18n/strings.g.dart'; import '../../../mixins/item_updatable.dart'; import '../../../models/plex_hub.dart'; import '../../../models/plex_metadata.dart'; -import '../../../widgets/hub_navigation_controller.dart'; import '../../../widgets/hub_section.dart'; import 'base_library_tab.dart'; @@ -21,21 +20,6 @@ class LibraryRecommendedTab extends BaseLibraryTab { class _LibraryRecommendedTabState extends BaseLibraryTabState with ItemUpdatable { - final HubNavigationController _hubNavigationController = - HubNavigationController(); - - @override - void dispose() { - _hubNavigationController.dispose(); - super.dispose(); - } - - /// Focus the first item in the first hub - @override - void focusFirstItem() { - _hubNavigationController.focusHub(0, 0); - } - @override PlexClient get client => getClientForLibrary(); @@ -111,28 +95,24 @@ class _LibraryRecommendedTabState @override Widget buildContent(List items) { - return HubNavigationScope( - controller: _hubNavigationController, - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: items.length, - itemBuilder: (context, index) { - final hub = items[index]; - final isContinueWatching = - hub.hubIdentifier == '_library_continue_watching_'; + return ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: items.length, + itemBuilder: (context, index) { + final hub = items[index]; + final isContinueWatching = + hub.hubIdentifier == '_library_continue_watching_'; - return HubSection( - hub: hub, - icon: _getHubIcon(hub), - navigationOrder: index, - isInContinueWatching: isContinueWatching, - onRefresh: updateItem, - onRemoveFromContinueWatching: isContinueWatching - ? _refreshContinueWatching - : null, - ); - }, - ), + return HubSection( + hub: hub, + icon: _getHubIcon(hub), + isInContinueWatching: isContinueWatching, + onRefresh: updateItem, + onRemoveFromContinueWatching: isContinueWatching + ? _refreshContinueWatching + : null, + ); + }, ); } diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index d049ad28..3e166bf6 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -1,10 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../services/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'; @@ -19,30 +17,6 @@ import 'libraries/libraries_screen.dart'; import 'search_screen.dart'; import 'settings/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; @@ -61,18 +35,9 @@ 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( @@ -104,38 +69,9 @@ 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) @@ -215,110 +151,52 @@ 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) + // Focus search input when selecting Search tab 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 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; - }, + return Scaffold( + body: IndexedStack(index: _currentIndex, children: _screens), + bottomNavigationBar: NavigationBar( + selectedIndex: _currentIndex, + onDestinationSelected: _selectTab, + 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 22dab50d..398f7052 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -5,8 +5,6 @@ import 'package:provider/provider.dart'; import '../i18n/strings.g.dart'; import '../widgets/plex_optimized_image.dart'; import '../utils/plex_image_helper.dart'; -import '../mixins/keyboard_long_press_mixin.dart'; -import '../widgets/focus/focus_indicator.dart'; import '../../services/plex_client.dart'; import '../models/plex_metadata.dart'; import '../providers/playback_state_provider.dart'; @@ -14,7 +12,6 @@ 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'; @@ -40,7 +37,6 @@ class _MediaDetailScreenState extends State { bool _isLoadingMetadata = true; late final ScrollController _scrollController; bool _watchStateChanged = false; - final FocusNode _playButtonFocusNode = FocusNode(debugLabel: 'PlayButton'); @override void initState() { @@ -52,7 +48,6 @@ class _MediaDetailScreenState extends State { @override void dispose() { _scrollController.dispose(); - _playButtonFocusNode.dispose(); super.dispose(); } @@ -94,11 +89,6 @@ 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(); @@ -112,11 +102,6 @@ class _MediaDetailScreenState extends State { _isLoadingMetadata = false; }); - // Focus the play button after loading - WidgetsBinding.instance.addPostFrameCallback((_) { - _playButtonFocusNode.requestFocus(); - }); - if (widget.metadata.type.toLowerCase() == 'show') { _loadSeasons(); } @@ -127,11 +112,6 @@ class _MediaDetailScreenState extends State { _isLoadingMetadata = false; }); - // Focus the play button after loading - WidgetsBinding.instance.addPostFrameCallback((_) { - _playButtonFocusNode.requestFocus(); - }); - if (widget.metadata.type.toLowerCase() == 'show') { _loadSeasons(); } @@ -392,128 +372,144 @@ class _MediaDetailScreenState extends State { final headerHeight = isDesktop ? size.height * 0.6 : size.height * 0.4; return Scaffold( - 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); - final mediaQuery = MediaQuery.of(context); - final imageUrl = PlexImageHelper.getOptimizedImageUrl( - client: client, - thumbPath: metadata.art, - maxWidth: mediaQuery.size.width, - maxHeight: mediaQuery.size.height * 0.6, - devicePixelRatio: mediaQuery.devicePixelRatio, - imageType: ImageType.art, - ); + 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); + final mediaQuery = MediaQuery.of(context); + final imageUrl = PlexImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: metadata.art, + maxWidth: mediaQuery.size.width, + maxHeight: mediaQuery.size.height * 0.6, + devicePixelRatio: mediaQuery.devicePixelRatio, + imageType: ImageType.art, + ); - return CachedNetworkImage( - imageUrl: imageUrl, - 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 + return CachedNetworkImage( + imageUrl: imageUrl, + 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( - 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], - ), - ), + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, ), - // 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, - ); - final dpr = MediaQuery.of( - context, - ).devicePixelRatio; - final logoUrl = - PlexImageHelper.getOptimizedImageUrl( - client: client, - thumbPath: metadata.clearLogo, - maxWidth: 400, - maxHeight: 120, - devicePixelRatio: dpr, - imageType: ImageType.logo, - ); + // 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], + ), + ), + ), - return CachedNetworkImage( - imageUrl: logoUrl, - filterQuality: FilterQuality.medium, - fit: BoxFit.contain, + // 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, + ); + final dpr = MediaQuery.of( + context, + ).devicePixelRatio; + final logoUrl = + PlexImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: metadata.clearLogo, + maxWidth: 400, + maxHeight: 120, + devicePixelRatio: dpr, + imageType: ImageType.logo, + ); + + return CachedNetworkImage( + imageUrl: logoUrl, + filterQuality: FilterQuality.medium, + fit: BoxFit.contain, + alignment: Alignment.centerLeft, + memCacheWidth: (400 * dpr) + .clamp(200, 800) + .round(), + placeholder: (context, url) => Align( alignment: Alignment.centerLeft, - memCacheWidth: (400 * dpr) - .clamp(200, 800) - .round(), - placeholder: (context, url) => Align( + 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, + ), + ), + errorWidget: (context, url, error) { + return Align( alignment: Alignment.centerLeft, child: Text( metadata.title, @@ -521,8 +517,7 @@ class _MediaDetailScreenState extends State { .textTheme .displaySmall ?.copyWith( - color: Colors.white - .withValues(alpha: 0.3), + color: Colors.white, fontWeight: FontWeight.bold, shadows: [ Shadow( @@ -537,324 +532,254 @@ 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, + ); + }, + ); + }, ), - 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, - ), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - '${metadata.year}', - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ), - 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, - color: Colors.white, - size: 16, + ) + 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, ), - 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, + blurRadius: 8, ), - 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, - ), - ), - ], - ), + ], ), - ], + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - ], - ), + 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, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${metadata.year}', + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + 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, + 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( - 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 + // 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}', + ); await navigateToVideoPlayer( context, - metadata: metadata, + 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(); } - }, - 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') ...[ - IconButton.filledTonal( - onPressed: () async { - await _handleShufflePlayWithQueue( - context, - metadata, - ); + } 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.shuffle), - tooltip: t.tooltips.shufflePlay, - iconSize: 20, - style: IconButton.styleFrom( - minimumSize: const Size(48, 48), - maximumSize: const Size(48, 48), + 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), - ], + ), + ), + const SizedBox(width: 12), + // Shuffle button (only for shows and seasons) + if (metadata.type.toLowerCase() == 'show' || + metadata.type.toLowerCase() == 'season') ...[ 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 _handleShufflePlayWithQueue( + context, + metadata, + ); }, - icon: const Icon(Icons.check), - tooltip: t.tooltips.markAsWatched, + icon: const Icon(Icons.shuffle), + tooltip: t.tooltips.shufflePlay, iconSize: 20, style: IconButton.styleFrom( minimumSize: const Size(48, 48), @@ -862,273 +787,308 @@ class _MediaDetailScreenState extends State { ), ), 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), - ), - ), ], - ), + IconButton.filledTonal( + onPressed: () async { + try { + final client = _getClientForMetadata(context); - 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: ({bool isKeyboard = false}) async { - final watchStateChanged = - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - SeasonDetailScreen( - season: season, - focusFirstEpisode: isKeyboard, - ), - ), - ); - if (watchStateChanged == true) { - _watchStateChanged = true; - _updateWatchState(); - } - }, - onRefresh: () { - _watchStateChanged = true; - _updateWatchState(); - }, + await client.markAsWatched(metadata.ratingKey); + if (context.mounted) { + _watchStateChanged = true; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(t.messages.markedAsWatched), + ), ); - }, - ), - ), - 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, + // 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(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(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(), ), - const SizedBox(height: 8), - SizedBox( - height: 84, - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ + ), + ), + ); + } + } + }, + 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), + ), + ), + ) + 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 _SeasonCard( + season: season, + client: _getClientForMetadata(context), + onTap: () async { + final watchStateChanged = + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + SeasonDetailScreen(season: season), + ), + ); + 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.tag, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + fontWeight: FontWeight.w600, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (actor.role != null) ...[ + const SizedBox(height: 2), Text( - actor.tag, + actor.role!, style: Theme.of(context) .textTheme - .bodyMedium + .bodySmall ?.copyWith( - fontWeight: FontWeight.w600, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, ), 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), - ], - - // 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), - ], + ), + const SizedBox(height: 24), ], - ), + + // 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), + ], + ], ), ), - ], - ), + ), + ], ), ); } @@ -1189,209 +1149,138 @@ class _MediaDetailScreenState extends State { } } -/// Focusable season card widget -class _FocusableSeasonCard extends StatefulWidget { +/// Season card widget +class _SeasonCard extends StatelessWidget { final PlexMetadata season; final PlexClient client; - final void Function({bool isKeyboard}) onTap; + final VoidCallback onTap; final VoidCallback onRefresh; - const _FocusableSeasonCard({ + const _SeasonCard({ 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(isKeyboard: true); - - @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(isKeyboard: false), - 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(isKeyboard: false), - 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: PlexPosterImage( - client: widget.client, - imagePath: 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( + return Card( + clipBehavior: Clip.antiAlias, + child: MediaContextMenu( + item: season, + onRefresh: (ratingKey) => onRefresh(), + onTap: onTap, + child: Semantics( + label: "media-season-${season.ratingKey}", + identifier: "media-season-${season.ratingKey}", + button: true, + hint: "Tap to view ${season.title}", + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + // Season poster + if (season.thumb != null) + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: PlexPosterImage( + client: client, + imagePath: season.thumb, + width: 80, + height: 120, + fit: BoxFit.cover, + placeholder: (context, url) => Container( width: 80, height: 120, - decoration: BoxDecoration( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(6), - ), + 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), ), - const SizedBox(width: 16), + ), + ) + 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), + // 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(), ), - 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, + ), ), - style: Theme.of(context).textTheme.bodyMedium + ), + 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 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), - ], + ], + ), + ], + ), ), - ), + + const Icon(Icons.chevron_right), + ], ), ), ), diff --git a/lib/screens/playlist/playlist_item_card.dart b/lib/screens/playlist/playlist_item_card.dart index b43e45c6..4b6dc680 100644 --- a/lib/screens/playlist/playlist_item_card.dart +++ b/lib/screens/playlist/playlist_item_card.dart @@ -1,11 +1,9 @@ import 'package:flutter/material.dart'; import '../../services/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 '../../widgets/focus/focus_indicator.dart'; import '../../widgets/media_context_menu.dart'; import '../../widgets/plex_optimized_image.dart'; @@ -33,18 +31,9 @@ class PlaylistItemCard extends StatefulWidget { State createState() => _PlaylistItemCardState(); } -class _PlaylistItemCardState extends State - with KeyboardLongPressMixin { +class _PlaylistItemCardState extends State { final _contextMenuKey = GlobalKey(); - @override - void onKeyboardTap() => widget.onTap?.call(); - - @override - void onKeyboardLongPress() { - _contextMenuKey.currentState?.showContextMenu(context); - } - @override Widget build(BuildContext context) { final item = widget.item; @@ -53,103 +42,92 @@ class _PlaylistItemCardState extends State item: item, onRefresh: widget.onRefresh, onTap: widget.onTap, - child: FocusableWrapper( - onKeyEvent: (node, event) => handleKeyboardLongPress(event), - builder: (context, isFocused) => 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: [ - // 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, - ), - ), - ], - ), + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: InkWell( + onTap: widget.onTap, + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + // 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), ), + ), - const SizedBox(width: 12), + // Poster thumbnail + _buildPosterImage(context), - // Duration - if (item.duration != null) + const SizedBox(width: 12), + + // Title and metadata + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Title Text( - formatDurationTextual(item.duration!), - style: TextStyle(fontSize: 13, color: Colors.grey[400]), + item.displayTitle, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - const SizedBox(width: 8), + const SizedBox(height: 4), - // Remove button - IconButton( - icon: const Icon(Icons.close, size: 20), - onPressed: widget.onRemove, - tooltip: t.playlists.removeItem, - color: Colors.grey[400], - ), - ], + // 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], + ), + ], ), ), ), diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 595eceb0..9b98e34d 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -1,10 +1,8 @@ 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'; @@ -12,7 +10,6 @@ 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'; @@ -160,178 +157,146 @@ class _SearchScreenState extends State with Refreshable { } } - /// Handle back key press - focus bottom navigation - KeyEventResult _handleBackKey(FocusNode node, KeyEvent event) { - if (isBackKeyEvent(event)) { - // Allow backspace to work normally in search field when it has content - if (event.logicalKey == LogicalKeyboardKey.backspace && - _searchFocusNode.hasFocus && - _searchController.text.isNotEmpty) { - return KeyEventResult.ignored; - } - - BackNavigationScope.of(context)?.focusBottomNav(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( - 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, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, - ), + 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, + ), + 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, + ), + 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, ), - 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.enterTitleActorOrKeyword, - style: TextStyle(color: Colors.grey.shade600), - ), - ], - ), + ), + 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: 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, - 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), - ), - ); - } - }, ), - ], - ), + ) + 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, 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 c305d5e9..40e3e1b5 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -1,31 +1,20 @@ import 'package:flutter/material.dart'; import '../../services/plex_client.dart'; import '../widgets/plex_optimized_image.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'; class SeasonDetailScreen extends StatefulWidget { final PlexMetadata season; - /// Whether to focus the first episode after loading. - /// Should be true when navigating via keyboard, false for mouse/tap. - final bool focusFirstEpisode; - - const SeasonDetailScreen({ - super.key, - required this.season, - this.focusFirstEpisode = false, - }); + const SeasonDetailScreen({super.key, required this.season}); @override State createState() => _SeasonDetailScreenState(); @@ -41,9 +30,6 @@ 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) { @@ -60,12 +46,6 @@ class _SeasonDetailScreenState extends State }); } - @override - void dispose() { - _firstEpisodeFocusNode.dispose(); - super.dispose(); - } - Future _loadEpisodes() async { setState(() { _isLoadingEpisodes = true; @@ -79,13 +59,6 @@ class _SeasonDetailScreenState extends State _episodes = episodes; _isLoadingEpisodes = false; }); - - // Focus the first episode after loading (only if keyboard navigation) - if (episodes.isNotEmpty && widget.focusFirstEpisode) { - WidgetsBinding.instance.addPostFrameCallback((_) { - _firstEpisodeFocusNode.requestFocus(); - }); - } } catch (e) { setState(() { _isLoadingEpisodes = false; @@ -110,145 +83,77 @@ class _SeasonDetailScreenState extends State @override Widget build(BuildContext context) { return Scaffold( - 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, + 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( 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 _EpisodeCard( + episode: episode, + client: _client, + onTap: () async { + await navigateToVideoPlayer(context, metadata: episode); + // Refresh episodes when returning from video player + _loadEpisodes(); + }, + onRefresh: updateItem, + ); + }, childCount: _episodes.length), + ), + ], ), ); } } -/// Focusable episode card widget -class _EpisodeCard extends StatefulWidget { +/// Episode card widget +class _EpisodeCard extends StatelessWidget { 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 && @@ -258,239 +163,217 @@ class _EpisodeCardState extends State<_EpisodeCard> : 0.0; return MediaContextMenu( - key: _contextMenuKey, item: episode, - 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, - ), - ), - ), - 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 - ? PlexThumbImage( - client: widget.client, - imagePath: 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, - ), - ), - ), - ], - ), - ), - - 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( + onRefresh: onRefresh, + onTap: onTap, + child: InkWell( + key: Key(episode.ratingKey), + onTap: onTap, + 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 + ? PlexThumbImage( + client: client, + imagePath: episode.thumb, + filterQuality: FilterQuality.medium, + fit: BoxFit.cover, + placeholder: (context, url) => Container( color: Theme.of( context, - ).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(3), + ).colorScheme.surfaceContainerHighest, ), - child: Text( - 'E${episode.index}', - style: TextStyle( - color: Theme.of( - context, - ).colorScheme.onPrimaryContainer, - fontSize: 11, - fontWeight: FontWeight.w600, - ), + 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), ), - const SizedBox(width: 8), - Expanded( - child: Text( - episode.title, - style: Theme.of(context).textTheme.titleSmall - ?.copyWith(fontWeight: FontWeight.bold), - maxLines: 2, - overflow: TextOverflow.ellipsis, + ), + ), + + // 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, + ), + ), + ), + ], + ), + ), + + 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), + // 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( - episode.summary!, + formatDurationTimestamp( + Duration(milliseconds: episode.duration!), + ), style: Theme.of(context).textTheme.bodySmall ?.copyWith( color: tokens(context).textMuted, - height: 1.3, + 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, ), - 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, - ), - ), - ], - ], - ), ], ), - ), - ], + ], + ), ), - ), + ], ), ), ), diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 08df3619..d8020309 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -4,13 +4,11 @@ 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 'hotkey_recorder_widget.dart'; import 'about_screen.dart'; @@ -69,15 +67,6 @@ 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) { @@ -85,34 +74,31 @@ class _SettingsScreenState extends State { } return Scaffold( - 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(), + 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(), 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), - ]), - ), + ], + _buildAboutSection(), + const SizedBox(height: 24), + ]), ), - ], - ), + ), + ], ), ); } diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index 951e2e4b..b2a6403b 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -3,7 +3,6 @@ import 'package:flutter/services.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; import '../mpv/mpv.dart'; import 'settings_service.dart'; -import '../utils/keyboard_utils.dart'; import '../utils/player_utils.dart'; class KeyboardShortcutsService { @@ -159,8 +158,8 @@ class KeyboardShortcutsService { }) { if (event is! KeyDownEvent) return KeyEventResult.ignored; - // Handle back navigation keys first - if (isBackKey(event.logicalKey)) { + // Handle back navigation keys (Escape) + if (event.logicalKey == LogicalKeyboardKey.escape) { onBack?.call(); return KeyEventResult.handled; } diff --git a/lib/utils/keyboard_utils.dart b/lib/utils/keyboard_utils.dart deleted file mode 100644 index ac5098fb..00000000 --- a/lib/utils/keyboard_utils.dart +++ /dev/null @@ -1,23 +0,0 @@ -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); -} - -/// Check if the given key should activate/select an item -bool isKeyboardActivationKey(LogicalKeyboardKey key) { - return key == LogicalKeyboardKey.enter || - key == LogicalKeyboardKey.space || - key == LogicalKeyboardKey.select || - key == LogicalKeyboardKey.gameButtonA; -} diff --git a/lib/widgets/focus/focus_indicator.dart b/lib/widgets/focus/focus_indicator.dart deleted file mode 100644 index c34f2b79..00000000 --- a/lib/widgets/focus/focus_indicator.dart +++ /dev/null @@ -1,151 +0,0 @@ -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 KeyEventResult Function(FocusNode, KeyEvent)? onKeyEvent; - final void Function(BuildContext context)? onScrollIntoView; - final String? debugLabel; - - const FocusableWrapper({ - super.key, - required this.builder, - this.focusNode, - this.autofocus = false, - this.onFocused, - this.onKeyEvent, - this.onScrollIntoView, - 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(); - // Use custom scroll behavior if provided, otherwise default - if (widget.onScrollIntoView != null) { - widget.onScrollIntoView!(context); - } else { - Scrollable.ensureVisible( - context, - alignment: 0.5, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - ); - } - } - } - } - - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { - return widget.onKeyEvent?.call(node, event) ?? 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 deleted file mode 100644 index d479b67d..00000000 --- a/lib/widgets/hub_navigation_controller.dart +++ /dev/null @@ -1,115 +0,0 @@ -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 6866ecd1..d99fc6cf 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -1,26 +1,19 @@ 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'; -import '../utils/keyboard_utils.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 StatefulWidget { +class HubSection extends StatelessWidget { 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, @@ -28,284 +21,124 @@ class HubSection extends StatefulWidget { 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() { + void _navigateToHubDetail(BuildContext context) { Navigator.push( context, - MaterialPageRoute(builder: (context) => HubDetailScreen(hub: widget.hub)), + MaterialPageRoute(builder: (context) => HubDetailScreen(hub: hub)), ); } - KeyEventResult _handleHeaderKeyEvent(FocusNode node, KeyEvent event) { - if (event is KeyDownEvent && widget.hub.more) { - if (isKeyboardActivationKey(event.logicalKey)) { - _navigateToHubDetail(); - return KeyEventResult.handled; - } - } - return KeyEventResult.ignored; - } - @override Widget build(BuildContext context) { - 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), - Expanded( - child: Text( - widget.hub.title, - style: Theme.of(context).textTheme.titleLarge, - overflow: TextOverflow.ellipsis, - maxLines: 1, - ), - ), - if (widget.hub.more) ...[ - const SizedBox(width: 4), - const Icon(Icons.chevron_right, size: 20), - ], - ], + 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 ? () => _navigateToHubDetail(context) : null, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon), + const SizedBox(width: 8), + Expanded( + child: Text( + hub.title, + style: Theme.of(context).textTheme.titleLarge, + overflow: TextOverflow.ellipsis, + maxLines: 1, ), ), - ), + if (hub.more) ...[ + const SizedBox(width: 4), + const Icon(Icons.chevron_right, size: 20), + ], + ], ), ), ), + ), - // 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; + // 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; - // 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; + // 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; - 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, - ), - ); - }, - ), - ), + return SizedBox( + height: containerHeight, + child: HorizontalScrollWithArrows( + builder: (scrollController) => ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 5, ), + 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, + ), + ); + }, ), - ); - }, - ) - 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 52602dcb..70edb3a8 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -1,10 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import 'focus/focus_indicator.dart'; -import 'hub_navigation_controller.dart'; import '../../services/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'; @@ -36,15 +32,6 @@ 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, @@ -56,9 +43,6 @@ class MediaCard extends StatefulWidget { this.forceGridMode = false, this.isInContinueWatching = false, this.collectionId, - this.focusNode, - this.hubId, - this.itemIndex, }); @override @@ -116,7 +100,7 @@ class _MediaCardState extends State { return baseLabel; } - void _handleTap(BuildContext context, {bool isKeyboard = false}) async { + void _handleTap(BuildContext context) async { // Handle playlists if (widget.item is PlexPlaylist) { await Navigator.push( @@ -177,7 +161,6 @@ class _MediaCardState extends State { MaterialPageRoute( builder: (context) => SeasonDetailScreen( season: widget.item, - focusFirstEpisode: isKeyboard, ), ), ); @@ -213,18 +196,13 @@ class _MediaCardState extends State { width: widget.width, height: widget.height, semanticLabel: semanticLabel, - onTap: ({bool isKeyboard = false}) => - _handleTap(context, isKeyboard: isKeyboard), + onTap: () => _handleTap(context), onLongPress: _showContextMenu, - focusNode: widget.focusNode, - hubId: widget.hubId, - itemIndex: widget.itemIndex, ) : _MediaCardList( item: widget.item, semanticLabel: semanticLabel, - onTap: ({bool isKeyboard = false}) => - _handleTap(context, isKeyboard: isKeyboard), + onTap: () => _handleTap(context), onLongPress: _showContextMenu, density: settingsProvider.libraryDensity, ); @@ -245,23 +223,14 @@ class _MediaCardState extends State { } /// Grid layout for media cards -class _MediaCardGrid extends StatefulWidget { +class _MediaCardGrid extends StatelessWidget { final dynamic item; // Can be PlexMetadata or PlexPlaylist final double? width; final double? height; final String semanticLabel; - final void Function({bool isKeyboard}) onTap; + 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, this.width, @@ -269,308 +238,141 @@ class _MediaCardGrid extends StatefulWidget { 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(isKeyboard: true); - - @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 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(isKeyboard: false), - borderRadius: BorderRadius.circular(8), - focusColor: Colors.transparent, // We use our own focus indicator - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( + 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( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, children: [ - // Poster - if (widget.height != null) - SizedBox( - width: double.infinity, - height: widget.height, - child: _buildPosterWithOverlay(context), - ) - 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!, - ), - 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; - - // 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(); - }, - ), - ], - ], + 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(); + }, + ) + 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 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(); + }, + ), + ], ], ), - ), + ], ), ), ), @@ -583,19 +385,19 @@ class _MediaCardGridState extends State<_MediaCardGrid> children: [ ClipRRect( borderRadius: BorderRadius.circular(8), - child: _buildPosterImage(context, widget.item), + child: _buildPosterImage(context, item), ), - _PosterOverlay(item: widget.item), + _PosterOverlay(item: item), ], ); } } /// List layout for media cards -class _MediaCardList extends StatefulWidget { +class _MediaCardList extends StatelessWidget { final dynamic item; // Can be PlexMetadata or PlexPlaylist final String semanticLabel; - final void Function({bool isKeyboard}) onTap; + final VoidCallback onTap; final VoidCallback onLongPress; final LibraryDensity density; @@ -607,71 +409,8 @@ class _MediaCardList extends StatefulWidget { required this.density, }); - @override - State<_MediaCardList> createState() => _MediaCardListState(); -} - -class _MediaCardListState extends State<_MediaCardList> - with KeyboardLongPressMixin { - @override - void onKeyboardTap() => widget.onTap(isKeyboard: true); - - @override - void onKeyboardLongPress() => widget.onLongPress(); - - /// 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(BuildContext ctx) { - final scrollable = Scrollable.maybeOf(ctx); - if (scrollable == null) { - // Fallback to simple centering - Scrollable.ensureVisible( - ctx, - alignment: 0.5, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - ); - return; - } - - final renderObject = ctx.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( - ctx, - alignment: 0.5, - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - ); - } - } - double get _posterWidth { - switch (widget.density) { + switch (density) { case LibraryDensity.compact: return 80; case LibraryDensity.normal: @@ -686,7 +425,7 @@ class _MediaCardListState extends State<_MediaCardList> } double get _titleFontSize { - switch (widget.density) { + switch (density) { case LibraryDensity.compact: return 14; case LibraryDensity.normal: @@ -697,7 +436,7 @@ class _MediaCardListState extends State<_MediaCardList> } double get _metadataFontSize { - switch (widget.density) { + switch (density) { case LibraryDensity.compact: return 11; case LibraryDensity.normal: @@ -708,7 +447,7 @@ class _MediaCardListState extends State<_MediaCardList> } double get _subtitleFontSize { - switch (widget.density) { + switch (density) { case LibraryDensity.compact: return 12; case LibraryDensity.normal: @@ -724,7 +463,7 @@ class _MediaCardListState extends State<_MediaCardList> } int get _summaryMaxLines { - switch (widget.density) { + switch (density) { case LibraryDensity.compact: return 2; case LibraryDensity.normal: @@ -737,8 +476,8 @@ class _MediaCardListState extends State<_MediaCardList> String _buildMetadataLine() { final parts = []; - if (widget.item is PlexPlaylist) { - final playlist = widget.item as PlexPlaylist; + if (item is PlexPlaylist) { + final playlist = item as PlexPlaylist; // Add item count if (playlist.leafCount != null && playlist.leafCount! > 0) { parts.add(t.playlists.itemCount(count: playlist.leafCount!)); @@ -753,8 +492,8 @@ class _MediaCardListState extends State<_MediaCardList> if (playlist.smart) { parts.add(t.playlists.smartPlaylist); } - } else if (widget.item is PlexMetadata) { - final metadata = widget.item as PlexMetadata; + } else if (item is PlexMetadata) { + final metadata = item as PlexMetadata; // For collections, show item count if (metadata.type.toLowerCase() == 'collection') { @@ -799,11 +538,11 @@ class _MediaCardListState extends State<_MediaCardList> } String? _buildSubtitleText() { - if (widget.item is PlexPlaylist) { + if (item is PlexPlaylist) { // Playlists don't have subtitles return null; - } else if (widget.item is PlexMetadata) { - final metadata = widget.item as PlexMetadata; + } else if (item is PlexMetadata) { + final metadata = item as PlexMetadata; // For TV episodes, show S#E# format if (metadata.parentIndex != null && metadata.index != null) { @@ -827,112 +566,103 @@ class _MediaCardListState extends State<_MediaCardList> final metadataLine = _buildMetadataLine(); final subtitle = _buildSubtitleText(); - return FocusableWrapper( - onKeyEvent: (node, event) => handleKeyboardLongPress(event), - onScrollIntoView: _scrollToCenterIfNeeded, - builder: (context, isFocused) => FocusIndicator( - isFocused: isFocused, - borderRadius: 8, - child: Semantics( - label: widget.semanticLabel, - button: true, - child: InkWell( - onTap: () => widget.onTap(isKeyboard: false), - 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), - ], + 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), ), - ), - 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, - ), - ), - ], - ], - ), - ), - ], + _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(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 2cea2d76..5862af14 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1,6 +1,5 @@ import 'dart:io'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../services/plex_client.dart'; import '../models/plex_metadata.dart'; @@ -10,7 +9,6 @@ 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'; @@ -371,10 +369,7 @@ class MediaContextMenuState extends State { await _navigateToRelated( context, metadata!.parentRatingKey, - (metadata) => SeasonDetailScreen( - season: metadata, - focusFirstEpisode: _openedFromKeyboard, - ), + (metadata) => SeasonDetailScreen(season: metadata), t.messages.errorLoadingSeason, ); break; @@ -1446,111 +1441,29 @@ class _FocusableContextMenuSheet extends StatefulWidget { 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 (isKeyboardActivationKey(event.logicalKey)) { - 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, - ), + return 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.withValues(alpha: 0.1), - ); - }, - ), - ); - }), - ], - ), + ), + ...widget.actions.map((action) { + return ListTile( + leading: Icon(action.icon), + title: Text(action.label), + onTap: () => Navigator.pop(context, action.value), + ); + }), + ], ), ); } @@ -1573,66 +1486,6 @@ class _FocusablePopupMenu extends StatefulWidget { } 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 (isKeyboardActivationKey(event.logicalKey)) { - Navigator.pop(context, widget.actions[_focusedIndex].value); - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - } - @override Widget build(BuildContext context) { final screenSize = MediaQuery.of(context).size; @@ -1667,60 +1520,36 @@ class _FocusablePopupMenuState extends State<_FocusablePopupMenu> { 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.withValues(alpha: 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)), - ], - ), - ), - ); - }, + 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.map((action) { + return InkWell( + onTap: () => Navigator.pop(context, action.value), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, ), - ); - }).toList(), - ), + child: Row( + children: [ + Icon(action.icon, size: 20), + const SizedBox(width: 12), + Expanded(child: Text(action.label)), + ], + ), + ), + ); + }).toList(), ), ), ),