From d9ea099727196c5b5e68c4474136e40fcc8f96f3 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 7 Dec 2025 15:00:28 +0100 Subject: [PATCH] refactor: deduplicate --- lib/focus/dpad_navigator.dart | 6 +- lib/focus/focusable_chip_mixin.dart | 63 + lib/focus/focusable_list_item.dart | 1 - lib/focus/focusable_scroll_section.dart | 9 +- lib/focus/focusable_wrapper.dart | 155 +- lib/focus/input_mode_tracker.dart | 19 +- lib/focus/key_event_utils.dart | 41 + lib/focus/locked_hub_controller.dart | 3 +- lib/mixins/library_tab_focus_mixin.dart | 34 + lib/screens/auth_screen.dart | 134 +- lib/screens/collection_detail_screen.dart | 74 +- lib/screens/discover_screen.dart | 237 +-- lib/screens/hub_detail_screen.dart | 125 +- .../libraries/adaptive_media_grid.dart | 95 +- lib/screens/libraries/folder_tree_view.dart | 33 +- lib/screens/libraries/libraries_screen.dart | 101 +- lib/screens/libraries/sort_bottom_sheet.dart | 5 +- .../libraries/tabs/base_library_tab.dart | 23 +- .../libraries/tabs/library_browse_tab.dart | 278 ++-- .../tabs/library_collections_tab.dart | 35 +- .../libraries/tabs/library_playlists_tab.dart | 73 +- .../tabs/library_recommended_tab.dart | 3 +- lib/screens/main_screen.dart | 16 +- lib/screens/media_detail_screen.dart | 1284 ++++++++--------- .../playlist/playlist_detail_screen.dart | 141 +- lib/screens/playlist/playlist_item_card.dart | 2 +- lib/screens/profile/user_avatar_widget.dart | 215 +-- lib/screens/search_screen.dart | 59 +- lib/screens/season_detail_screen.dart | 184 ++- lib/screens/settings/about_screen.dart | 132 +- lib/screens/settings/licenses_screen.dart | 251 ++-- .../base_shared_preferences_service.dart | 45 + lib/services/settings_service.dart | 206 ++- lib/services/storage_service.dart | 144 +- lib/utils/desktop_window_padding.dart | 13 +- lib/utils/media_navigation_helper.dart | 60 + lib/utils/sliver_adaptive_media_builder.dart | 51 + lib/widgets/desktop_app_bar.dart | 254 +++- lib/widgets/focus_builders.dart | 150 ++ lib/widgets/focusable_filter_chip.dart | 92 +- lib/widgets/focusable_media_card.dart | 223 +-- lib/widgets/focusable_tab_chip.dart | 66 +- lib/widgets/focused_scroll_scaffold.dart | 51 + .../horizontal_scroll_with_arrows.dart | 73 +- lib/widgets/hub_section.dart | 105 +- lib/widgets/media_card.dart | 181 +-- lib/widgets/media_grid_delegate.dart | 39 + lib/widgets/media_grid_sliver.dart | 75 + lib/widgets/plex_optimized_image.dart | 111 +- lib/widgets/side_navigation_rail.dart | 326 +++-- .../helpers/track_filter_helper.dart | 48 + .../helpers/track_selection_helper.dart | 76 + .../video_controls/sheets/chapter_sheet.dart | 5 +- .../sheets/track_selection_sheet.dart | 91 +- .../video_controls/sheets/version_sheet.dart | 3 +- .../sheets/video_control_sheet_launcher.dart | 22 + .../sheets/video_settings_sheet.dart | 85 +- .../widgets/track_chapter_controls.dart | 11 +- 58 files changed, 3376 insertions(+), 3061 deletions(-) create mode 100644 lib/focus/focusable_chip_mixin.dart create mode 100644 lib/focus/key_event_utils.dart create mode 100644 lib/mixins/library_tab_focus_mixin.dart create mode 100644 lib/services/base_shared_preferences_service.dart create mode 100644 lib/utils/media_navigation_helper.dart create mode 100644 lib/utils/sliver_adaptive_media_builder.dart create mode 100644 lib/widgets/focus_builders.dart create mode 100644 lib/widgets/focused_scroll_scaffold.dart create mode 100644 lib/widgets/media_grid_delegate.dart create mode 100644 lib/widgets/media_grid_sliver.dart create mode 100644 lib/widgets/video_controls/helpers/track_filter_helper.dart create mode 100644 lib/widgets/video_controls/helpers/track_selection_helper.dart create mode 100644 lib/widgets/video_controls/sheets/video_control_sheet_launcher.dart diff --git a/lib/focus/dpad_navigator.dart b/lib/focus/dpad_navigator.dart index d82b5313..0de4a324 100644 --- a/lib/focus/dpad_navigator.dart +++ b/lib/focus/dpad_navigator.dart @@ -37,11 +37,7 @@ class DpadNavigator extends StatelessWidget { return child; } - return Focus( - autofocus: true, - onKeyEvent: _handleKeyEvent, - child: child, - ); + return Focus(autofocus: true, onKeyEvent: _handleKeyEvent, child: child); } KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { diff --git a/lib/focus/focusable_chip_mixin.dart b/lib/focus/focusable_chip_mixin.dart new file mode 100644 index 00000000..f6b5bc77 --- /dev/null +++ b/lib/focus/focusable_chip_mixin.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +/// A mixin that provides common FocusNode lifecycle management for chip widgets. +/// +/// This mixin handles: +/// - Internal/external FocusNode pattern +/// - `_isFocused` state tracking +/// - Listener setup in `initState` +/// - Listener handoff in `didUpdateWidget` +/// - Cleanup in `dispose` +/// +/// To use this mixin: +/// 1. Add `with FocusableChipStateMixin` to your State class +/// 2. Implement [widgetFocusNode] to return the widget's optional focusNode +/// 3. Implement [debugLabel] to return a debug label for the internal node +/// 4. Call [initFocusNode] in your `initState` +/// 5. Call [updateFocusNode] in your `didUpdateWidget` +/// 6. Call [disposeFocusNode] in your `dispose` +/// 7. Use [focusNode] and [isFocused] in your build method +mixin FocusableChipStateMixin on State { + FocusNode? _internalFocusNode; + bool _isFocused = false; + + /// Override to return the widget's optional external focus node. + FocusNode? get widgetFocusNode; + + /// Override to return a debug label for the internal focus node. + String get debugLabel; + + /// The active focus node (external if provided, otherwise internal). + FocusNode get focusNode { + return widgetFocusNode ?? + (_internalFocusNode ??= FocusNode(debugLabel: debugLabel)); + } + + /// Whether this widget is currently focused. + bool get isFocused => _isFocused; + + /// Call this in your `initState` to set up the focus listener. + void initFocusNode() { + focusNode.addListener(_onFocusChange); + } + + /// Call this in your `didUpdateWidget` with the old widget's focusNode. + void updateFocusNode(FocusNode? oldFocusNode) { + if (oldFocusNode != widgetFocusNode) { + oldFocusNode?.removeListener(_onFocusChange); + focusNode.addListener(_onFocusChange); + } + } + + /// Call this in your `dispose` to clean up the focus listener. + void disposeFocusNode() { + focusNode.removeListener(_onFocusChange); + _internalFocusNode?.dispose(); + } + + void _onFocusChange() { + if (mounted) { + setState(() => _isFocused = focusNode.hasFocus); + } + } +} diff --git a/lib/focus/focusable_list_item.dart b/lib/focus/focusable_list_item.dart index 29647642..8e38b8f1 100644 --- a/lib/focus/focusable_list_item.dart +++ b/lib/focus/focusable_list_item.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'focusable_wrapper.dart'; -import 'focus_theme.dart'; /// A focusable list item for settings screens and menus. /// diff --git a/lib/focus/focusable_scroll_section.dart b/lib/focus/focusable_scroll_section.dart index b72b65c7..9f660a12 100644 --- a/lib/focus/focusable_scroll_section.dart +++ b/lib/focus/focusable_scroll_section.dart @@ -31,7 +31,7 @@ class FocusableScrollSection extends StatefulWidget { /// Builder for each focusable item. /// The [focusNode] should be passed to a FocusableWrapper or Focus widget. final Widget Function(BuildContext context, int index, FocusNode focusNode) - itemBuilder; + itemBuilder; /// Optional scroll controller for external control. final ScrollController? scrollController; @@ -108,9 +108,7 @@ class _FocusableScrollSectionState extends State { void _createFocusNodes() { _disposeFocusNodes(); for (int i = 0; i < widget.itemCount; i++) { - final node = FocusNode( - debugLabel: '${widget.sectionId}_item_$i', - ); + final node = FocusNode(debugLabel: '${widget.sectionId}_item_$i'); node.addListener(() => _handleItemFocusChange(i, node.hasFocus)); _itemFocusNodes.add(node); } @@ -180,8 +178,7 @@ class _FocusableScrollSectionState extends State { // Check if section lost focus entirely WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - final stillHasFocus = - _itemFocusNodes.any((node) => node.hasFocus); + final stillHasFocus = _itemFocusNodes.any((node) => node.hasFocus); if (_hasFocus && !stillHasFocus) { _hasFocus = false; widget.onSectionBlurred?.call(); diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index 8a892da3..05e565d0 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'focus_theme.dart'; @@ -9,19 +11,29 @@ import 'input_mode_tracker.dart'; /// - Visual focus indicator (border + scale animation) /// - Keyboard/D-pad event handling (Enter/Select to activate) /// - Optional auto-scroll to keep focused item visible +/// - Long-press detection for SELECT key +/// - Navigation callbacks (UP, BACK) class FocusableWrapper extends StatefulWidget { /// The child widget to wrap. final Widget child; /// Called when the item is selected (Enter/Select/GamepadA). + /// For short press when [enableLongPress] is true. final VoidCallback? onSelect; - /// Called when long press is triggered (context menu key). + /// Called when long press is triggered (hold SELECT key or context menu key). + /// Only triggered if [enableLongPress] is true. final VoidCallback? onLongPress; /// Called when focus changes. final ValueChanged? onFocusChange; + /// Called when the user presses UP and there's no focusable item above. + final VoidCallback? onNavigateUp; + + /// Called when the user presses BACK. + final VoidCallback? onBack; + /// Whether this widget should request focus when first built. final bool autofocus; @@ -37,6 +49,10 @@ class FocusableWrapper extends StatefulWidget { /// Alignment for auto-scroll (0.0 = start, 0.5 = center, 1.0 = end). final double scrollAlignment; + /// Whether to use comfortable zone scrolling (only scroll if item is outside middle 60%). + /// If false, always scrolls to [scrollAlignment]. + final bool useComfortableZone; + /// Optional semantic label for accessibility. final String? semanticLabel; @@ -47,20 +63,33 @@ class FocusableWrapper extends StatefulWidget { /// This is called before the default key handling. final KeyEventResult Function(FocusNode node, KeyEvent event)? onKeyEvent; + /// Whether to enable long-press detection for SELECT key. + /// When enabled, holding SELECT triggers [onLongPress] after 500ms. + /// Short press triggers [onSelect]. + final bool enableLongPress; + + /// Duration for long-press detection. + final Duration longPressDuration; + const FocusableWrapper({ super.key, required this.child, this.onSelect, this.onLongPress, this.onFocusChange, + this.onNavigateUp, + this.onBack, this.autofocus = false, this.focusNode, this.borderRadius = FocusTheme.defaultBorderRadius, this.autoScroll = true, this.scrollAlignment = 0.5, + this.useComfortableZone = false, this.semanticLabel, this.canRequestFocus = true, this.onKeyEvent, + this.enableLongPress = false, + this.longPressDuration = const Duration(milliseconds: 500), }); @override @@ -76,6 +105,10 @@ class _FocusableWrapperState extends State late AnimationController _animationController; late Animation _scaleAnimation; + // Long-press detection for SELECT key + Timer? _longPressTimer; + bool _isSelectKeyDown = false; + @override void initState() { super.initState(); @@ -102,13 +135,13 @@ class _FocusableWrapperState extends State duration: const Duration(milliseconds: 150), ); - _scaleAnimation = Tween( - begin: 1.0, - end: FocusTheme.focusScale, - ).animate(CurvedAnimation( - parent: _animationController, - curve: Curves.easeOutCubic, - )); + _scaleAnimation = Tween(begin: 1.0, end: FocusTheme.focusScale) + .animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.easeOutCubic, + ), + ); } @override @@ -131,6 +164,7 @@ class _FocusableWrapperState extends State @override void dispose() { + _longPressTimer?.cancel(); _animationController.dispose(); if (_ownsNode) { _focusNode.dispose(); @@ -163,11 +197,43 @@ class _FocusableWrapperState extends State void _scrollIntoView() { WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; + if (!mounted || !_isFocused) return; final renderObject = context.findRenderObject(); if (renderObject == null) return; + if (widget.useComfortableZone) { + // Check if item is already in the comfortable zone + final scrollable = Scrollable.maybeOf(context); + if (scrollable == null) return; + + final viewport = scrollable.context.findRenderObject() as RenderBox?; + if (viewport == null) return; + + // Get item's position relative to viewport + final itemBox = renderObject as RenderBox; + final itemPosition = itemBox.localToGlobal( + Offset.zero, + ancestor: viewport, + ); + + // Check if item is already in the comfortable zone + final viewportHeight = viewport.size.height; + final itemHeight = itemBox.size.height; + final itemVerticalCenter = itemPosition.dy + itemHeight / 2; + + // Define comfortable zone - if item center is within middle 60% of viewport, don't scroll + final comfortZoneTop = viewportHeight * 0.2; + final comfortZoneBottom = viewportHeight * 0.8; + + if (itemVerticalCenter >= comfortZoneTop && + itemVerticalCenter <= comfortZoneBottom) { + // Item is in comfortable zone, no need to scroll + return; + } + } + + // Item is outside comfortable zone or comfortable zone disabled, scroll to alignment Scrollable.ensureVisible( context, alignment: widget.scrollAlignment, @@ -178,7 +244,7 @@ class _FocusableWrapperState extends State } KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; + final key = event.logicalKey; // Call custom key handler first if (widget.onKeyEvent != null) { @@ -188,15 +254,65 @@ class _FocusableWrapperState extends State } } - // Handle Select/Enter for activation - if (_isSelectKey(event.logicalKey)) { - widget.onSelect?.call(); + // Handle SELECT key with optional long-press detection + if (_isSelectKey(key)) { + if (widget.enableLongPress) { + if (event is KeyDownEvent) { + // Only start timer on initial press, not repeats + if (!_isSelectKeyDown) { + _isSelectKeyDown = true; + _longPressTimer?.cancel(); + _longPressTimer = Timer(widget.longPressDuration, () { + // Long press detected + if (mounted) { + widget.onLongPress?.call(); + } + }); + } + return KeyEventResult.handled; + } else if (event is KeyRepeatEvent) { + // Consume repeat events to prevent system sounds + return KeyEventResult.handled; + } else if (event is KeyUpEvent) { + final timerWasActive = _longPressTimer?.isActive ?? false; + _longPressTimer?.cancel(); + if (timerWasActive && _isSelectKeyDown) { + // Timer still active - short press + widget.onSelect?.call(); + } + // If timer already fired, long press was triggered - do nothing on key up + _isSelectKeyDown = false; + return KeyEventResult.handled; + } + } else { + // Simple select handling without long-press + if (event is KeyDownEvent) { + widget.onSelect?.call(); + return KeyEventResult.handled; + } + } + } + + // Ignore key repeat events for other keys + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + // Context menu key + if (_isContextMenuKey(key)) { + widget.onLongPress?.call(); return KeyEventResult.handled; } - // Handle context menu key - if (_isContextMenuKey(event.logicalKey)) { - widget.onLongPress?.call(); + // UP arrow - if callback provided, navigate up + if (key == LogicalKeyboardKey.arrowUp && widget.onNavigateUp != null) { + widget.onNavigateUp!(); + return KeyEventResult.handled; + } + + // BACK key + if (_isBackKey(key) && widget.onBack != null) { + widget.onBack!(); return KeyEventResult.handled; } @@ -215,6 +331,13 @@ class _FocusableWrapperState extends State key == LogicalKeyboardKey.gameButtonX; } + bool _isBackKey(LogicalKeyboardKey key) { + return key == LogicalKeyboardKey.escape || + key == LogicalKeyboardKey.goBack || + key == LogicalKeyboardKey.browserBack || + key == LogicalKeyboardKey.gameButtonB; + } + @override Widget build(BuildContext context) { final duration = FocusTheme.getAnimationDuration(context); diff --git a/lib/focus/input_mode_tracker.dart b/lib/focus/input_mode_tracker.dart index 363b9381..fe02e123 100644 --- a/lib/focus/input_mode_tracker.dart +++ b/lib/focus/input_mode_tracker.dart @@ -28,8 +28,8 @@ class InputModeTracker extends StatefulWidget { /// Get the current input mode. static InputMode of(BuildContext context) { - final provider = - context.dependOnInheritedWidgetOfExactType<_InputModeProvider>(); + final provider = context + .dependOnInheritedWidgetOfExactType<_InputModeProvider>(); return provider?.mode ?? InputMode.pointer; } @@ -44,8 +44,9 @@ class InputModeTracker extends StatefulWidget { class _InputModeTrackerState extends State { // Default to keyboard mode on Android TV, pointer mode elsewhere - InputMode _mode = - TvDetectionService.isTVSync() ? InputMode.keyboard : InputMode.pointer; + InputMode _mode = TvDetectionService.isTVSync() + ? InputMode.keyboard + : InputMode.pointer; @override void initState() { @@ -82,10 +83,7 @@ class _InputModeTrackerState extends State { onPointerDown: (_) => _setMode(InputMode.pointer), onPointerHover: (_) => _setMode(InputMode.pointer), behavior: HitTestBehavior.translucent, - child: _InputModeProvider( - mode: _mode, - child: widget.child, - ), + child: _InputModeProvider(mode: _mode, child: widget.child), ); } } @@ -94,10 +92,7 @@ class _InputModeTrackerState extends State { class _InputModeProvider extends InheritedWidget { final InputMode mode; - const _InputModeProvider({ - required this.mode, - required super.child, - }); + const _InputModeProvider({required this.mode, required super.child}); @override bool updateShouldNotify(_InputModeProvider oldWidget) { diff --git a/lib/focus/key_event_utils.dart b/lib/focus/key_event_utils.dart new file mode 100644 index 00000000..fd5be15b --- /dev/null +++ b/lib/focus/key_event_utils.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'dpad_navigator.dart'; + +/// Handles back key events by popping the current route. +/// +/// Optionally pass a [result] to return to the previous route. +/// +/// Use this as an `onKeyEvent` callback for Focus widgets that need +/// simple back navigation behavior: +/// +/// ```dart +/// Focus( +/// onKeyEvent: (node, event) => handleBackKeyNavigation(context, event), +/// child: ... +/// ) +/// ``` +/// +/// With a result value: +/// ```dart +/// Focus( +/// onKeyEvent: (node, event) => handleBackKeyNavigation( +/// context, +/// event, +/// result: _hasChanges, +/// ), +/// child: ... +/// ) +/// ``` +KeyEventResult handleBackKeyNavigation( + BuildContext context, + KeyEvent event, { + T? result, +}) { + if (event is KeyDownEvent && event.logicalKey.isBackKey) { + Navigator.pop(context, result); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; +} diff --git a/lib/focus/locked_hub_controller.dart b/lib/focus/locked_hub_controller.dart index 815e4307..d3c40857 100644 --- a/lib/focus/locked_hub_controller.dart +++ b/lib/focus/locked_hub_controller.dart @@ -86,7 +86,8 @@ class LockedHubController extends ChangeNotifier { if (!scrollController.hasClients) return; final viewport = scrollController.position.viewportDimension; - final targetCenter = leadingPadding + (index * itemExtent) + (itemExtent / 2); + final targetCenter = + leadingPadding + (index * itemExtent) + (itemExtent / 2); final desiredOffset = (targetCenter - (viewport / 2)).clamp( 0.0, scrollController.position.maxScrollExtent, diff --git a/lib/mixins/library_tab_focus_mixin.dart b/lib/mixins/library_tab_focus_mixin.dart new file mode 100644 index 00000000..341fe3d4 --- /dev/null +++ b/lib/mixins/library_tab_focus_mixin.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; + +/// Mixin that provides focus management for library tabs. +/// Handles the lifecycle of a focus node for the first item and provides +/// a method to request focus on that item. +mixin LibraryTabFocusMixin on State { + /// Focus node for the first item (for programmatic focus) + late final FocusNode firstItemFocusNode; + + /// Debug label for the focus node + String get focusNodeDebugLabel; + + /// Number of items in the list/grid + int get itemCount; + + @override + void initState() { + super.initState(); + firstItemFocusNode = FocusNode(debugLabel: focusNodeDebugLabel); + } + + @override + void dispose() { + firstItemFocusNode.dispose(); + super.dispose(); + } + + /// Focus the first item in the grid/list (for tab activation) + void focusFirstItem() { + if (itemCount > 0) { + firstItemFocusNode.requestFocus(); + } + } +} diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index 03d5b6d7..8b238aeb 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -330,37 +330,10 @@ class _AuthScreenState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (_isAuthenticating) ...[ - if (_useQrFlow && _qrAuthUrl != null) ...[ - // QR code flow - hint text above QR code - Text( - t.auth.scanQRCodeInstruction, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.grey), - ), - const SizedBox(height: 24), - Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: QrImageView( - data: _qrAuthUrl!, - size: 300, - version: QrVersions.auto, - backgroundColor: Colors.white, - ), - ), - ), - _buildRetryButton(), - ] else ...[ - // Browser auth flow - show spinner and waiting message - const Center(child: CircularProgressIndicator()), - const SizedBox(height: 16), - Text( - t.auth.waitingForAuth, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.grey), - ), - _buildRetryButton(), - ], + if (_useQrFlow && _qrAuthUrl != null) + _buildQrAuthWidget(qrSize: 300) + else + _buildBrowserAuthWidget(), ] else ...[ // Initial state buttons ElevatedButton( @@ -436,57 +409,10 @@ class _AuthScreenState extends State { ), const SizedBox(height: 48), if (_isAuthenticating) ...[ - if (_useQrFlow && _qrAuthUrl != null) ...[ - // QR code flow - show QR code and scan instruction - Text( - t.auth.scanQRCodeInstruction, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.grey), - ), - const SizedBox(height: 24), - Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: QrImageView( - data: _qrAuthUrl!, - size: 200, - version: QrVersions.auto, - backgroundColor: Colors.white, - ), - ), - ), - const SizedBox(height: 24), - OutlinedButton( - onPressed: _retryAuthentication, - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - vertical: 12, - horizontal: 24, - ), - ), - child: Text(t.auth.retry), - ), - ] else ...[ - // Browser auth flow - show spinner and waiting message - const Center(child: CircularProgressIndicator()), - const SizedBox(height: 16), - Text( - t.auth.waitingForAuth, - textAlign: TextAlign.center, - style: const TextStyle(color: Colors.grey), - ), - const SizedBox(height: 24), - OutlinedButton( - onPressed: _retryAuthentication, - style: OutlinedButton.styleFrom( - padding: const EdgeInsets.symmetric( - vertical: 12, - horizontal: 24, - ), - ), - child: Text(t.auth.retry), - ), - ], + if (_useQrFlow && _qrAuthUrl != null) + _buildQrAuthWidget(qrSize: 200) + else + _buildBrowserAuthWidget(), ] else ...[ // add QR button here ElevatedButton( @@ -560,4 +486,48 @@ class _AuthScreenState extends State { ], ); } + + /// Builds the QR code authentication widget + Widget _buildQrAuthWidget({required double qrSize}) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + t.auth.scanQRCodeInstruction, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.grey), + ), + const SizedBox(height: 24), + Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: QrImageView( + data: _qrAuthUrl!, + size: qrSize, + version: QrVersions.auto, + backgroundColor: Colors.white, + ), + ), + ), + _buildRetryButton(), + ], + ); + } + + /// Builds the browser authentication waiting widget + Widget _buildBrowserAuthWidget() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Center(child: CircularProgressIndicator()), + const SizedBox(height: 16), + Text( + t.auth.waitingForAuth, + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.grey), + ), + _buildRetryButton(), + ], + ); + } } diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index 8b0162c9..a1add47b 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -1,13 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import '../focus/dpad_navigator.dart'; import '../models/plex_metadata.dart'; -import '../providers/settings_provider.dart'; -import '../widgets/media_card.dart'; -import '../widgets/desktop_app_bar.dart'; +import '../widgets/media_grid_sliver.dart'; +import '../widgets/focused_scroll_scaffold.dart'; import '../i18n/strings.g.dart'; -import '../utils/grid_size_calculator.dart'; import '../utils/dialogs.dart'; import '../utils/app_logger.dart'; import 'base_media_list_detail_screen.dart'; @@ -114,61 +109,22 @@ class _CollectionDetailScreenState } } - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { - if (event is KeyDownEvent && event.logicalKey.isBackKey) { - Navigator.pop(context); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - @override Widget build(BuildContext context) { - return Focus( - autofocus: true, - onKeyEvent: _handleKeyEvent, - child: Scaffold( - body: CustomScrollView( - slivers: [ - CustomAppBar( - title: Text(widget.collection.title), - pinned: true, - actions: buildAppBarActions(onDelete: _deleteCollection), + return FocusedScrollScaffold( + title: Text(widget.collection.title), + actions: buildAppBarActions(onDelete: _deleteCollection), + slivers: [ + ...buildStateSlivers(), + if (items.isNotEmpty) + MediaGridSliver( + items: items, + onRefresh: updateItem, + collectionId: widget.collection.ratingKey, + onListRefresh: loadItems, + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), ), - ...buildStateSlivers(), - if (items.isNotEmpty) - SliverPadding( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), - sliver: Consumer( - builder: (context, settingsProvider, child) { - return SliverGrid( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: - GridSizeCalculator.getMaxCrossAxisExtent( - context, - settingsProvider.libraryDensity, - ), - childAspectRatio: 2 / 3.3, - crossAxisSpacing: 0, - mainAxisSpacing: 0, - ), - delegate: SliverChildBuilderDelegate((context, index) { - final item = items[index]; - return MediaCard( - key: Key(item.ratingKey), - item: item, - onRefresh: updateItem, - collectionId: widget.collection.ratingKey, - onListRefresh: loadItems, - ); - }, childCount: items.length), - ); - }, - ), - ), - ], - ), - ), + ], ); } } diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 434820a1..e1653984 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -747,8 +747,8 @@ class _DiscoverScreenState extends State /// Show user menu programmatically (for D-pad select) void _showUserMenu(BuildContext context, UserProfileProvider userProvider) { - final RenderBox? button = _userButtonFocusNode.context - ?.findRenderObject() as RenderBox?; + final RenderBox? button = + _userButtonFocusNode.context?.findRenderObject() as RenderBox?; if (button == null) return; final RenderBox overlay = @@ -821,7 +821,9 @@ class _DiscoverScreenState extends State child: Container( decoration: BoxDecoration( color: _isRefreshFocused - ? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08) + ? Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.08) : Colors.transparent, borderRadius: BorderRadius.circular(20), ), @@ -839,7 +841,9 @@ class _DiscoverScreenState extends State child: DecoratedBox( decoration: BoxDecoration( color: _isUserFocused - ? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08) + ? Theme.of( + context, + ).colorScheme.onSurface.withValues(alpha: 0.08) : Colors.transparent, borderRadius: BorderRadius.circular(20), ), @@ -919,8 +923,7 @@ class _DiscoverScreenState extends State // Hero Section (Continue Watching) Consumer( builder: (context, settingsProvider, child) { - if (_onDeck.isNotEmpty && - settingsProvider.showHeroSection) { + if (_onDeck.isNotEmpty && settingsProvider.showHeroSection) { return _buildHeroSection(); } return const SliverToBoxAdapter(child: SizedBox.shrink()); @@ -1054,124 +1057,124 @@ class _DiscoverScreenState extends State 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', + 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, - ), - width: maxWidth, - height: dotSize, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular( - dotSize / 2, + if (isActive) { + // Animated progress indicator for active page + return AnimatedBuilder( + animation: _indicatorAnimationController, + builder: (context, child) { + // Fill width animates based on dot size + final maxWidth = + dotSize * + 3; // 24px for normal, 15px for small + final fillWidth = + dotSize + + ((maxWidth - dotSize) * + _indicatorAnimationController.value); + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + margin: const EdgeInsets.symmetric( + horizontal: 4, ), - ), - child: Align( - alignment: Alignment.centerLeft, - child: Container( - width: fillWidth, - height: dotSize, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular( - dotSize / 2, + width: maxWidth, + height: dotSize, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular( + dotSize / 2, + ), + ), + child: Align( + alignment: Alignment.centerLeft, + child: Container( + width: fillWidth, + height: dotSize, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular( + dotSize / 2, + ), ), ), ), - ), - ); - }, - ); - } else { - // Static indicator for inactive pages - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - margin: const EdgeInsets.symmetric(horizontal: 4), - width: dotSize, - height: dotSize, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(dotSize / 2), - ), - ); - } - }); - }(), - ], + ); + }, + ); + } else { + // Static indicator for inactive pages + return AnimatedContainer( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + margin: const EdgeInsets.symmetric(horizontal: 4), + width: dotSize, + height: dotSize, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(dotSize / 2), + ), + ); + } + }); + }(), + ], + ), ), - ), - ], - ), + ], + ), ), ), ); diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 92342d94..ff7cbaf7 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -1,17 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import '../focus/dpad_navigator.dart'; import '../../services/plex_client.dart'; import '../models/plex_hub.dart'; import '../models/plex_metadata.dart'; import '../models/plex_sort.dart'; -import '../providers/settings_provider.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; -import '../utils/grid_cross_axis_extent.dart'; -import '../widgets/media_card.dart'; -import '../widgets/desktop_app_bar.dart'; +import '../widgets/media_grid_sliver.dart'; +import '../widgets/focused_scroll_scaffold.dart'; import 'libraries/sort_bottom_sheet.dart'; import '../mixins/refreshable.dart'; import '../i18n/strings.g.dart'; @@ -242,87 +237,51 @@ class _HubDetailScreenState extends State with Refreshable { _loadMoreItems(); } - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { - if (event is KeyDownEvent && event.logicalKey.isBackKey) { - Navigator.pop(context); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - @override Widget build(BuildContext context) { - return Focus( - autofocus: true, - onKeyEvent: _handleKeyEvent, - child: Scaffold( - body: CustomScrollView( - slivers: [ - CustomAppBar( - title: Text(widget.hub.title), - pinned: true, - actions: [ - IconButton( - icon: Icon(Icons.swap_vert, semanticLabel: t.libraries.sort), - onPressed: _showSortBottomSheet, - ), - ], - ), - 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: _loadMoreItems, - child: Text(t.common.retry), - ), - ], - ), - ), - ) - else if (_filteredItems.isEmpty && _isLoading) - const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), - ) - else if (_filteredItems.isEmpty) - SliverFillRemaining( - child: Center(child: Text(t.hubDetail.noItemsFound)), - ) - else - SliverPadding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), - sliver: SliverGrid( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: getMaxCrossAxisExtentWithPadding( - context, - context.watch().libraryDensity, - 16, + return FocusedScrollScaffold( + title: Text(widget.hub.title), + actions: [ + IconButton( + icon: Icon(Icons.swap_vert, semanticLabel: t.libraries.sort), + onPressed: _showSortBottomSheet, + ), + ], + slivers: [ + 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: _loadMoreItems, + child: Text(t.common.retry), ), - childAspectRatio: 2 / 3.3, - crossAxisSpacing: 0, - mainAxisSpacing: 0, - ), - delegate: SliverChildBuilderDelegate((context, index) { - return MediaCard( - item: _filteredItems[index], - onRefresh: _handleItemRefresh, - ); - }, childCount: _filteredItems.length), + ], ), ), - ], - ), - ), + ) + else if (_filteredItems.isEmpty && _isLoading) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_filteredItems.isEmpty) + SliverFillRemaining( + child: Center(child: Text(t.hubDetail.noItemsFound)), + ) + else + MediaGridSliver( + items: _filteredItems, + onRefresh: _handleItemRefresh, + usePaddingAwareExtent: true, + horizontalPadding: 16, + ), + ], ); } } diff --git a/lib/screens/libraries/adaptive_media_grid.dart b/lib/screens/libraries/adaptive_media_grid.dart index 4af66840..80f194e4 100644 --- a/lib/screens/libraries/adaptive_media_grid.dart +++ b/lib/screens/libraries/adaptive_media_grid.dart @@ -1,17 +1,20 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../../models/plex_metadata.dart'; import '../../providers/settings_provider.dart'; -import '../../services/settings_service.dart' show ViewMode; +import '../../services/settings_service.dart' show ViewMode, LibraryDensity; import '../../utils/grid_size_calculator.dart'; -import '../../widgets/focusable_media_card.dart'; /// A widget that automatically switches between grid and list view /// based on user settings, providing a consistent layout pattern -/// across all library screens -class AdaptiveMediaGrid extends StatelessWidget { - /// The list of media items to display - final List items; +/// across all library screens. +/// +/// Generic type T: The type of items being displayed +class AdaptiveMediaGrid extends StatelessWidget { + /// The list of items to display + final List items; + + /// Builder function for each item in the grid/list + final Widget Function(BuildContext context, T item, int index) itemBuilder; /// Callback when the list needs to be refreshed final VoidCallback? onRefresh; @@ -31,6 +34,7 @@ class AdaptiveMediaGrid extends StatelessWidget { const AdaptiveMediaGrid({ super.key, required this.items, + required this.itemBuilder, this.onRefresh, this.padding = const EdgeInsets.fromLTRB(8, 8, 8, 8), this.childAspectRatio = 2 / 3.3, @@ -42,47 +46,44 @@ class AdaptiveMediaGrid extends StatelessWidget { Widget build(BuildContext context) { return Consumer( builder: (context, settingsProvider, child) { - if (settingsProvider.viewMode == ViewMode.list) { - return ListView.builder( - padding: padding, - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - return FocusableMediaCard( - key: Key(item.ratingKey), - item: item, - focusNode: index == 0 ? firstItemFocusNode : null, - onListRefresh: onRefresh, - onBack: onBack, - ); - }, - ); - } else { - return GridView.builder( - padding: padding, - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( - context, - settingsProvider.libraryDensity, - ), - childAspectRatio: childAspectRatio, - crossAxisSpacing: 0, - mainAxisSpacing: 0, - ), - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; - return FocusableMediaCard( - key: Key(item.ratingKey), - item: item, - focusNode: index == 0 ? firstItemFocusNode : null, - onListRefresh: onRefresh, - onBack: onBack, - ); - }, - ); - } + return _buildItemsView( + context, + settingsProvider.viewMode, + settingsProvider.libraryDensity, + ); }, ); } + + /// Builds either a list or grid view based on the view mode + Widget _buildItemsView( + BuildContext context, + ViewMode viewMode, + LibraryDensity density, + ) { + if (viewMode == ViewMode.list) { + return ListView.builder( + padding: padding, + itemCount: items.length, + itemBuilder: (context, index) => + itemBuilder(context, items[index], index), + ); + } else { + return GridView.builder( + padding: padding, + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( + context, + density, + ), + childAspectRatio: childAspectRatio, + crossAxisSpacing: 0, + mainAxisSpacing: 0, + ), + itemCount: items.length, + itemBuilder: (context, index) => + itemBuilder(context, items[index], index), + ); + } + } } diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index 01158a7c..59088187 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -1,10 +1,8 @@ import 'package:flutter/material.dart'; import '../../models/plex_metadata.dart'; -import '../media_detail_screen.dart'; -import '../season_detail_screen.dart'; import '../../utils/app_logger.dart'; +import '../../utils/media_navigation_helper.dart'; import '../../utils/provider_extensions.dart'; -import '../../utils/video_player_navigation.dart'; import '../../i18n/strings.g.dart'; import 'folder_tree_item.dart'; import 'empty_state_widget.dart'; @@ -151,34 +149,7 @@ class _FolderTreeViewState extends State { } Future _handleItemTap(PlexMetadata item) async { - final itemType = item.type.toLowerCase(); - - // For episodes, start playback directly - if (itemType == 'episode') { - final result = await navigateToVideoPlayer(context, metadata: item); - if (result == true) { - widget.onRefresh?.call(item.ratingKey); - } - } else if (itemType == 'season') { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SeasonDetailScreen(season: item), - ), - ); - widget.onRefresh?.call(item.ratingKey); - } else { - // For all other types (shows, movies), show detail screen - final result = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => MediaDetailScreen(metadata: item), - ), - ); - if (result == true) { - widget.onRefresh?.call(item.ratingKey); - } - } + await navigateToMediaItem(context, item, onRefresh: widget.onRefresh); } bool _isFolder(PlexMetadata item) { diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index b7ff5a25..636d51e1 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -20,7 +20,6 @@ import 'context_menu_wrapper.dart'; import '../../services/storage_service.dart'; import '../../mixins/refreshable.dart'; import '../../mixins/item_updatable.dart'; -import '../../theme/theme_helper.dart'; import '../../i18n/strings.g.dart'; import '../../utils/error_message_utils.dart'; import 'tabs/library_browse_tab.dart'; @@ -66,7 +65,6 @@ class _LibrariesScreenState extends State String? _errorMessage; String? _selectedLibraryGlobalKey; bool _isInitialLoad = true; - List? _serverOrder; // Cached server order from storage /// When true, suppress auto-focus in tabs (used when navigating via tab bar) bool _suppressAutoFocus = false; @@ -91,10 +89,16 @@ class _LibrariesScreenState extends State final _libraryDropdownKey = GlobalKey>(); // Focus nodes for tab chips - final _recommendedTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_recommended'); + final _recommendedTabChipFocusNode = FocusNode( + debugLabel: 'tab_chip_recommended', + ); final _browseTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_browse'); - final _collectionsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_collections'); - final _playlistsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_playlists'); + final _collectionsTabChipFocusNode = FocusNode( + debugLabel: 'tab_chip_collections', + ); + final _playlistsTabChipFocusNode = FocusNode( + debugLabel: 'tab_chip_playlists', + ); @override void initState() { @@ -200,7 +204,9 @@ class _LibrariesScreenState extends State if (_tabController.index == tabIndex && mounted) { // Use post-frame callback to ensure the widget tree is fully built WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _tabController.index == tabIndex && !_suppressAutoFocus) { + if (mounted && + _tabController.index == tabIndex && + !_suppressAutoFocus) { _focusCurrentTab(); } }); @@ -283,37 +289,6 @@ class _LibrariesScreenState extends State return uniqueServerIds.length > 1; } - /// Get ordered list of server IDs from libraries - List _getOrderedServerIds(List libraries) { - // Get unique server IDs from libraries - final serverIds = libraries - .where((lib) => lib.serverId != null) - .map((lib) => lib.serverId!) - .toSet() - .toList(); - - if (_serverOrder == null || _serverOrder!.isEmpty) { - return serverIds; - } - - // Apply saved order, but include any new servers not in the saved order - final ordered = []; - for (final id in _serverOrder!) { - if (serverIds.contains(id)) { - ordered.add(id); - } - } - - // Add any servers not in saved order - for (final id in serverIds) { - if (!ordered.contains(id)) { - ordered.add(id); - } - } - - return ordered; - } - Future _loadLibraries() async { // Extract context dependencies before async gap final multiServerProvider = Provider.of( @@ -355,13 +330,9 @@ class _LibrariesScreenState extends State savedOrder, ); - // Load saved server order - final savedServerOrder = storage.getServerOrder(); - _updateState(() { _allLibraries = orderedLibraries; // Store all libraries with ordering applied - _serverOrder = savedServerOrder; _isLoadingLibraries = false; }); @@ -955,8 +926,8 @@ class _LibrariesScreenState extends State return visibleLibraries.map((library) { final isSelected = library.globalKey == _selectedLibraryGlobalKey; - final showServerName = nonUniqueNames.contains(library.title) && - library.serverName != null; + final showServerName = + nonUniqueNames.contains(library.title) && library.serverName != null; return PopupMenuItem( value: library.globalKey, @@ -965,9 +936,7 @@ class _LibrariesScreenState extends State Icon( _getLibraryIcon(library.type), size: 20, - color: isSelected - ? Theme.of(context).colorScheme.primary - : null, + color: isSelected ? Theme.of(context).colorScheme.primary : null, ), const SizedBox(width: 12), Expanded( @@ -978,7 +947,9 @@ class _LibrariesScreenState extends State Text( library.title, style: TextStyle( - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w400, color: isSelected ? Theme.of(context).colorScheme.primary : null, @@ -989,11 +960,9 @@ class _LibrariesScreenState extends State library.serverName!, style: TextStyle( fontSize: 11, - color: Theme.of(context) - .textTheme - .bodySmall - ?.color - ?.withValues(alpha: 0.6), + color: Theme.of( + context, + ).textTheme.bodySmall?.color?.withValues(alpha: 0.6), ), ), ], @@ -1614,10 +1583,12 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { focusNode: _listFocusNode, autofocus: InputModeTracker.isKeyboardMode(context), onKeyEvent: _handleKeyEvent, - child: _buildFlatLibraryList(scrollController, hiddenLibraryKeys), + child: _buildFlatLibraryList( + scrollController, + hiddenLibraryKeys, + ), ), ), - ], ); }, @@ -1640,7 +1611,8 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { buildDefaultDragHandles: false, itemBuilder: (context, index) { final library = _tempLibraries[index]; - final showServerName = nonUniqueNames.contains(library.title) && + final showServerName = + nonUniqueNames.contains(library.title) && library.serverName != null; final isFocused = isKeyboardMode && index == _focusedIndex; final isMoving = index == _movingIndex; @@ -1687,9 +1659,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { key: ValueKey(library.globalKey), opacity: isHidden ? 0.5 : 1.0, child: Container( - decoration: BoxDecoration( - color: tileColor, - ), + decoration: BoxDecoration(color: tileColor), child: ListTile( leading: Row( mainAxisSize: MainAxisSize.min, @@ -1713,11 +1683,9 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { library.serverName!, style: TextStyle( fontSize: 11, - color: Theme.of(context) - .textTheme - .bodySmall - ?.color - ?.withValues(alpha: 0.6), + color: Theme.of( + context, + ).textTheme.bodySmall?.color?.withValues(alpha: 0.6), ), ) : null, @@ -1732,7 +1700,9 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { ) : null, child: IconButton( - icon: Icon(isHidden ? Icons.visibility_off : Icons.visibility), + icon: Icon( + isHidden ? Icons.visibility_off : Icons.visibility, + ), tooltip: isHidden ? t.libraries.showLibrary : t.libraries.hideLibrary, @@ -1749,7 +1719,8 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { child: IconButton( icon: const Icon(Icons.more_vert), tooltip: t.libraries.libraryOptions, - onPressed: () => _showLibraryMenuBottomSheet(context, library), + onPressed: () => + _showLibraryMenuBottomSheet(context, library), ), ), ], diff --git a/lib/screens/libraries/sort_bottom_sheet.dart b/lib/screens/libraries/sort_bottom_sheet.dart index d04f60df..882d56bd 100644 --- a/lib/screens/libraries/sort_bottom_sheet.dart +++ b/lib/screens/libraries/sort_bottom_sheet.dart @@ -99,10 +99,7 @@ class _SortBottomSheetState extends State { ), ButtonSegment( value: true, - icon: Icon( - Icons.arrow_downward, - size: 16, - ), + icon: Icon(Icons.arrow_downward, size: 16), ), ], selected: {_currentDescending}, diff --git a/lib/screens/libraries/tabs/base_library_tab.dart b/lib/screens/libraries/tabs/base_library_tab.dart index 50672056..7b2bd03a 100644 --- a/lib/screens/libraries/tabs/base_library_tab.dart +++ b/lib/screens/libraries/tabs/base_library_tab.dart @@ -83,6 +83,17 @@ abstract class BaseLibraryTabState> List get items => _items; bool get isLoading => _isLoading; String? get errorMessage => _errorMessage; + bool get hasLoadedData => _hasLoadedData; + + // Setters for subclasses that override loadItems with custom logic + @protected + set items(List value) => _items = value; + @protected + set isLoading(bool value) => _isLoading = value; + @protected + set errorMessage(String? value) => _errorMessage = value; + @protected + set hasLoadedData(bool value) => _hasLoadedData = value; @override void initState() { @@ -125,7 +136,7 @@ abstract class BaseLibraryTabState> // Check if we should focus (became active after data loaded) if (widget.isActive && !oldWidget.isActive) { - _tryFocus(); + tryFocus(); } } @@ -151,11 +162,15 @@ abstract class BaseLibraryTabState> Stream? getRefreshStream() => null; /// Try to focus the first item if conditions are met (active + loaded + not yet focused) - void _tryFocus() { + @protected + void tryFocus() { // Don't auto-focus if suppressed (e.g., when navigating via tab bar) if (widget.suppressAutoFocus) return; - if (widget.isActive && _hasLoadedData && !_hasFocused && _items.isNotEmpty) { + if (widget.isActive && + _hasLoadedData && + !_hasFocused && + _items.isNotEmpty) { _hasFocused = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { @@ -190,7 +205,7 @@ abstract class BaseLibraryTabState> // Mark data as loaded and try to focus _hasLoadedData = true; - _tryFocus(); + tryFocus(); // Notify parent that data has loaded if (widget.onDataLoaded != null) { diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 0e31b54f..884c00a1 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -1,9 +1,7 @@ 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'; -import '../../../models/plex_library.dart'; import '../../../models/plex_metadata.dart'; import '../../../models/plex_filter.dart'; import '../../../models/plex_sort.dart'; @@ -12,6 +10,8 @@ import '../../../utils/error_message_utils.dart'; import '../../../utils/grid_size_calculator.dart'; import '../../../widgets/focusable_media_card.dart'; import '../../../widgets/focusable_filter_chip.dart'; +import '../../../widgets/media_grid_delegate.dart'; +import '../../../mixins/library_tab_focus_mixin.dart'; import '../folder_tree_view.dart'; import '../filters_bottom_sheet.dart'; import '../sort_bottom_sheet.dart'; @@ -21,82 +21,52 @@ import '../../../services/storage_service.dart'; import '../../../services/settings_service.dart' show ViewMode; import '../../../mixins/item_updatable.dart'; import '../../../mixins/library_tab_state.dart'; -import '../../../mixins/refreshable.dart'; import '../../../i18n/strings.g.dart'; +import 'base_library_tab.dart'; /// Browse tab for library screen /// Shows library items with grouping, filtering, and sorting -class LibraryBrowseTab extends StatefulWidget { - final PlexLibrary library; - final String? viewMode; - final String? density; - - /// Callback invoked when data has finished loading successfully. - /// Used by parent to trigger focus on the first item. - final VoidCallback? onDataLoaded; - - /// Whether this tab is currently the active/visible tab. - /// Used for internal focus management. - final bool isActive; - - /// Whether to suppress auto-focus when tab becomes active. - /// Used when navigating via tab bar to keep focus on the tab chips. - final bool suppressAutoFocus; - - /// Called when the user presses BACK in the tab content. - /// Used to navigate focus back to the tab bar. - final VoidCallback? onBack; - +class LibraryBrowseTab extends BaseLibraryTab { const LibraryBrowseTab({ super.key, - required this.library, - this.viewMode, - this.density, - this.onDataLoaded, - this.isActive = false, - this.suppressAutoFocus = false, - this.onBack, + required super.library, + super.viewMode, + super.density, + super.onDataLoaded, + super.isActive, + super.suppressAutoFocus, + super.onBack, }); @override State createState() => _LibraryBrowseTabState(); } -class _LibraryBrowseTabState extends State - with - AutomaticKeepAliveClientMixin, - ItemUpdatable, - Refreshable, - LibraryTabStateMixin { - @override - bool get wantKeepAlive => true; - - @override - PlexLibrary get library => widget.library; - +class _LibraryBrowseTabState + extends BaseLibraryTabState + with ItemUpdatable, LibraryTabStateMixin, LibraryTabFocusMixin { @override PlexClient get client => getClientForLibrary(); @override - void refresh() { - _loadContent(); - } + String get focusNodeDebugLabel => 'browse_first_item'; + + @override + int get itemCount => items.length; @override void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { setState(() { - final index = _items.indexWhere((item) => item.ratingKey == ratingKey); + final index = items.indexWhere((item) => item.ratingKey == ratingKey); if (index != -1) { - _items[index] = updatedMetadata; + items[index] = updatedMetadata; } }); } - List _items = []; + // Browse-specific state (not in base class) List _filters = []; List _sortOptions = []; - bool _isLoading = false; - String? _errorMessage; Map _selectedFilters = {}; PlexSort? _selectedSort; bool _isSortDescending = false; @@ -109,74 +79,59 @@ class _LibraryBrowseTabState extends State int _requestId = 0; static const int _pageSize = 500; - // Focus node for the first item (for programmatic focus) - final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'browse_first_item'); - // Focus nodes for filter chips - final FocusNode _groupingChipFocusNode = FocusNode(debugLabel: 'grouping_chip'); + final FocusNode _groupingChipFocusNode = FocusNode( + debugLabel: 'grouping_chip', + ); final FocusNode _filtersChipFocusNode = FocusNode(debugLabel: 'filters_chip'); final FocusNode _sortChipFocusNode = FocusNode(debugLabel: 'sort_chip'); - // Focus management - bool _hasLoadedData = false; - bool _hasFocused = false; - - @override - void initState() { - super.initState(); - _loadContent(); - } - - @override - void didUpdateWidget(LibraryBrowseTab oldWidget) { - super.didUpdateWidget(oldWidget); - // Reload if library changed - if (oldWidget.library.globalKey != widget.library.globalKey) { - // Reset focus state for new library - _hasFocused = false; - _hasLoadedData = false; - _loadContent(); - } - - // Check if we should focus (became active after data loaded) - if (widget.isActive && !oldWidget.isActive) { - _tryFocus(); - } - } - @override void dispose() { _cancelToken?.cancel(); - _firstItemFocusNode.dispose(); _groupingChipFocusNode.dispose(); _filtersChipFocusNode.dispose(); _sortChipFocusNode.dispose(); super.dispose(); } - /// Try to focus the first item if conditions are met (active + loaded + not yet focused) - void _tryFocus() { - // Don't auto-focus if suppressed (e.g., when navigating via tab bar) - if (widget.suppressAutoFocus) return; - - if (widget.isActive && _hasLoadedData && !_hasFocused && _items.isNotEmpty) { - _hasFocused = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - focusFirstItem(); - } - }); - } + // Override loadData to use our custom _loadContent + @override + Future> loadData() async { + // This is called by base class loadItems(), but we override loadItems() entirely + // So this just returns empty - actual loading is done in _loadContent + return []; } + // Override loadItems to use our custom loading with pagination + @override + Future loadItems() async { + await _loadContent(); + } + + // Required abstract implementations from base class + @override + IconData get emptyIcon => Icons.folder_open; + + @override + String get emptyMessage => t.libraries.thisLibraryIsEmpty; + + @override + String get errorContext => t.libraries.content; + + // Override buildContent - not used since we override build() + @override + Widget buildContent(List items) => const SizedBox.shrink(); + /// Focus the first item in the grid/list (for tab activation) + @override void focusFirstItem() { - if (_items.isNotEmpty) { + if (items.isNotEmpty) { // Request immediately, then once more on the next frame to handle cases // where the grid/list attaches after the initial focus attempt. void request() { - if (mounted && _items.isNotEmpty && !_firstItemFocusNode.hasFocus) { - _firstItemFocusNode.requestFocus(); + if (mounted && items.isNotEmpty && !firstItemFocusNode.hasFocus) { + firstItemFocusNode.requestFocus(); } } @@ -195,9 +150,9 @@ class _LibraryBrowseTabState extends State final client = getClientForLibrary(); setState(() { - _isLoading = true; - _errorMessage = null; - _items = []; + isLoading = true; + errorMessage = null; + items = []; _currentPage = 0; _hasMoreItems = true; // Clear filter/sort state while loading to prevent showing stale options @@ -255,7 +210,7 @@ class _LibraryBrowseTabState extends State } Future _loadItems({bool loadMore = false}) async { - if (loadMore && _isLoading) return; + if (loadMore && isLoading) return; if (!loadMore) { _currentPage = 0; @@ -269,9 +224,9 @@ class _LibraryBrowseTabState extends State _cancelToken = CancelToken(); setState(() { - _isLoading = true; + isLoading = true; if (!loadMore) { - _items = []; + items = []; } }); @@ -298,7 +253,7 @@ class _LibraryBrowseTabState extends State } // Items are automatically tagged with server info by PlexClient - final items = await client.getLibraryContent( + final loadedItems = await client.getLibraryContent( widget.library.key, start: _currentPage * _pageSize, size: _pageSize, @@ -310,19 +265,19 @@ class _LibraryBrowseTabState extends State setState(() { if (loadMore) { - _items.addAll(items); + items.addAll(loadedItems); } else { - _items = items; + items = loadedItems; } - _hasMoreItems = items.length >= _pageSize; + _hasMoreItems = loadedItems.length >= _pageSize; _currentPage++; - _isLoading = false; + isLoading = false; }); // On initial load (not pagination), mark data as loaded and try to focus if (!loadMore) { - _hasLoadedData = true; - _tryFocus(); + hasLoadedData = true; + tryFocus(); // Notify parent if (widget.onDataLoaded != null) { @@ -340,8 +295,8 @@ class _LibraryBrowseTabState extends State if (currentRequestId != _requestId) return; setState(() { - _errorMessage = _getErrorMessage(error); - _isLoading = false; + errorMessage = _getErrorMessage(error); + isLoading = false; }); } @@ -501,8 +456,8 @@ class _LibraryBrowseTabState extends State /// Navigate focus from chips down to the first grid item void _navigateToGrid() { - if (_items.isNotEmpty) { - _firstItemFocusNode.requestFocus(); + if (items.isNotEmpty) { + firstItemFocusNode.requestFocus(); } } @@ -512,8 +467,12 @@ class _LibraryBrowseTabState extends State } /// Calculate the number of columns in the current grid based on screen width - int _getGridColumnCount(BuildContext context, SettingsProvider settingsProvider) { - final screenWidth = MediaQuery.of(context).size.width - 16; // Subtract padding + int _getGridColumnCount( + BuildContext context, + SettingsProvider settingsProvider, + ) { + final screenWidth = + MediaQuery.of(context).size.width - 16; // Subtract padding final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent( context, settingsProvider.libraryDensity, @@ -601,20 +560,20 @@ class _LibraryBrowseTabState extends State ); } - if (_isLoading && _items.isEmpty) { + if (isLoading && items.isEmpty) { return const Center(child: CircularProgressIndicator()); } - if (_errorMessage != null && _items.isEmpty) { + if (errorMessage != null && items.isEmpty) { return ErrorStateWidget( - message: _errorMessage!, + message: errorMessage!, icon: Icons.error_outline, onRetry: _loadContent, retryLabel: t.common.retry, ); } - if (_items.isEmpty) { + if (items.isEmpty) { return EmptyStateWidget( message: t.libraries.thisLibraryIsEmpty, icon: Icons.folder_open, @@ -626,63 +585,64 @@ class _LibraryBrowseTabState extends State if (notification.metrics.pixels >= notification.metrics.maxScrollExtent - 300 && _hasMoreItems && - !_isLoading) { + !isLoading) { _loadItems(loadMore: true); } return false; }, child: Consumer( builder: (context, settingsProvider, child) { - if (settingsProvider.viewMode == ViewMode.list) { - // In list view, only the first item can navigate up to chips - return ListView.builder( - padding: const EdgeInsets.all(8), - itemCount: - _items.length + (_hasMoreItems && _isLoading ? 1 : 0), - itemBuilder: (context, index) => _buildMediaCardItem( - index, - isFirstRow: index == 0, - ), - ); - } else { - // In grid view, calculate columns and pass to item builder - final columnCount = _getGridColumnCount(context, settingsProvider); - return GridView.builder( - padding: const EdgeInsets.all(8), - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( - context, - settingsProvider.libraryDensity, - ), - childAspectRatio: 2 / 3.3, - crossAxisSpacing: 0, - mainAxisSpacing: 0, - ), - itemCount: - _items.length + (_hasMoreItems && _isLoading ? 1 : 0), - itemBuilder: (context, index) => _buildMediaCardItem( - index, - isFirstRow: _isFirstRow(index, columnCount), - ), - ); - } + return _buildItemsView(context, settingsProvider); }, ), ); } + /// Builds either a list or grid view based on the view mode + Widget _buildItemsView( + BuildContext context, + SettingsProvider settingsProvider, + ) { + final itemCount = items.length + (_hasMoreItems && isLoading ? 1 : 0); + + if (settingsProvider.viewMode == ViewMode.list) { + // In list view, only the first item can navigate up to chips + return ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: itemCount, + itemBuilder: (context, index) => + _buildMediaCardItem(index, isFirstRow: index == 0), + ); + } else { + // In grid view, calculate columns and pass to item builder + final columnCount = _getGridColumnCount(context, settingsProvider); + return GridView.builder( + padding: const EdgeInsets.all(8), + gridDelegate: MediaGridDelegate.createDelegate( + context: context, + density: settingsProvider.libraryDensity, + ), + itemCount: itemCount, + itemBuilder: (context, index) => _buildMediaCardItem( + index, + isFirstRow: _isFirstRow(index, columnCount), + ), + ); + } + } + Widget _buildMediaCardItem(int index, {required bool isFirstRow}) { - if (index >= _items.length) { + if (index >= items.length) { return const Padding( padding: EdgeInsets.all(16.0), child: Center(child: CircularProgressIndicator()), ); } - final item = _items[index]; + final item = items[index]; return FocusableMediaCard( key: Key(item.ratingKey), item: item, - focusNode: index == 0 ? _firstItemFocusNode : null, + focusNode: index == 0 ? firstItemFocusNode : null, onRefresh: updateItem, onNavigateUp: isFirstRow ? _navigateToChips : null, onBack: widget.onBack, diff --git a/lib/screens/libraries/tabs/library_collections_tab.dart b/lib/screens/libraries/tabs/library_collections_tab.dart index 5eec1b11..3aab78bf 100644 --- a/lib/screens/libraries/tabs/library_collections_tab.dart +++ b/lib/screens/libraries/tabs/library_collections_tab.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import '../../../models/plex_metadata.dart'; import '../../../utils/library_refresh_notifier.dart'; +import '../../../mixins/library_tab_focus_mixin.dart'; +import '../../../widgets/focusable_media_card.dart'; import '../../../i18n/strings.g.dart'; import '../adaptive_media_grid.dart'; import 'base_library_tab.dart'; @@ -24,23 +26,13 @@ class LibraryCollectionsTab extends BaseLibraryTab { } class _LibraryCollectionsTabState - extends BaseLibraryTabState { - // Focus node for the first item (for programmatic focus) - final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'collections_first_item'); + extends BaseLibraryTabState + with LibraryTabFocusMixin { + @override + String get focusNodeDebugLabel => 'collections_first_item'; @override - void dispose() { - _firstItemFocusNode.dispose(); - super.dispose(); - } - - /// Focus the first item in the grid/list (for tab activation) - @override - void focusFirstItem() { - if (items.isNotEmpty) { - _firstItemFocusNode.requestFocus(); - } - } + int get itemCount => items.length; @override IconData get emptyIcon => Icons.collections; @@ -66,10 +58,19 @@ class _LibraryCollectionsTabState @override Widget buildContent(List items) { - return AdaptiveMediaGrid( + return AdaptiveMediaGrid( items: items, + itemBuilder: (context, item, index) { + return FocusableMediaCard( + key: Key(item.ratingKey), + item: item, + focusNode: index == 0 ? firstItemFocusNode : null, + onListRefresh: loadItems, + onBack: widget.onBack, + ); + }, onRefresh: loadItems, - firstItemFocusNode: _firstItemFocusNode, + firstItemFocusNode: firstItemFocusNode, onBack: widget.onBack, ); } diff --git a/lib/screens/libraries/tabs/library_playlists_tab.dart b/lib/screens/libraries/tabs/library_playlists_tab.dart index 9e96b9bd..8066f9f6 100644 --- a/lib/screens/libraries/tabs/library_playlists_tab.dart +++ b/lib/screens/libraries/tabs/library_playlists_tab.dart @@ -1,12 +1,10 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; import '../../../models/plex_playlist.dart'; -import '../../../providers/settings_provider.dart'; import '../../../utils/library_refresh_notifier.dart'; -import '../../../services/settings_service.dart' show ViewMode; -import '../../../utils/grid_size_calculator.dart'; +import '../../../mixins/library_tab_focus_mixin.dart'; import '../../../widgets/focusable_media_card.dart'; import '../../../i18n/strings.g.dart'; +import '../adaptive_media_grid.dart'; import 'base_library_tab.dart'; /// Playlists tab for library screen @@ -28,23 +26,13 @@ class LibraryPlaylistsTab extends BaseLibraryTab { } class _LibraryPlaylistsTabState - extends BaseLibraryTabState { - // Focus node for the first item (for programmatic focus) - final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'playlists_first_item'); + extends BaseLibraryTabState + with LibraryTabFocusMixin { + @override + String get focusNodeDebugLabel => 'playlists_first_item'; @override - void dispose() { - _firstItemFocusNode.dispose(); - super.dispose(); - } - - /// Focus the first item in the grid/list (for tab activation) - @override - void focusFirstItem() { - if (items.isNotEmpty) { - _firstItemFocusNode.requestFocus(); - } - } + int get itemCount => items.length; @override IconData get emptyIcon => Icons.playlist_play; @@ -72,42 +60,19 @@ class _LibraryPlaylistsTabState @override Widget buildContent(List items) { - return Consumer( - builder: (context, settingsProvider, child) { - if (settingsProvider.viewMode == ViewMode.list) { - return ListView.builder( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), - itemCount: items.length, - itemBuilder: (context, index) => - _buildPlaylistItem(items[index], index), - ); - } else { - return GridView.builder( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( - context, - settingsProvider.libraryDensity, - ), - childAspectRatio: 2 / 3.3, - crossAxisSpacing: 0, - mainAxisSpacing: 0, - ), - itemCount: items.length, - itemBuilder: (context, index) => - _buildPlaylistItem(items[index], index), - ); - } + return AdaptiveMediaGrid( + items: items, + itemBuilder: (context, playlist, index) { + return FocusableMediaCard( + key: Key(playlist.ratingKey), + item: playlist, + focusNode: index == 0 ? firstItemFocusNode : null, + onListRefresh: loadItems, + onBack: widget.onBack, + ); }, - ); - } - - Widget _buildPlaylistItem(PlexPlaylist playlist, int index) { - return FocusableMediaCard( - key: Key(playlist.ratingKey), - item: playlist, - focusNode: index == 0 ? _firstItemFocusNode : null, - onListRefresh: loadItems, + onRefresh: loadItems, + firstItemFocusNode: firstItemFocusNode, onBack: widget.onBack, ); } diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 8d482260..74ec0f38 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -162,7 +162,8 @@ class _LibraryRecommendedTabState onRemoveFromContinueWatching: isContinueWatching ? _refreshContinueWatching : null, - onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp), + onVerticalNavigation: (isUp) => + _handleVerticalNavigation(index, isUp), onBack: widget.onBack, ); }, diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 1cd2e65d..f1939d12 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -66,8 +66,12 @@ class _MainScreenState extends State with RouteAware { final GlobalKey _sideNavKey = GlobalKey(); // Focus management for sidebar/content switching - final FocusScopeNode _sidebarFocusScope = FocusScopeNode(debugLabel: 'Sidebar'); - final FocusScopeNode _contentFocusScope = FocusScopeNode(debugLabel: 'Content'); + final FocusScopeNode _sidebarFocusScope = FocusScopeNode( + debugLabel: 'Sidebar', + ); + final FocusScopeNode _contentFocusScope = FocusScopeNode( + debugLabel: 'Content', + ); bool _isSidebarFocused = false; @override @@ -138,7 +142,8 @@ class _MainScreenState extends State with RouteAware { KeyEventResult _handleBackKey(KeyEvent event) { if (event is! KeyDownEvent) return KeyEventResult.ignored; - final isBackKey = event.logicalKey == LogicalKeyboardKey.escape || + final isBackKey = + event.logicalKey == LogicalKeyboardKey.escape || event.logicalKey == LogicalKeyboardKey.goBack || event.logicalKey == LogicalKeyboardKey.browserBack || event.logicalKey == LogicalKeyboardKey.gameButtonB; @@ -310,7 +315,10 @@ class _MainScreenState extends State with RouteAware { child: FocusScope( node: _contentFocusScope, autofocus: true, - child: IndexedStack(index: _currentIndex, children: _screens), + child: IndexedStack( + index: _currentIndex, + children: _screens, + ), ), ), ], diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 7f965709..c54abd48 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -1,9 +1,8 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import '../focus/dpad_navigator.dart'; +import '../focus/key_event_utils.dart'; import '../focus/input_mode_tracker.dart'; import '../i18n/strings.g.dart'; import '../widgets/plex_optimized_image.dart'; @@ -355,24 +354,19 @@ class _MediaDetailScreenState extends State { } } - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { - if (event is KeyDownEvent && event.logicalKey.isBackKey) { - Navigator.pop(context, _watchStateChanged); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - @override Widget build(BuildContext context) { // Use full metadata if loaded, otherwise use passed metadata final metadata = _fullMetadata ?? widget.metadata; final isShow = metadata.type.toLowerCase() == 'show'; + KeyEventResult handleBack(FocusNode _, KeyEvent event) => + handleBackKeyNavigation(context, event, result: _watchStateChanged); + // Show loading state while fetching full metadata if (_isLoadingMetadata) { return Focus( - onKeyEvent: _handleKeyEvent, + onKeyEvent: handleBack, child: Scaffold( appBar: AppBar(), body: const Center(child: CircularProgressIndicator()), @@ -386,146 +380,121 @@ class _MediaDetailScreenState extends State { final headerHeight = isDesktop ? size.height * 0.6 : size.height * 0.4; return Focus( - onKeyEvent: _handleKeyEvent, + onKeyEvent: handleBack, child: Scaffold( - body: CustomScrollView( - controller: _scrollController, - slivers: [ - // Hero header with background art - DesktopSliverAppBar( - expandedHeight: headerHeight, - pinned: true, - leading: AppBarBackButton( - style: BackButtonStyle.circular, - onPressed: () => Navigator.pop(context, _watchStateChanged), - ), - flexibleSpace: FlexibleSpaceBar( - background: Stack( - fit: StackFit.expand, - children: [ - // Background Art - if (metadata.art != null) - Builder( - builder: (context) { - final client = _getClientForMetadata(context); - 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 + 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 Container( - color: Theme.of( - context, - ).colorScheme.surfaceContainerHighest, - ), - - // Gradient overlay - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.7), - Colors.black.withValues(alpha: 0.95), - ], - stops: const [0.3, 0.7, 1.0], + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withValues(alpha: 0.7), + Colors.black.withValues(alpha: 0.95), + ], + stops: const [0.3, 0.7, 1.0], + ), ), ), - ), - // Content at bottom - Positioned( - bottom: 16, - left: 0, - right: 0, - child: SafeArea( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // Clear logo or title - if (metadata.clearLogo != null) - SizedBox( - height: 120, - width: 400, - child: Builder( - builder: (context) { - final client = _getClientForMetadata( - context, - ); - final dpr = MediaQuery.of( - context, - ).devicePixelRatio; - final logoUrl = - PlexImageHelper.getOptimizedImageUrl( - client: client, - thumbPath: metadata.clearLogo, - maxWidth: 400, - maxHeight: 120, - devicePixelRatio: dpr, - imageType: ImageType.logo, - ); + // 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( + return CachedNetworkImage( + imageUrl: logoUrl, + filterQuality: FilterQuality.medium, + fit: BoxFit.contain, alignment: Alignment.centerLeft, - child: Text( - metadata.title, - style: Theme.of(context) - .textTheme - .displaySmall - ?.copyWith( - color: Colors.white.withValues( - alpha: 0.3, - ), - fontWeight: FontWeight.bold, - shadows: [ - Shadow( - color: Colors.black - .withValues(alpha: 0.5), - blurRadius: 8, - ), - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - errorWidget: (context, url, error) { - return Align( + memCacheWidth: (400 * dpr) + .clamp(200, 800) + .round(), + placeholder: (context, url) => Align( alignment: Alignment.centerLeft, child: Text( metadata.title, @@ -533,7 +502,8 @@ class _MediaDetailScreenState extends State { .textTheme .displaySmall ?.copyWith( - color: Colors.white, + color: Colors.white + .withValues(alpha: 0.3), fontWeight: FontWeight.bold, shadows: [ Shadow( @@ -548,255 +518,326 @@ class _MediaDetailScreenState extends State { maxLines: 2, overflow: TextOverflow.ellipsis, ), - ); - }, - ); - }, + ), + errorWidget: (context, url, error) { + return Align( + alignment: Alignment.centerLeft, + child: Text( + metadata.title, + style: Theme.of(context) + .textTheme + .displaySmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black + .withValues( + alpha: 0.5, + ), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ); + }, + ); + }, + ), + ) + else + Text( + metadata.title, + style: Theme.of(context) + .textTheme + .displaySmall + ?.copyWith( + color: Colors.white, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Colors.black.withValues( + alpha: 0.5, + ), + blurRadius: 8, + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - ) - else - Text( - metadata.title, - style: Theme.of(context).textTheme.displaySmall - ?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - shadows: [ - Shadow( - color: Colors.black.withValues( - alpha: 0.5, - ), - blurRadius: 8, - ), - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 12), + const SizedBox(height: 12), - // Metadata chips - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - if (metadata.year != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.black.withValues( - alpha: 0.4, + // Metadata chips + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + if (metadata.year != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, ), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - '${metadata.year}', - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500, + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(6), ), - ), - ), - if (metadata.contentRating != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.black.withValues( - alpha: 0.4, - ), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - formatContentRating( - metadata.contentRating!, - ), - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ), - if (metadata.duration != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.black.withValues( - alpha: 0.4, - ), - borderRadius: BorderRadius.circular(6), - ), - child: Text( - formatDurationTextual(metadata.duration!), - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ), - if (metadata.rating != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.black.withValues( - alpha: 0.4, - ), - borderRadius: BorderRadius.circular(6), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.star, + child: Text( + '${metadata.year}', + style: const TextStyle( color: Colors.white, - size: 16, + fontSize: 13, + fontWeight: FontWeight.w500, ), - const SizedBox(width: 4), - Text( - '${(metadata.rating! * 10).toStringAsFixed(0)}%', - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - if (metadata.audienceRating != null) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.black.withValues( - alpha: 0.4, ), - borderRadius: BorderRadius.circular(6), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.people, + if (metadata.contentRating != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + formatContentRating( + metadata.contentRating!, + ), + style: const TextStyle( color: Colors.white, - size: 16, + fontSize: 13, + fontWeight: FontWeight.w500, ), - const SizedBox(width: 4), - Text( - '${(metadata.audienceRating! * 10).toStringAsFixed(0)}%', - style: const TextStyle( - color: Colors.white, - fontSize: 13, - fontWeight: FontWeight.w500, - ), - ), - ], + ), ), - ), - ], - ), - ], + if (metadata.duration != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + formatDurationTextual( + metadata.duration!, + ), + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ), + if (metadata.rating != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.star, + color: Colors.white, + size: 16, + ), + const SizedBox(width: 4), + Text( + '${(metadata.rating! * 10).toStringAsFixed(0)}%', + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + if (metadata.audienceRating != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.black.withValues( + alpha: 0.4, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.people, + color: Colors.white, + size: 16, + ), + const SizedBox(width: 4), + Text( + '${(metadata.audienceRating! * 10).toStringAsFixed(0)}%', + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + ], + ), ), ), ), - ), - ], + ], + ), ), ), - ), - // Main content - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Action buttons - Row( - children: [ - Expanded( - child: SizedBox( - height: 48, - child: FilledButton.icon( - autofocus: InputModeTracker.isKeyboardMode(context), - onPressed: () async { - // For TV shows, play the OnDeck episode if available - // Otherwise, play the first episode of the first season - if (metadata.type.toLowerCase() == 'show') { - if (_onDeckEpisode != null) { - appLogger.d( - 'Playing on deck episode: ${_onDeckEpisode!.title}', - ); + // Main content + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Action buttons + Row( + children: [ + Expanded( + child: SizedBox( + height: 48, + child: FilledButton.icon( + autofocus: InputModeTracker.isKeyboardMode( + context, + ), + onPressed: () async { + // For TV shows, play the OnDeck episode if available + // Otherwise, play the first episode of the first season + if (metadata.type.toLowerCase() == 'show') { + if (_onDeckEpisode != null) { + appLogger.d( + 'Playing on deck episode: ${_onDeckEpisode!.title}', + ); + await navigateToVideoPlayer( + context, + metadata: _onDeckEpisode!, + ); + appLogger.d( + 'Returned from playback, refreshing metadata', + ); + // Refresh metadata when returning from video player + _loadFullMetadata(); + } else { + // No on deck episode, fetch first episode of first season + await _playFirstEpisode(); + } + } else { + appLogger.d('Playing: ${metadata.title}'); + // For movies or episodes, play directly await navigateToVideoPlayer( context, - metadata: _onDeckEpisode!, + metadata: metadata, ); appLogger.d( 'Returned from playback, refreshing metadata', ); // Refresh metadata when returning from video player _loadFullMetadata(); - } else { - // No on deck episode, fetch first episode of first season - await _playFirstEpisode(); } - } else { - appLogger.d('Playing: ${metadata.title}'); - // For movies or episodes, play directly - await navigateToVideoPlayer( - context, - metadata: metadata, - ); - appLogger.d( - 'Returned from playback, refreshing metadata', - ); - // Refresh metadata when returning from video player - _loadFullMetadata(); - } - }, - icon: const Icon(Icons.play_arrow, size: 20), - label: Text( - _getPlayButtonLabel(metadata), - style: const TextStyle(fontSize: 16), - ), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 16, + }, + icon: const Icon(Icons.play_arrow, size: 20), + label: Text( + _getPlayButtonLabel(metadata), + style: const TextStyle(fontSize: 16), + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 16, + ), ), ), ), ), - ), - const SizedBox(width: 12), - // Shuffle button (only for shows and seasons) - if (metadata.type.toLowerCase() == 'show' || - metadata.type.toLowerCase() == 'season') ...[ + const SizedBox(width: 12), + // Shuffle button (only for shows and seasons) + if (metadata.type.toLowerCase() == 'show' || + metadata.type.toLowerCase() == 'season') ...[ + IconButton.filledTonal( + onPressed: () async { + await _handleShufflePlayWithQueue( + context, + metadata, + ); + }, + icon: const Icon(Icons.shuffle), + tooltip: t.tooltips.shufflePlay, + iconSize: 20, + style: IconButton.styleFrom( + minimumSize: const Size(48, 48), + maximumSize: const Size(48, 48), + ), + ), + const SizedBox(width: 12), + ], IconButton.filledTonal( onPressed: () async { - await _handleShufflePlayWithQueue( - context, - metadata, - ); + try { + final client = _getClientForMetadata(context); + + await client.markAsWatched(metadata.ratingKey); + if (context.mounted) { + _watchStateChanged = true; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(t.messages.markedAsWatched), + ), + ); + // Update watch state without full rebuild + _updateWatchState(); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + t.messages.errorLoading( + error: e.toString(), + ), + ), + ), + ); + } + } }, - icon: const Icon(Icons.shuffle), - tooltip: t.tooltips.shufflePlay, + icon: const Icon(Icons.check), + tooltip: t.tooltips.markAsWatched, iconSize: 20, style: IconButton.styleFrom( minimumSize: const Size(48, 48), @@ -804,309 +845,268 @@ class _MediaDetailScreenState extends State { ), ), const SizedBox(width: 12), - ], - IconButton.filledTonal( - onPressed: () async { - try { - final client = _getClientForMetadata(context); + IconButton.filledTonal( + onPressed: () async { + try { + final client = _getClientForMetadata(context); - await client.markAsWatched(metadata.ratingKey); - if (context.mounted) { - _watchStateChanged = true; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.messages.markedAsWatched), - ), - ); - // Update watch state without full rebuild - _updateWatchState(); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - t.messages.errorLoading( - error: e.toString(), - ), - ), - ), - ); - } - } - }, - icon: const Icon(Icons.check), - tooltip: t.tooltips.markAsWatched, - iconSize: 20, - style: IconButton.styleFrom( - minimumSize: const Size(48, 48), - maximumSize: const Size(48, 48), - ), - ), - const SizedBox(width: 12), - IconButton.filledTonal( - onPressed: () async { - try { - final client = _getClientForMetadata(context); - - await client.markAsUnwatched(metadata.ratingKey); - if (context.mounted) { - _watchStateChanged = true; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(t.messages.markedAsUnwatched), - ), - ); - // Update watch state without full rebuild - _updateWatchState(); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - t.messages.errorLoading( - error: e.toString(), - ), - ), - ), - ); - } - } - }, - icon: const Icon(Icons.remove_done), - tooltip: t.tooltips.markAsUnwatched, - iconSize: 20, - style: IconButton.styleFrom( - minimumSize: const Size(48, 48), - maximumSize: const Size(48, 48), - ), - ), - ], - ), - - const SizedBox(height: 24), - - // Summary - if (metadata.summary != null) ...[ - Text( - t.discover.overview, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 12), - Text( - metadata.summary!, - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(height: 1.6), - ), - const SizedBox(height: 24), - ], - - // Seasons (for TV shows) - if (isShow) ...[ - Text( - t.discover.seasons, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 12), - if (_isLoadingSeasons) - const Center( - child: Padding( - padding: EdgeInsets.all(32), - child: CircularProgressIndicator(), - ), - ) - else if (_seasons.isEmpty) - Padding( - padding: const EdgeInsets.all(32), - child: Center( - child: Text( - t.messages.noSeasonsFound, - style: Theme.of( - context, - ).textTheme.bodyLarge?.copyWith(color: Colors.grey), - ), - ), - ) - 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) { + 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(); } - }, - 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), - ), - ), + } 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: [ - Text( - actor.tag, - style: Theme.of(context) - .textTheme - .bodyMedium - ?.copyWith( - fontWeight: FontWeight.w600, + ); + } + } + }, + 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), ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - if (actor.role != null) ...[ - const SizedBox(height: 2), + ), + ), + const SizedBox(height: 8), + SizedBox( + height: 84, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ Text( - actor.role!, + actor.tag, style: Theme.of(context) .textTheme - .bodySmall + .bodyMedium ?.copyWith( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant, + fontWeight: FontWeight.w600, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), + if (actor.role != null) ...[ + const SizedBox(height: 2), + Text( + actor.role!, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], ], - ], + ), ), - ), - ], - ), - ); - }, + ], + ), + ); + }, + ), ), ), - ), - const SizedBox(height: 24), - ], + const SizedBox(height: 24), + ], - // Additional info - if (metadata.studio != null) ...[ - _buildInfoRow(t.discover.studio, metadata.studio!), - const SizedBox(height: 12), + // Additional info + if (metadata.studio != null) ...[ + _buildInfoRow(t.discover.studio, metadata.studio!), + const SizedBox(height: 12), + ], + if (metadata.contentRating != null) ...[ + _buildInfoRow( + t.discover.rating, + formatContentRating(metadata.contentRating!), + ), + const SizedBox(height: 12), + ], ], - if (metadata.contentRating != null) ...[ - _buildInfoRow( - t.discover.rating, - formatContentRating(metadata.contentRating!), - ), - const SizedBox(height: 12), - ], - ], + ), ), ), - ), - ], - ), + ], + ), ), ); } @@ -1204,7 +1204,7 @@ class _SeasonCard extends StatelessWidget { if (season.thumb != null) ClipRRect( borderRadius: BorderRadius.circular(6), - child: PlexPosterImage( + child: PlexOptimizedImage.poster( client: client, imagePath: season.thumb, width: 80, diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 01514955..90badcfb 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -1,22 +1,18 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:provider/provider.dart'; -import '../../focus/dpad_navigator.dart'; import '../../services/plex_client.dart'; import '../../models/plex_playlist.dart'; import '../../models/plex_metadata.dart'; -import '../../providers/settings_provider.dart'; import '../../providers/playback_state_provider.dart'; import '../../utils/app_logger.dart'; import '../../utils/provider_extensions.dart'; import '../../utils/video_player_navigation.dart'; -import '../../utils/grid_size_calculator.dart'; -import '../../widgets/media_card.dart'; +import '../../widgets/media_grid_sliver.dart'; import 'playlist_item_card.dart'; -import '../../widgets/desktop_app_bar.dart'; +import '../../widgets/focused_scroll_scaffold.dart'; import '../../i18n/strings.g.dart'; import '../../utils/dialogs.dart'; import '../base_media_list_detail_screen.dart'; +import 'package:provider/provider.dart'; /// Screen to display the contents of a playlist class PlaylistDetailScreen extends StatefulWidget { @@ -266,102 +262,61 @@ class _PlaylistDetailScreenState } } - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { - if (event is KeyDownEvent && event.logicalKey.isBackKey) { - Navigator.pop(context); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - @override Widget build(BuildContext context) { - return Focus( - autofocus: true, - onKeyEvent: _handleKeyEvent, - child: Scaffold( - body: CustomScrollView( - slivers: [ - CustomAppBar( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, + return FocusedScrollScaffold( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.playlist.title, style: const TextStyle(fontSize: 16)), + if (widget.playlist.smart) + Row( + mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.auto_awesome, size: 12, color: Colors.blue[300]), + const SizedBox(width: 4), Text( - widget.playlist.title, - style: const TextStyle(fontSize: 16), - ), - if (widget.playlist.smart) - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.auto_awesome, - size: 12, - color: Colors.blue[300], - ), - const SizedBox(width: 4), - Text( - t.playlists.smartPlaylist, - style: TextStyle( - fontSize: 11, - color: Colors.blue[300], - fontWeight: FontWeight.normal, - ), - ), - ], + t.playlists.smartPlaylist, + style: TextStyle( + fontSize: 11, + color: Colors.blue[300], + fontWeight: FontWeight.normal, ), + ), ], ), - pinned: true, - actions: buildAppBarActions( - onDelete: widget.playlist.smart ? null : _deletePlaylist, - deleteTooltip: t.playlists.delete, - showDelete: !widget.playlist.smart, - ), - ), - ...buildStateSlivers(), - if (items.isNotEmpty) - if (widget.playlist.smart) - // Smart playlists: Use grid view (cannot be reordered) - SliverPadding( - padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), - sliver: SliverGrid( - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: - GridSizeCalculator.getMaxCrossAxisExtent( - context, - context.watch().libraryDensity, - ), - childAspectRatio: 2 / 3.3, - crossAxisSpacing: 0, - mainAxisSpacing: 0, - ), - delegate: SliverChildBuilderDelegate((context, index) { - return MediaCard(item: items[index], onRefresh: updateItem); - }, childCount: items.length), - ), - ) - else - // Regular playlists: Use reorderable list view - SliverReorderableList( - itemBuilder: (context, index) { - final item = items[index]; - return PlaylistItemCard( - key: ValueKey(item.playlistItemID ?? item.ratingKey), - item: item, - index: index, - onRemove: () => _removeItem(index), - onTap: () => _playFromItem(index), - onRefresh: updateItem, - canReorder: !widget.playlist.smart, - ); - }, - itemCount: items.length, - onReorder: _onReorder, - ), ], ), + actions: buildAppBarActions( + onDelete: widget.playlist.smart ? null : _deletePlaylist, + deleteTooltip: t.playlists.delete, + showDelete: !widget.playlist.smart, ), + slivers: [ + ...buildStateSlivers(), + if (items.isNotEmpty) + if (widget.playlist.smart) + // Smart playlists: Use grid view (cannot be reordered) + MediaGridSliver(items: items, onRefresh: updateItem) + else + // Regular playlists: Use reorderable list view + SliverReorderableList( + itemBuilder: (context, index) { + final item = items[index]; + return PlaylistItemCard( + key: ValueKey(item.playlistItemID ?? item.ratingKey), + item: item, + index: index, + onRemove: () => _removeItem(index), + onTap: () => _playFromItem(index), + onRefresh: updateItem, + canReorder: !widget.playlist.smart, + ); + }, + itemCount: items.length, + onReorder: _onReorder, + ), + ], ); } } diff --git a/lib/screens/playlist/playlist_item_card.dart b/lib/screens/playlist/playlist_item_card.dart index 4b6dc680..b9d27cdf 100644 --- a/lib/screens/playlist/playlist_item_card.dart +++ b/lib/screens/playlist/playlist_item_card.dart @@ -144,7 +144,7 @@ class _PlaylistItemCardState extends State { final posterUrl = widget.item.posterThumb(); return ClipRRect( borderRadius: BorderRadius.circular(6), - child: PlexPosterImage( + child: PlexOptimizedImage.poster( client: _getClientForItem(context), imagePath: posterUrl, width: 60, diff --git a/lib/screens/profile/user_avatar_widget.dart b/lib/screens/profile/user_avatar_widget.dart index 0e707046..93f54fae 100644 --- a/lib/screens/profile/user_avatar_widget.dart +++ b/lib/screens/profile/user_avatar_widget.dart @@ -35,64 +35,106 @@ class UserAvatarWidget extends StatelessWidget { ); } - List _buildTextLabels(ThemeData theme) { + /// Helper method to build a circular badge with an icon + /// + /// [icon] - The icon to display in the badge + /// [color] - The background color of the badge + /// [iconColor] - The color of the icon + /// [position] - The position of the badge ('topRight' or 'bottomRight') + /// [sizeRatio] - The size ratio relative to the avatar size (default 0.3) + Widget _buildBadge({ + required BuildContext context, + required IconData icon, + required Color color, + required Color iconColor, + required String position, + double sizeRatio = 0.3, + }) { + final badgeSize = size * sizeRatio; + final iconSize = + size * (sizeRatio * 0.67); // Approximately 2/3 of badge size + + return Positioned( + top: position == 'topRight' ? 0 : null, + bottom: position == 'bottomRight' ? 0 : null, + right: 0, + child: Container( + width: badgeSize, + height: badgeSize, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: Border.all( + color: Theme.of(context).colorScheme.surface, + width: 1, + ), + ), + child: Icon(icon, size: iconSize, color: iconColor), + ), + ); + } + + /// Helper method to build a text label chip + /// + /// [text] - The text to display in the chip + /// [backgroundColor] - The background color of the chip + /// [textColor] - The color of the text + Widget _buildLabelChip({ + required BuildContext context, + required String text, + required Color backgroundColor, + required Color textColor, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + text, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: textColor, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + List _buildTextLabels(BuildContext context, ThemeData theme) { if (!useTextLabels || !showIndicators) return []; final labels = []; if (user.isAdminUser) { labels.add( - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: theme.colorScheme.primary, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - t.userStatus.admin, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onPrimary, - fontWeight: FontWeight.bold, - ), - ), + _buildLabelChip( + context: context, + text: t.userStatus.admin, + backgroundColor: theme.colorScheme.primary, + textColor: theme.colorScheme.onPrimary, ), ); } if (user.isRestrictedUser && !user.isAdminUser) { labels.add( - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: theme.colorScheme.warning ?? Colors.orange, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - t.userStatus.restricted, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onPrimary, - fontWeight: FontWeight.bold, - ), - ), + _buildLabelChip( + context: context, + text: t.userStatus.restricted, + backgroundColor: theme.colorScheme.warning ?? Colors.orange, + textColor: theme.colorScheme.onPrimary, ), ); } if (user.requiresPassword) { labels.add( - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - decoration: BoxDecoration( - color: theme.colorScheme.secondary, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - t.userStatus.protected, - style: theme.textTheme.labelSmall?.copyWith( - color: theme.colorScheme.onSecondary, - fontWeight: FontWeight.bold, - ), - ), + _buildLabelChip( + context: context, + text: t.userStatus.protected, + backgroundColor: theme.colorScheme.secondary, + textColor: theme.colorScheme.onSecondary, ), ); } @@ -110,7 +152,7 @@ class UserAvatarWidget extends StatelessWidget { ]; } - Widget _buildAvatar(ThemeData theme) { + Widget _buildAvatar(BuildContext context, ThemeData theme) { return SizedBox( width: size, height: size, @@ -123,9 +165,8 @@ class UserAvatarWidget extends StatelessWidget { width: size, height: size, fit: BoxFit.cover, - placeholder: (context, url) => _buildPlaceholderAvatar(theme), - errorWidget: (context, url, error) => - _buildPlaceholderAvatar(theme), + placeholder: (ctx, url) => _buildPlaceholderAvatar(theme), + errorWidget: (ctx, url, error) => _buildPlaceholderAvatar(theme), ), ), @@ -133,74 +174,33 @@ class UserAvatarWidget extends StatelessWidget { if (showIndicators && !useTextLabels) ...[ // Admin badge if (user.isAdminUser) - Positioned( - top: 0, - right: 0, - child: Container( - width: size * 0.3, - height: size * 0.3, - decoration: BoxDecoration( - color: theme.colorScheme.primary, - shape: BoxShape.circle, - border: Border.all( - color: theme.colorScheme.surface, - width: 1, - ), - ), - child: Icon( - Icons.admin_panel_settings, - size: size * 0.2, - color: theme.colorScheme.onPrimary, - ), - ), + _buildBadge( + context: context, + icon: Icons.admin_panel_settings, + color: theme.colorScheme.primary, + iconColor: theme.colorScheme.onPrimary, + position: 'topRight', ), // Restricted badge if (user.isRestrictedUser && !user.isAdminUser) - Positioned( - top: 0, - right: 0, - child: Container( - width: size * 0.3, - height: size * 0.3, - decoration: BoxDecoration( - color: theme.colorScheme.warning ?? Colors.orange, - shape: BoxShape.circle, - border: Border.all( - color: theme.colorScheme.surface, - width: 1, - ), - ), - child: Icon( - Icons.security, - size: size * 0.2, - color: theme.colorScheme.onPrimary, - ), - ), + _buildBadge( + context: context, + icon: Icons.security, + color: theme.colorScheme.warning ?? Colors.orange, + iconColor: theme.colorScheme.onPrimary, + position: 'topRight', ), // Password indicator if (user.requiresPassword) - Positioned( - bottom: 0, - right: 0, - child: Container( - width: size * 0.25, - height: size * 0.25, - decoration: BoxDecoration( - color: theme.colorScheme.secondary, - shape: BoxShape.circle, - border: Border.all( - color: theme.colorScheme.surface, - width: 1, - ), - ), - child: Icon( - Icons.lock, - size: size * 0.15, - color: theme.colorScheme.onSecondary, - ), - ), + _buildBadge( + context: context, + icon: Icons.lock, + color: theme.colorScheme.secondary, + iconColor: theme.colorScheme.onSecondary, + position: 'bottomRight', + sizeRatio: 0.25, ), ], ], @@ -218,12 +218,15 @@ class UserAvatarWidget extends StatelessWidget { onTap: onTap, child: Column( mainAxisSize: MainAxisSize.min, - children: [_buildAvatar(theme), ..._buildTextLabels(theme)], + children: [ + _buildAvatar(context, theme), + ..._buildTextLabels(context, theme), + ], ), ); } else { // Return just the avatar (original behavior) - return GestureDetector(onTap: onTap, child: _buildAvatar(theme)); + return GestureDetector(onTap: onTap, child: _buildAvatar(context, theme)); } } } diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 9b98e34d..00453184 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -7,9 +7,8 @@ import '../mixins/refreshable.dart'; import '../models/plex_metadata.dart'; import '../providers/multi_server_provider.dart'; import '../providers/settings_provider.dart'; -import '../services/settings_service.dart'; import '../utils/app_logger.dart'; -import '../utils/grid_cross_axis_extent.dart'; +import '../utils/sliver_adaptive_media_builder.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/media_card.dart'; @@ -255,45 +254,23 @@ class _SearchScreenState extends State with Refreshable { 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), - ), - ); - } + return buildAdaptiveMediaSliverBuilder( + context: context, + items: _searchResults, + itemBuilder: (context, item, index) { + return MediaCard( + key: Key(item.ratingKey), + item: item, + onRefresh: updateItem, + ); + }, + viewMode: settingsProvider.viewMode, + density: settingsProvider.libraryDensity, + padding: const EdgeInsets.all(16), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 8, + mainAxisSpacing: 8, + ); }, ), ], diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index e02b2f21..b82c00b6 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import '../../services/plex_client.dart'; -import '../focus/dpad_navigator.dart'; +import '../focus/key_event_utils.dart'; import '../focus/input_mode_tracker.dart'; import '../widgets/plex_optimized_image.dart'; import '../models/plex_metadata.dart'; @@ -83,71 +82,65 @@ class _SeasonDetailScreenState extends State } } - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { - if (event is KeyDownEvent && event.logicalKey.isBackKey) { - Navigator.pop(context, _watchStateChanged); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - @override Widget build(BuildContext context) { return Focus( - onKeyEvent: _handleKeyEvent, + onKeyEvent: (_, event) => + handleBackKeyNavigation(context, event, result: _watchStateChanged), child: Scaffold( - body: CustomScrollView( - slivers: [ - CustomAppBar( - title: Text(widget.season.title), - pinned: true, - onBackPressed: () => Navigator.pop(context, _watchStateChanged), - ), - if (_isLoadingEpisodes) - const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), - ) - else if (_episodes.isEmpty) - SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.movie_outlined, - size: 64, - color: tokens(context).textMuted, - ), - const SizedBox(height: 16), - Text( - t.messages.noEpisodesFoundGeneral, - style: Theme.of(context).textTheme.titleLarge?.copyWith( + body: CustomScrollView( + slivers: [ + CustomAppBar( + title: Text(widget.season.title), + pinned: true, + onBackPressed: () => Navigator.pop(context, _watchStateChanged), + ), + if (_isLoadingEpisodes) + const SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ) + else if (_episodes.isEmpty) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.movie_outlined, + size: 64, color: tokens(context).textMuted, ), - ), - ], + const SizedBox(height: 16), + Text( + t.messages.noEpisodesFoundGeneral, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: tokens(context).textMuted, + ), + ), + ], + ), ), + ) + else + SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final episode = _episodes[index]; + return _EpisodeCard( + episode: episode, + client: _client, + autofocus: + index == 0 && InputModeTracker.isKeyboardMode(context), + 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, - autofocus: index == 0 && InputModeTracker.isKeyboardMode(context), - onTap: () async { - await navigateToVideoPlayer(context, metadata: episode); - // Refresh episodes when returning from video player - _loadEpisodes(); - }, - onRefresh: updateItem, - ); - }, childCount: _episodes.length), - ), - ], - ), + ], + ), ), ); } @@ -169,6 +162,40 @@ class _EpisodeCard extends StatelessWidget { this.autofocus = false, }); + Widget _buildEpisodeMetaRow(BuildContext context) { + return 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, + ), + ), + ], + ], + ); + } + @override Widget build(BuildContext context) { final hasProgress = @@ -210,7 +237,7 @@ class _EpisodeCard extends StatelessWidget { child: AspectRatio( aspectRatio: 16 / 9, child: episode.thumb != null - ? PlexThumbImage( + ? PlexOptimizedImage.thumb( client: client, imagePath: episode.thumb, filterQuality: FilterQuality.medium, @@ -352,42 +379,7 @@ class _EpisodeCard extends StatelessWidget { // 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, - ), - ), - ], - ], - ), + _buildEpisodeMetaRow(context), ], ), ), diff --git a/lib/screens/settings/about_screen.dart b/lib/screens/settings/about_screen.dart index 20c98d01..e1a3b31b 100644 --- a/lib/screens/settings/about_screen.dart +++ b/lib/screens/settings/about_screen.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import '../../focus/dpad_navigator.dart'; -import '../../widgets/desktop_app_bar.dart'; +import '../../widgets/focused_scroll_scaffold.dart'; import '../../i18n/strings.g.dart'; import 'licenses_screen.dart'; @@ -31,86 +29,72 @@ class _AboutScreenState extends State { }); } - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { - if (event is KeyDownEvent && event.logicalKey.isBackKey) { - Navigator.pop(context); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - @override Widget build(BuildContext context) { final appName = _appName; final appVersion = _appVersion; - return Focus( - autofocus: true, - onKeyEvent: _handleKeyEvent, - child: Scaffold( - body: CustomScrollView( - slivers: [ - CustomAppBar(title: Text(t.about.title), pinned: true), - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildListDelegate([ - // App Icon and Name - Center( - child: Column( - children: [ - const SizedBox(height: 24), - Image.asset('assets/plezy.png', width: 80, height: 80), - const SizedBox(height: 16), - Text( - appName, - style: Theme.of(context).textTheme.headlineMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Text( - t.about.versionLabel(version: appVersion), - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(color: Colors.grey), - ), - const SizedBox(height: 24), - Text( - t.about.appDescription, - style: Theme.of(context).textTheme.bodyLarge, - textAlign: TextAlign.center, - ), - ], - ), - ), - - const SizedBox(height: 40), - - // Open Source Licenses - Card( - child: ListTile( - leading: const Icon(Icons.description), - title: Text(t.about.openSourceLicenses), - subtitle: Text(t.about.viewLicensesDescription), - trailing: const Icon(Icons.chevron_right), - onTap: () { - Navigator.push( + return FocusedScrollScaffold( + title: Text(t.about.title), + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildListDelegate([ + // App Icon and Name + Center( + child: Column( + children: [ + const SizedBox(height: 24), + Image.asset('assets/plezy.png', width: 80, height: 80), + const SizedBox(height: 16), + Text( + appName, + style: Theme.of(context).textTheme.headlineMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + t.about.versionLabel(version: appVersion), + style: Theme.of( context, - MaterialPageRoute( - builder: (context) => const LicensesScreen(), - ), - ); - }, - ), + ).textTheme.bodyMedium?.copyWith(color: Colors.grey), + ), + const SizedBox(height: 24), + Text( + t.about.appDescription, + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + ], ), + ), - const SizedBox(height: 24), - ]), - ), + const SizedBox(height: 40), + + // Open Source Licenses + Card( + child: ListTile( + leading: const Icon(Icons.description), + title: Text(t.about.openSourceLicenses), + subtitle: Text(t.about.viewLicensesDescription), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const LicensesScreen(), + ), + ); + }, + ), + ), + + const SizedBox(height: 24), + ]), ), - ], - ), - ), + ), + ], ); } } diff --git a/lib/screens/settings/licenses_screen.dart b/lib/screens/settings/licenses_screen.dart index 8bdba7e0..ff08e1b8 100644 --- a/lib/screens/settings/licenses_screen.dart +++ b/lib/screens/settings/licenses_screen.dart @@ -1,8 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import '../../focus/dpad_navigator.dart'; -import '../../widgets/desktop_app_bar.dart'; +import '../../widgets/focused_scroll_scaffold.dart'; import '../../i18n/strings.g.dart'; class MergedLicenseEntry { @@ -67,64 +65,53 @@ class _LicensesScreenState extends State { } } - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { - if (event is KeyDownEvent && event.logicalKey.isBackKey) { - Navigator.pop(context); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - @override Widget build(BuildContext context) { if (_isLoading) { - return Focus( - autofocus: true, - onKeyEvent: _handleKeyEvent, - child: const Scaffold(body: Center(child: CircularProgressIndicator())), + return FocusedScrollScaffold( + title: Text(t.screens.licenses), + slivers: const [ + SliverFillRemaining( + child: Center(child: CircularProgressIndicator()), + ), + ], ); } - return Focus( - autofocus: true, - onKeyEvent: _handleKeyEvent, - child: Scaffold( - body: CustomScrollView( - slivers: [ - CustomAppBar(title: Text(t.screens.licenses), pinned: true), - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildBuilderDelegate((context, index) { - final mergedLicense = _mergedLicenses[index]; - final packageName = mergedLicense.packageName; + return FocusedScrollScaffold( + title: Text(t.screens.licenses), + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final mergedLicense = _mergedLicenses[index]; + final packageName = mergedLicense.packageName; - return Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - title: Text( - packageName, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - ), + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text( + packageName, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, ), - subtitle: mergedLicense.licenseEntries.length > 1 - ? Text( - t.licenses.licensesCount( - count: mergedLicense.licenseEntries.length, - ), - ) - : null, - trailing: const Icon(Icons.chevron_right), - onTap: () => _showLicenseDetail(mergedLicense), ), - ); - }, childCount: _mergedLicenses.length), - ), + subtitle: mergedLicense.licenseEntries.length > 1 + ? Text( + t.licenses.licensesCount( + count: mergedLicense.licenseEntries.length, + ), + ) + : null, + trailing: const Icon(Icons.chevron_right), + onTap: () => _showLicenseDetail(mergedLicense), + ), + ); + }, childCount: _mergedLicenses.length), ), - ], - ), - ), + ), + ], ); } @@ -144,108 +131,92 @@ class _LicenseDetailScreen extends StatelessWidget { const _LicenseDetailScreen({required this.mergedLicense}); - KeyEventResult _handleKeyEvent(BuildContext context, FocusNode node, KeyEvent event) { - if (event is KeyDownEvent && event.logicalKey.isBackKey) { - Navigator.pop(context); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - } - @override Widget build(BuildContext context) { final packageName = mergedLicense.packageName; final licenseEntries = mergedLicense.licenseEntries; - return Focus( - autofocus: true, - onKeyEvent: (node, event) => _handleKeyEvent(context, node, event), - child: Scaffold( - body: CustomScrollView( - slivers: [ - CustomAppBar(title: Text(packageName), pinned: true), - SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildListDelegate([ - // Package info card - if (mergedLicense.allPackageNames.length > 1) - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - t.licenses.relatedPackages, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Text( - mergedLicense.allPackageNames.join(', '), - style: Theme.of(context).textTheme.bodyMedium, - ), - ], - ), + return FocusedScrollScaffold( + title: Text(packageName), + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildListDelegate([ + // Package info card + if (mergedLicense.allPackageNames.length > 1) + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.licenses.relatedPackages, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text( + mergedLicense.allPackageNames.join(', '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], ), ), - if (mergedLicense.allPackageNames.length > 1) - const SizedBox(height: 16), + ), + if (mergedLicense.allPackageNames.length > 1) + const SizedBox(height: 16), - // License cards - ...licenseEntries.asMap().entries.map((entry) { - final index = entry.key; - final license = entry.value; - final isMultipleLicenses = licenseEntries.length > 1; + // License cards + ...licenseEntries.asMap().entries.map((entry) { + final index = entry.key; + final license = entry.value; + final isMultipleLicenses = licenseEntries.length > 1; - return Column( - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - isMultipleLicenses - ? t.licenses.licenseNumber( - number: index + 1, - ) - : t.licenses.license, - style: Theme.of(context).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 16), - ...license.paragraphs.map((paragraph) { - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: SelectableText( - paragraph.text, - style: TextStyle( - fontFamily: paragraph.indent > 0 - ? 'monospace' - : null, - fontSize: 14, - ), + return Column( + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + isMultipleLicenses + ? t.licenses.licenseNumber(number: index + 1) + : t.licenses.license, + style: Theme.of(context).textTheme.titleMedium + ?.copyWith(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + ...license.paragraphs.map((paragraph) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: SelectableText( + paragraph.text, + style: TextStyle( + fontFamily: paragraph.indent > 0 + ? 'monospace' + : null, + fontSize: 14, ), - ); - }), - ], - ), + ), + ); + }), + ], ), ), - if (index < licenseEntries.length - 1) - const SizedBox(height: 16), - ], - ); - }), - ]), - ), + ), + if (index < licenseEntries.length - 1) + const SizedBox(height: 16), + ], + ); + }), + ]), ), - ], - ), - ), + ), + ], ); } } diff --git a/lib/services/base_shared_preferences_service.dart b/lib/services/base_shared_preferences_service.dart new file mode 100644 index 00000000..99106b21 --- /dev/null +++ b/lib/services/base_shared_preferences_service.dart @@ -0,0 +1,45 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Base class for services that use SharedPreferences singleton pattern. +/// +/// This class handles the boilerplate for singleton initialization and +/// SharedPreferences lifecycle management. Subclasses should: +/// 1. Create a private named constructor (e.g., SettingsService._()) +/// 2. Implement their own getInstance() method that calls BaseSharedPreferencesService.initializeInstance() +/// 3. Optionally override onInit() for post-initialization setup +abstract class BaseSharedPreferencesService { + static final Map _instances = {}; + late SharedPreferences _prefs; + + /// Protected constructor for subclasses + BaseSharedPreferencesService(); + + /// Access to SharedPreferences instance + SharedPreferences get prefs => _prefs; + + /// Initialize the SharedPreferences instance + /// + /// This method handles: + /// - Singleton instance management + /// - SharedPreferences initialization + /// - Calling onInit() hook for subclass-specific setup + static Future initializeInstance( + T Function() constructor, + ) async { + if (_instances[T] == null) { + final instance = constructor(); + _instances[T] = instance; + instance._prefs = await SharedPreferences.getInstance(); + await instance.onInit(); + } + return _instances[T] as T; + } + + /// Hook for subclass-specific initialization after SharedPreferences is ready. + /// + /// Override this method to perform any setup that requires access to + /// SharedPreferences (e.g., registering values with other services). + Future onInit() async { + // Default implementation does nothing + } +} diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index e4268915..54d7ab14 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -1,9 +1,9 @@ import 'dart:convert'; -import 'package:shared_preferences/shared_preferences.dart'; import 'package:flutter/services.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:plezy/utils/app_logger.dart'; import '../i18n/strings.g.dart'; +import 'base_shared_preferences_service.dart'; enum ThemeMode { system, light, dark } @@ -11,7 +11,7 @@ enum LibraryDensity { compact, normal, comfortable } enum ViewMode { grid, list } -class SettingsService { +class SettingsService extends BaseSharedPreferencesService { static const String _keyThemeMode = 'theme_mode'; static const String _keyEnableDebugLogging = 'enable_debug_logging'; static const String _keyBufferSize = 'buffer_size'; @@ -46,30 +46,21 @@ class SettingsService { static const String _keyAutoSkipCredits = 'auto_skip_credits'; static const String _keyAutoSkipDelay = 'auto_skip_delay'; - static SettingsService? _instance; - late SharedPreferences _prefs; - SettingsService._(); static Future getInstance() async { - if (_instance == null) { - _instance = SettingsService._(); - await _instance!._init(); - } - return _instance!; - } - - Future _init() async { - _prefs = await SharedPreferences.getInstance(); + return BaseSharedPreferencesService.initializeInstance( + () => SettingsService._(), + ); } // Theme Mode Future setThemeMode(ThemeMode mode) async { - await _prefs.setString(_keyThemeMode, mode.name); + await prefs.setString(_keyThemeMode, mode.name); } ThemeMode getThemeMode() { - final modeString = _prefs.getString(_keyThemeMode); + final modeString = prefs.getString(_keyThemeMode); return ThemeMode.values.firstWhere( (mode) => mode.name == modeString, orElse: () => ThemeMode.system, @@ -78,68 +69,67 @@ class SettingsService { // Debug Logging Future setEnableDebugLogging(bool enabled) async { - await _prefs.setBool(_keyEnableDebugLogging, enabled); + await prefs.setBool(_keyEnableDebugLogging, enabled); // Update logger level immediately when setting changes setLoggerLevel(enabled); } bool getEnableDebugLogging() { - return _prefs.getBool(_keyEnableDebugLogging) ?? false; + return prefs.getBool(_keyEnableDebugLogging) ?? false; } // Buffer Size (in MB) Future setBufferSize(int sizeInMB) async { - await _prefs.setInt(_keyBufferSize, sizeInMB); + await prefs.setInt(_keyBufferSize, sizeInMB); } int getBufferSize() { - return _prefs.getInt(_keyBufferSize) ?? 128; // Default 128MB + return prefs.getInt(_keyBufferSize) ?? 128; // Default 128MB } // Hardware Decoding Future setEnableHardwareDecoding(bool enabled) async { - await _prefs.setBool(_keyEnableHardwareDecoding, enabled); + await prefs.setBool(_keyEnableHardwareDecoding, enabled); } bool getEnableHardwareDecoding() { - return _prefs.getBool(_keyEnableHardwareDecoding) ?? - true; // Default enabled + return prefs.getBool(_keyEnableHardwareDecoding) ?? true; // Default enabled } // HDR (High Dynamic Range) Future setEnableHDR(bool enabled) async { - await _prefs.setBool(_keyEnableHDR, enabled); + await prefs.setBool(_keyEnableHDR, enabled); } bool getEnableHDR() { - return _prefs.getBool(_keyEnableHDR) ?? true; // Default enabled + return prefs.getBool(_keyEnableHDR) ?? true; // Default enabled } // Preferred Video Codec Future setPreferredVideoCodec(String codec) async { - await _prefs.setString(_keyPreferredVideoCodec, codec); + await prefs.setString(_keyPreferredVideoCodec, codec); } String getPreferredVideoCodec() { - return _prefs.getString(_keyPreferredVideoCodec) ?? 'auto'; + return prefs.getString(_keyPreferredVideoCodec) ?? 'auto'; } // Preferred Audio Codec Future setPreferredAudioCodec(String codec) async { - await _prefs.setString(_keyPreferredAudioCodec, codec); + await prefs.setString(_keyPreferredAudioCodec, codec); } String getPreferredAudioCodec() { - return _prefs.getString(_keyPreferredAudioCodec) ?? 'auto'; + return prefs.getString(_keyPreferredAudioCodec) ?? 'auto'; } // Library Density Future setLibraryDensity(LibraryDensity density) async { - await _prefs.setString(_keyLibraryDensity, density.name); + await prefs.setString(_keyLibraryDensity, density.name); } LibraryDensity getLibraryDensity() { - final densityString = _prefs.getString(_keyLibraryDensity); + final densityString = prefs.getString(_keyLibraryDensity); return LibraryDensity.values.firstWhere( (density) => density.name == densityString, orElse: () => LibraryDensity.normal, @@ -148,11 +138,11 @@ class SettingsService { // View Mode Future setViewMode(ViewMode mode) async { - await _prefs.setString(_keyViewMode, mode.name); + await prefs.setString(_keyViewMode, mode.name); } ViewMode getViewMode() { - final modeString = _prefs.getString(_keyViewMode); + final modeString = prefs.getString(_keyViewMode); return ViewMode.values.firstWhere( (mode) => mode.name == modeString, orElse: () => ViewMode.grid, @@ -161,86 +151,86 @@ class SettingsService { // Use Season Poster Future setUseSeasonPoster(bool enabled) async { - await _prefs.setBool(_keyUseSeasonPoster, enabled); + await prefs.setBool(_keyUseSeasonPoster, enabled); } bool getUseSeasonPoster() { - return _prefs.getBool(_keyUseSeasonPoster) ?? + return prefs.getBool(_keyUseSeasonPoster) ?? false; // Default: false (use series poster) } // Show Hero Section Future setShowHeroSection(bool enabled) async { - await _prefs.setBool(_keyShowHeroSection, enabled); + await prefs.setBool(_keyShowHeroSection, enabled); } bool getShowHeroSection() { - return _prefs.getBool(_keyShowHeroSection) ?? + return prefs.getBool(_keyShowHeroSection) ?? true; // Default: true (show hero section) } // Seek Time Small (in seconds) Future setSeekTimeSmall(int seconds) async { - await _prefs.setInt(_keySeekTimeSmall, seconds); + await prefs.setInt(_keySeekTimeSmall, seconds); } int getSeekTimeSmall() { - return _prefs.getInt(_keySeekTimeSmall) ?? 10; // Default: 10 seconds + return prefs.getInt(_keySeekTimeSmall) ?? 10; // Default: 10 seconds } // Seek Time Large (in seconds) Future setSeekTimeLarge(int seconds) async { - await _prefs.setInt(_keySeekTimeLarge, seconds); + await prefs.setInt(_keySeekTimeLarge, seconds); } int getSeekTimeLarge() { - return _prefs.getInt(_keySeekTimeLarge) ?? 30; // Default: 30 seconds + return prefs.getInt(_keySeekTimeLarge) ?? 30; // Default: 30 seconds } // Sleep Timer Duration (in minutes) Future setSleepTimerDuration(int minutes) async { - await _prefs.setInt(_keySleepTimerDuration, minutes); + await prefs.setInt(_keySleepTimerDuration, minutes); } int getSleepTimerDuration() { - return _prefs.getInt(_keySleepTimerDuration) ?? 30; // Default: 30 minutes + return prefs.getInt(_keySleepTimerDuration) ?? 30; // Default: 30 minutes } // Audio Sync Offset (in milliseconds) Future setAudioSyncOffset(int milliseconds) async { - await _prefs.setInt(_keyAudioSyncOffset, milliseconds); + await prefs.setInt(_keyAudioSyncOffset, milliseconds); } int getAudioSyncOffset() { - return _prefs.getInt(_keyAudioSyncOffset) ?? 0; // Default: 0ms (no offset) + return prefs.getInt(_keyAudioSyncOffset) ?? 0; // Default: 0ms (no offset) } // Subtitle Sync Offset (in milliseconds) Future setSubtitleSyncOffset(int milliseconds) async { - await _prefs.setInt(_keySubtitleSyncOffset, milliseconds); + await prefs.setInt(_keySubtitleSyncOffset, milliseconds); } int getSubtitleSyncOffset() { - return _prefs.getInt(_keySubtitleSyncOffset) ?? + return prefs.getInt(_keySubtitleSyncOffset) ?? 0; // Default: 0ms (no offset) } // Volume (0.0 to 100.0) Future setVolume(double volume) async { - await _prefs.setDouble(_keyVolume, volume); + await prefs.setDouble(_keyVolume, volume); } double getVolume() { - return _prefs.getDouble(_keyVolume) ?? 100.0; // Default: full volume + return prefs.getDouble(_keyVolume) ?? 100.0; // Default: full volume } // Rotation Lock (mobile only) Future setRotationLocked(bool locked) async { - await _prefs.setBool(_keyRotationLocked, locked); + await prefs.setBool(_keyRotationLocked, locked); } bool getRotationLocked() { - return _prefs.getBool(_keyRotationLocked) ?? + return prefs.getBool(_keyRotationLocked) ?? true; // Default: locked (landscape only) } @@ -248,56 +238,56 @@ class SettingsService { // Font Size (30-80, default 55) Future setSubtitleFontSize(int size) async { - await _prefs.setInt(_keySubtitleFontSize, size); + await prefs.setInt(_keySubtitleFontSize, size); } int getSubtitleFontSize() { - return _prefs.getInt(_keySubtitleFontSize) ?? 55; + return prefs.getInt(_keySubtitleFontSize) ?? 55; } // Text Color (hex format #RRGGBB, default white) Future setSubtitleTextColor(String color) async { - await _prefs.setString(_keySubtitleTextColor, color); + await prefs.setString(_keySubtitleTextColor, color); } String getSubtitleTextColor() { - return _prefs.getString(_keySubtitleTextColor) ?? '#FFFFFF'; + return prefs.getString(_keySubtitleTextColor) ?? '#FFFFFF'; } // Border Size (0-5, default 3) Future setSubtitleBorderSize(int size) async { - await _prefs.setInt(_keySubtitleBorderSize, size); + await prefs.setInt(_keySubtitleBorderSize, size); } int getSubtitleBorderSize() { - return _prefs.getInt(_keySubtitleBorderSize) ?? 3; + return prefs.getInt(_keySubtitleBorderSize) ?? 3; } // Border Color (hex format #RRGGBB, default black) Future setSubtitleBorderColor(String color) async { - await _prefs.setString(_keySubtitleBorderColor, color); + await prefs.setString(_keySubtitleBorderColor, color); } String getSubtitleBorderColor() { - return _prefs.getString(_keySubtitleBorderColor) ?? '#000000'; + return prefs.getString(_keySubtitleBorderColor) ?? '#000000'; } // Background Color (hex format #RRGGBB, default black) Future setSubtitleBackgroundColor(String color) async { - await _prefs.setString(_keySubtitleBackgroundColor, color); + await prefs.setString(_keySubtitleBackgroundColor, color); } String getSubtitleBackgroundColor() { - return _prefs.getString(_keySubtitleBackgroundColor) ?? '#000000'; + return prefs.getString(_keySubtitleBackgroundColor) ?? '#000000'; } // Background Opacity (0-100, default 0 for transparent) Future setSubtitleBackgroundOpacity(int opacity) async { - await _prefs.setInt(_keySubtitleBackgroundOpacity, opacity); + await prefs.setInt(_keySubtitleBackgroundOpacity, opacity); } int getSubtitleBackgroundOpacity() { - return _prefs.getInt(_keySubtitleBackgroundOpacity) ?? 0; + return prefs.getInt(_keySubtitleBackgroundOpacity) ?? 0; } // Keyboard Shortcuts (Legacy String-based) @@ -357,11 +347,11 @@ class SettingsService { Future setKeyboardShortcuts(Map shortcuts) async { final jsonString = json.encode(shortcuts); - await _prefs.setString(_keyKeyboardShortcuts, jsonString); + await prefs.setString(_keyKeyboardShortcuts, jsonString); } Map getKeyboardShortcuts() { - final jsonString = _prefs.getString(_keyKeyboardShortcuts); + final jsonString = prefs.getString(_keyKeyboardShortcuts); if (jsonString == null) return getDefaultKeyboardShortcuts(); try { @@ -401,11 +391,11 @@ class SettingsService { serializedHotkeys[entry.key] = _serializeHotKey(entry.value); } final jsonString = json.encode(serializedHotkeys); - await _prefs.setString(_keyKeyboardHotkeys, jsonString); + await prefs.setString(_keyKeyboardHotkeys, jsonString); } Future> getKeyboardHotkeys() async { - final jsonString = _prefs.getString(_keyKeyboardHotkeys); + final jsonString = prefs.getString(_keyKeyboardHotkeys); if (jsonString == null) { return getDefaultKeyboardHotkeys(); } @@ -763,7 +753,7 @@ class SettingsService { preferences[seriesRatingKey] = mediaIndex; final jsonString = json.encode(preferences); - await _prefs.setString(_keyMediaVersionPreferences, jsonString); + await prefs.setString(_keyMediaVersionPreferences, jsonString); } /// Get saved media version preference for a series @@ -779,12 +769,12 @@ class SettingsService { preferences.remove(seriesRatingKey); final jsonString = json.encode(preferences); - await _prefs.setString(_keyMediaVersionPreferences, jsonString); + await prefs.setString(_keyMediaVersionPreferences, jsonString); } /// Get all media version preferences Map _getMediaVersionPreferences() { - final jsonString = _prefs.getString(_keyMediaVersionPreferences); + final jsonString = prefs.getString(_keyMediaVersionPreferences); if (jsonString == null) return {}; final decoded = _decodeJsonStringToMap(jsonString); @@ -802,11 +792,11 @@ class SettingsService { // App Locale Future setAppLocale(AppLocale locale) async { - await _prefs.setString(_keyAppLocale, locale.languageCode); + await prefs.setString(_keyAppLocale, locale.languageCode); } AppLocale getAppLocale() { - final localeString = _prefs.getString(_keyAppLocale); + final localeString = prefs.getString(_keyAppLocale); if (localeString == null) return AppLocale.en; // Default to English return AppLocale.values.firstWhere( @@ -819,71 +809,71 @@ class SettingsService { /// Remember Track Selections - Save per-media audio/subtitle language preferences Future setRememberTrackSelections(bool value) async { - await _prefs.setBool(_keyRememberTrackSelections, value); + await prefs.setBool(_keyRememberTrackSelections, value); } bool getRememberTrackSelections() { - return _prefs.getBool(_keyRememberTrackSelections) ?? true; + return prefs.getBool(_keyRememberTrackSelections) ?? true; } // Auto Skip Intro Future setAutoSkipIntro(bool value) async { - await _prefs.setBool(_keyAutoSkipIntro, value); + await prefs.setBool(_keyAutoSkipIntro, value); } bool getAutoSkipIntro() { - return _prefs.getBool(_keyAutoSkipIntro) ?? true; // Default: enabled + return prefs.getBool(_keyAutoSkipIntro) ?? true; // Default: enabled } // Auto Skip Credits Future setAutoSkipCredits(bool value) async { - await _prefs.setBool(_keyAutoSkipCredits, value); + await prefs.setBool(_keyAutoSkipCredits, value); } bool getAutoSkipCredits() { - return _prefs.getBool(_keyAutoSkipCredits) ?? true; // Default: enabled + return prefs.getBool(_keyAutoSkipCredits) ?? true; // Default: enabled } // Auto Skip Delay (in seconds) Future setAutoSkipDelay(int seconds) async { - await _prefs.setInt(_keyAutoSkipDelay, seconds); + await prefs.setInt(_keyAutoSkipDelay, seconds); } int getAutoSkipDelay() { - return _prefs.getInt(_keyAutoSkipDelay) ?? 5; // Default: 5 seconds + return prefs.getInt(_keyAutoSkipDelay) ?? 5; // Default: 5 seconds } // Reset all settings to defaults Future resetAllSettings() async { await Future.wait([ - _prefs.remove(_keyThemeMode), - _prefs.remove(_keyEnableDebugLogging), - _prefs.remove(_keyBufferSize), - _prefs.remove(_keyKeyboardShortcuts), - _prefs.remove(_keyKeyboardHotkeys), - _prefs.remove(_keyEnableHardwareDecoding), - _prefs.remove(_keyEnableHDR), - _prefs.remove(_keyPreferredVideoCodec), - _prefs.remove(_keyPreferredAudioCodec), - _prefs.remove(_keyLibraryDensity), - _prefs.remove(_keyViewMode), - _prefs.remove(_keyUseSeasonPoster), - _prefs.remove(_keyShowHeroSection), - _prefs.remove(_keySeekTimeSmall), - _prefs.remove(_keySeekTimeLarge), - _prefs.remove(_keyMediaVersionPreferences), - _prefs.remove(_keySleepTimerDuration), - _prefs.remove(_keyAudioSyncOffset), - _prefs.remove(_keySubtitleSyncOffset), - _prefs.remove(_keyVolume), - _prefs.remove(_keySubtitleFontSize), - _prefs.remove(_keySubtitleTextColor), - _prefs.remove(_keySubtitleBorderSize), - _prefs.remove(_keySubtitleBorderColor), - _prefs.remove(_keySubtitleBackgroundColor), - _prefs.remove(_keySubtitleBackgroundOpacity), - _prefs.remove(_keyAppLocale), - _prefs.remove(_keyRememberTrackSelections), + prefs.remove(_keyThemeMode), + prefs.remove(_keyEnableDebugLogging), + prefs.remove(_keyBufferSize), + prefs.remove(_keyKeyboardShortcuts), + prefs.remove(_keyKeyboardHotkeys), + prefs.remove(_keyEnableHardwareDecoding), + prefs.remove(_keyEnableHDR), + prefs.remove(_keyPreferredVideoCodec), + prefs.remove(_keyPreferredAudioCodec), + prefs.remove(_keyLibraryDensity), + prefs.remove(_keyViewMode), + prefs.remove(_keyUseSeasonPoster), + prefs.remove(_keyShowHeroSection), + prefs.remove(_keySeekTimeSmall), + prefs.remove(_keySeekTimeLarge), + prefs.remove(_keyMediaVersionPreferences), + prefs.remove(_keySleepTimerDuration), + prefs.remove(_keyAudioSyncOffset), + prefs.remove(_keySubtitleSyncOffset), + prefs.remove(_keyVolume), + prefs.remove(_keySubtitleFontSize), + prefs.remove(_keySubtitleTextColor), + prefs.remove(_keySubtitleBorderSize), + prefs.remove(_keySubtitleBorderColor), + prefs.remove(_keySubtitleBackgroundColor), + prefs.remove(_keySubtitleBackgroundOpacity), + prefs.remove(_keyAppLocale), + prefs.remove(_keyRememberTrackSelections), ]); } diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index 8dd8191a..ce446f13 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -1,10 +1,9 @@ import 'dart:convert'; -import 'package:shared_preferences/shared_preferences.dart'; - import '../utils/log_redaction_manager.dart'; +import 'base_shared_preferences_service.dart'; -class StorageService { +class StorageService extends BaseSharedPreferencesService { static const String _keyServerUrl = 'server_url'; static const String _keyToken = 'token'; static const String _keyPlexToken = 'plex_token'; @@ -23,21 +22,16 @@ class StorageService { static const String _keyEnabledServers = 'enabled_servers'; static const String _keyServerOrder = 'server_order'; - static StorageService? _instance; - late SharedPreferences _prefs; - StorageService._(); static Future getInstance() async { - if (_instance == null) { - _instance = StorageService._(); - await _instance!._init(); - } - return _instance!; + return BaseSharedPreferencesService.initializeInstance( + () => StorageService._(), + ); } - Future _init() async { - _prefs = await SharedPreferences.getInstance(); + @override + Future onInit() async { // Seed known values so logs can redact immediately on startup. LogRedactionManager.registerServerUrl(getServerUrl()); LogRedactionManager.registerToken(getToken()); @@ -46,36 +40,36 @@ class StorageService { // Server URL Future saveServerUrl(String url) async { - await _prefs.setString(_keyServerUrl, url); + await prefs.setString(_keyServerUrl, url); LogRedactionManager.registerServerUrl(url); } String? getServerUrl() { - return _prefs.getString(_keyServerUrl); + return prefs.getString(_keyServerUrl); } // Per-Server Endpoint URL (for multi-server connection caching) Future saveServerEndpoint(String serverId, String url) async { - await _prefs.setString('server_endpoint_$serverId', url); + await prefs.setString('server_endpoint_$serverId', url); LogRedactionManager.registerServerUrl(url); } String? getServerEndpoint(String serverId) { - return _prefs.getString('server_endpoint_$serverId'); + return prefs.getString('server_endpoint_$serverId'); } Future clearServerEndpoint(String serverId) async { - await _prefs.remove('server_endpoint_$serverId'); + await prefs.remove('server_endpoint_$serverId'); } // Server Access Token Future saveToken(String token) async { - await _prefs.setString(_keyToken, token); + await prefs.setString(_keyToken, token); LogRedactionManager.registerToken(token); } String? getToken() { - return _prefs.getString(_keyToken); + return prefs.getString(_keyToken); } // Alias for server access token for clarity @@ -89,18 +83,18 @@ class StorageService { // Plex.tv Token (for API access) Future savePlexToken(String token) async { - await _prefs.setString(_keyPlexToken, token); + await prefs.setString(_keyPlexToken, token); LogRedactionManager.registerToken(token); } String? getPlexToken() { - return _prefs.getString(_keyPlexToken); + return prefs.getString(_keyPlexToken); } // Server Data (full PlexServer object as JSON) Future saveServerData(Map serverJson) async { final jsonString = json.encode(serverJson); - await _prefs.setString(_keyServerData, jsonString); + await prefs.setString(_keyServerData, jsonString); } Map? getServerData() { @@ -109,11 +103,11 @@ class StorageService { // Client Identifier Future saveClientIdentifier(String clientId) async { - await _prefs.setString(_keyClientId, clientId); + await prefs.setString(_keyClientId, clientId); } String? getClientIdentifier() { - return _prefs.getString(_keyClientId); + return prefs.getString(_keyClientId); } // Save all credentials at once @@ -137,15 +131,15 @@ class StorageService { // Clear all credentials Future clearCredentials() async { await Future.wait([ - _prefs.remove(_keyServerUrl), - _prefs.remove(_keyToken), - _prefs.remove(_keyPlexToken), - _prefs.remove(_keyServerData), - _prefs.remove(_keyClientId), - _prefs.remove(_keyUserProfile), - _prefs.remove(_keyCurrentUserUUID), - _prefs.remove(_keyHomeUsersCache), - _prefs.remove(_keyHomeUsersCacheExpiry), + prefs.remove(_keyServerUrl), + prefs.remove(_keyToken), + prefs.remove(_keyPlexToken), + prefs.remove(_keyServerData), + prefs.remove(_keyClientId), + prefs.remove(_keyUserProfile), + prefs.remove(_keyCurrentUserUUID), + prefs.remove(_keyHomeUsersCache), + prefs.remove(_keyHomeUsersCacheExpiry), clearMultiServerData(), ]); LogRedactionManager.clearTrackedValues(); @@ -161,16 +155,16 @@ class StorageService { } int? getSelectedLibraryIndex() { - return _prefs.getInt(_keySelectedLibraryIndex); + return prefs.getInt(_keySelectedLibraryIndex); } // Selected Library Key (replaces index-based selection) Future saveSelectedLibraryKey(String key) async { - await _prefs.setString(_keySelectedLibraryKey, key); + await prefs.setString(_keySelectedLibraryKey, key); } String? getSelectedLibraryKey() { - return _prefs.getString(_keySelectedLibraryKey); + return prefs.getString(_keySelectedLibraryKey); } // Library Filters (stored as JSON string) @@ -182,7 +176,7 @@ class StorageService { final key = sectionId != null ? 'library_filters_$sectionId' : _keyLibraryFilters; - await _prefs.setString(key, jsonString); + await prefs.setString(key, jsonString); } Map getLibraryFilters({String? sectionId}) { @@ -192,9 +186,9 @@ class StorageService { // Prefer per-library filters when available final jsonString = - _prefs.getString(scopedKey) ?? + prefs.getString(scopedKey) ?? // Legacy support: fall back to global filters if present - _prefs.getString(_keyLibraryFilters); + prefs.getString(_keyLibraryFilters); if (jsonString == null) return {}; final decoded = _decodeJsonStringToMap(jsonString); @@ -208,7 +202,7 @@ class StorageService { bool descending = false, }) async { final sortData = {'key': sortKey, 'descending': descending}; - await _prefs.setString('library_sort_$sectionId', json.encode(sortData)); + await prefs.setString('library_sort_$sectionId', json.encode(sortData)); } Map? getLibrarySort(String sectionId) { @@ -217,31 +211,31 @@ class StorageService { // Library Grouping (per-library, e.g., 'movies', 'shows', 'seasons', 'episodes') Future saveLibraryGrouping(String sectionId, String grouping) async { - await _prefs.setString('library_grouping_$sectionId', grouping); + await prefs.setString('library_grouping_$sectionId', grouping); } String? getLibraryGrouping(String sectionId) { - return _prefs.getString('library_grouping_$sectionId'); + return prefs.getString('library_grouping_$sectionId'); } // Library Tab (per-library, saves last selected tab index) Future saveLibraryTab(String sectionId, int tabIndex) async { - await _prefs.setInt('library_tab_$sectionId', tabIndex); + await prefs.setInt('library_tab_$sectionId', tabIndex); } int? getLibraryTab(String sectionId) { - return _prefs.getInt('library_tab_$sectionId'); + return prefs.getInt('library_tab_$sectionId'); } // Hidden Libraries (stored as JSON array of library section IDs) Future saveHiddenLibraries(Set libraryKeys) async { final list = libraryKeys.toList(); final jsonString = json.encode(list); - await _prefs.setString(_keyHiddenLibraries, jsonString); + await prefs.setString(_keyHiddenLibraries, jsonString); } Set getHiddenLibraries() { - final jsonString = _prefs.getString(_keyHiddenLibraries); + final jsonString = prefs.getString(_keyHiddenLibraries); if (jsonString == null) return {}; try { @@ -255,22 +249,22 @@ class StorageService { // Clear library preferences Future clearLibraryPreferences() async { await Future.wait([ - _prefs.remove(_keySelectedLibraryIndex), - _prefs.remove(_keyLibraryFilters), - _prefs.remove(_keyLibraryOrder), - _prefs.remove(_keyHiddenLibraries), + prefs.remove(_keySelectedLibraryIndex), + prefs.remove(_keyLibraryFilters), + prefs.remove(_keyLibraryOrder), + prefs.remove(_keyHiddenLibraries), ]); // Also clear all library sort preferences - final keys = _prefs.getKeys(); + final keys = prefs.getKeys(); final sortKeys = keys.where((key) => key.startsWith('library_sort_')); - await Future.wait(sortKeys.map((key) => _prefs.remove(key))); + await Future.wait(sortKeys.map((key) => prefs.remove(key))); } // Library Order (stored as JSON list of library keys) Future saveLibraryOrder(List libraryKeys) async { final jsonString = json.encode(libraryKeys); - await _prefs.setString(_keyLibraryOrder, jsonString); + await prefs.setString(_keyLibraryOrder, jsonString); } List? getLibraryOrder() => _getStringList(_keyLibraryOrder); @@ -278,7 +272,7 @@ class StorageService { // User Profile (stored as JSON string) Future saveUserProfile(Map profileJson) async { final jsonString = json.encode(profileJson); - await _prefs.setString(_keyUserProfile, jsonString); + await prefs.setString(_keyUserProfile, jsonString); } Map? getUserProfile() { @@ -287,27 +281,27 @@ class StorageService { // Current User UUID Future saveCurrentUserUUID(String uuid) async { - await _prefs.setString(_keyCurrentUserUUID, uuid); + await prefs.setString(_keyCurrentUserUUID, uuid); } String? getCurrentUserUUID() { - return _prefs.getString(_keyCurrentUserUUID); + return prefs.getString(_keyCurrentUserUUID); } // Home Users Cache (stored as JSON string with expiry) Future saveHomeUsersCache(Map homeData) async { final jsonString = json.encode(homeData); - await _prefs.setString(_keyHomeUsersCache, jsonString); + await prefs.setString(_keyHomeUsersCache, jsonString); // Set cache expiry to 1 hour from now final expiry = DateTime.now() .add(const Duration(hours: 1)) .millisecondsSinceEpoch; - await _prefs.setInt(_keyHomeUsersCacheExpiry, expiry); + await prefs.setInt(_keyHomeUsersCacheExpiry, expiry); } Map? getHomeUsersCache() { - final expiry = _prefs.getInt(_keyHomeUsersCacheExpiry); + final expiry = prefs.getInt(_keyHomeUsersCacheExpiry); if (expiry == null || DateTime.now().millisecondsSinceEpoch > expiry) { // Cache expired, clear it clearHomeUsersCache(); @@ -319,14 +313,14 @@ class StorageService { Future clearHomeUsersCache() async { await Future.wait([ - _prefs.remove(_keyHomeUsersCache), - _prefs.remove(_keyHomeUsersCacheExpiry), + prefs.remove(_keyHomeUsersCache), + prefs.remove(_keyHomeUsersCacheExpiry), ]); } // Clear current user UUID (for server switching) Future clearCurrentUserUUID() async { - await _prefs.remove(_keyCurrentUserUUID); + await prefs.remove(_keyCurrentUserUUID); } // Clear all user-related data (for logout) @@ -346,38 +340,38 @@ class StorageService { /// Get servers list as JSON string String? getServersListJson() { - return _prefs.getString(_keyServersList); + return prefs.getString(_keyServersList); } /// Save servers list as JSON string Future saveServersListJson(String serversJson) async { - await _prefs.setString(_keyServersList, serversJson); + await prefs.setString(_keyServersList, serversJson); } /// Get enabled servers as JSON string String? getEnabledServersJson() { - return _prefs.getString(_keyEnabledServers); + return prefs.getString(_keyEnabledServers); } /// Save enabled servers as JSON string Future saveEnabledServersJson(String enabledJson) async { - await _prefs.setString(_keyEnabledServers, enabledJson); + await prefs.setString(_keyEnabledServers, enabledJson); } /// Clear servers list Future clearServersList() async { - await _prefs.remove(_keyServersList); + await prefs.remove(_keyServersList); } /// Clear enabled servers Future clearEnabledServers() async { - await _prefs.remove(_keyEnabledServers); + await prefs.remove(_keyEnabledServers); } /// Clear all multi-server data Future clearMultiServerData() async { // Clear all server endpoint caches - final keys = _prefs.getKeys(); + final keys = prefs.getKeys(); final endpointKeys = keys.where( (key) => key.startsWith('server_endpoint_'), ); @@ -386,28 +380,28 @@ class StorageService { clearServersList(), clearEnabledServers(), clearServerOrder(), - ...endpointKeys.map((key) => _prefs.remove(key)), + ...endpointKeys.map((key) => prefs.remove(key)), ]); } /// Server Order (stored as JSON list of server IDs) Future saveServerOrder(List serverIds) async { final jsonString = json.encode(serverIds); - await _prefs.setString(_keyServerOrder, jsonString); + await prefs.setString(_keyServerOrder, jsonString); } List? getServerOrder() => _getStringList(_keyServerOrder); /// Clear server order Future clearServerOrder() async { - await _prefs.remove(_keyServerOrder); + await prefs.remove(_keyServerOrder); } // Private helper methods /// Helper to read and decode JSON `List` from preferences List? _getStringList(String key) { - final jsonString = _prefs.getString(key); + final jsonString = prefs.getString(key); if (jsonString == null) return null; try { @@ -427,7 +421,7 @@ class StorageService { String key, { bool legacyStringOk = false, }) { - final jsonString = _prefs.getString(key); + final jsonString = prefs.getString(key); if (jsonString == null) return null; return _decodeJsonStringToMap(jsonString, legacyStringOk: legacyStringOk); diff --git a/lib/utils/desktop_window_padding.dart b/lib/utils/desktop_window_padding.dart index 446787c6..e15b5de4 100644 --- a/lib/utils/desktop_window_padding.dart +++ b/lib/utils/desktop_window_padding.dart @@ -6,13 +6,11 @@ import '../services/fullscreen_state_manager.dart'; /// When present, app bars should skip their left padding since the side nav /// already handles the macOS traffic lights area. class SideNavigationScope extends InheritedWidget { - const SideNavigationScope({ - super.key, - required super.child, - }); + const SideNavigationScope({super.key, required super.child}); static bool isPresent(BuildContext context) { - return context.dependOnInheritedWidgetOfExactType() != null; + return context.dependOnInheritedWidgetOfExactType() != + null; } @override @@ -126,7 +124,10 @@ class DesktopAppBarHelper { /// Calculates the leading width for SliverAppBar to account for macOS traffic lights /// [context] - Required to check if side navigation is visible - static double? calculateLeadingWidth(Widget? leading, {BuildContext? context}) { + static double? calculateLeadingWidth( + Widget? leading, { + BuildContext? context, + }) { if (!Platform.isMacOS || leading == null) { return null; } diff --git a/lib/utils/media_navigation_helper.dart b/lib/utils/media_navigation_helper.dart new file mode 100644 index 00000000..f49bc036 --- /dev/null +++ b/lib/utils/media_navigation_helper.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_playlist.dart'; +import '../screens/media_detail_screen.dart'; +import '../screens/season_detail_screen.dart'; +import '../screens/playlist/playlist_detail_screen.dart'; +import 'video_player_navigation.dart'; + +/// Navigates to the appropriate screen based on the item type. +/// +/// For episodes, starts playback directly via video player. +/// For seasons, navigates to season detail screen. +/// For playlists, navigates to playlist detail screen. +/// For other types (shows, movies), navigates to media detail screen. +/// +/// The [onRefresh] callback is invoked with the item's ratingKey after +/// returning from the detail screen, allowing the caller to refresh state. +Future navigateToMediaItem( + BuildContext context, + dynamic item, { + void Function(String)? onRefresh, +}) async { + // Handle playlists + if (item is PlexPlaylist) { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => PlaylistDetailScreen(playlist: item), + ), + ); + return; + } + + final itemType = (item as PlexMetadata).type.toLowerCase(); + + // For episodes, start playback directly + if (itemType == 'episode') { + final result = await navigateToVideoPlayer(context, metadata: item); + if (result == true) { + onRefresh?.call(item.ratingKey); + } + } else if (itemType == 'season') { + await Navigator.push( + context, + MaterialPageRoute(builder: (context) => SeasonDetailScreen(season: item)), + ); + onRefresh?.call(item.ratingKey); + } else { + // For all other types (shows, movies), show detail screen + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => MediaDetailScreen(metadata: item), + ), + ); + if (result == true) { + onRefresh?.call(item.ratingKey); + } + } +} diff --git a/lib/utils/sliver_adaptive_media_builder.dart b/lib/utils/sliver_adaptive_media_builder.dart new file mode 100644 index 00000000..9147aac8 --- /dev/null +++ b/lib/utils/sliver_adaptive_media_builder.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import '../services/settings_service.dart'; +import 'grid_size_calculator.dart'; + +/// Builds an adaptive Sliver widget that switches between grid and list +/// based on the current view mode setting. +/// +/// This helper consolidates the list vs grid Sliver builders to keep +/// padding and density logic in sync across different screens. +Widget buildAdaptiveMediaSliverBuilder({ + required BuildContext context, + required List items, + required Widget Function(BuildContext context, T item, int index) itemBuilder, + required ViewMode viewMode, + required LibraryDensity density, + EdgeInsets padding = const EdgeInsets.all(16), + double childAspectRatio = 2 / 3.3, + double crossAxisSpacing = 8, + double mainAxisSpacing = 8, +}) { + if (viewMode == ViewMode.list) { + return SliverPadding( + padding: padding, + sliver: SliverList( + delegate: SliverChildBuilderDelegate((context, index) { + final item = items[index]; + return itemBuilder(context, item, index); + }, childCount: items.length), + ), + ); + } else { + return SliverPadding( + padding: padding, + sliver: SliverGrid( + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent( + context, + density, + ), + childAspectRatio: childAspectRatio, + crossAxisSpacing: crossAxisSpacing, + mainAxisSpacing: mainAxisSpacing, + ), + delegate: SliverChildBuilderDelegate((context, index) { + final item = items[index]; + return itemBuilder(context, item, index); + }, childCount: items.length), + ), + ); + } +} diff --git a/lib/widgets/desktop_app_bar.dart b/lib/widgets/desktop_app_bar.dart index 27a120f8..fdc7ea71 100644 --- a/lib/widgets/desktop_app_bar.dart +++ b/lib/widgets/desktop_app_bar.dart @@ -3,6 +3,102 @@ import '../utils/desktop_window_padding.dart'; import '../services/fullscreen_state_manager.dart'; import 'app_bar_back_button.dart'; +/// Configuration class for common app bar properties. +/// Reduces duplication between different app bar implementations. +class DesktopAppBarConfig { + final Widget? title; + final List? actions; + final double? elevation; + final Color? backgroundColor; + final Color? surfaceTintColor; + final Color? shadowColor; + final double? scrolledUnderElevation; + final bool floating; + final bool pinned; + final double? expandedHeight; + final Widget? flexibleSpace; + final PreferredSizeWidget? bottom; + + const DesktopAppBarConfig({ + this.title, + this.actions, + this.elevation, + this.backgroundColor, + this.surfaceTintColor, + this.shadowColor, + this.scrolledUnderElevation, + this.floating = false, + this.pinned = false, + this.expandedHeight, + this.flexibleSpace, + this.bottom, + }); +} + +/// Helper class for building app bar sections with consistent desktop behavior. +class DesktopAppBarSections { + /// Builds the leading section with proper padding and back button handling. + static Widget? buildLeadingSection({ + Widget? leading, + bool automaticallyImplyLeading = true, + required BuildContext context, + }) { + Widget? effectiveLeading = leading; + + // If no leading is provided but automaticallyImplyLeading is true, + // create a back button manually so it goes through our padding logic + if (leading == null && automaticallyImplyLeading) { + final parentRoute = ModalRoute.of(context); + final canPop = parentRoute?.canPop ?? false; + + if (canPop) { + effectiveLeading = IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).pop(), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + ); + } + } + + return DesktopAppBarHelper.buildAdjustedLeading( + effectiveLeading, + includeGestureDetector: true, + context: context, + ); + } + + /// Builds the title section with proper padding. + static Widget? buildTitleSection({ + required Widget? title, + required Widget? leading, + }) { + if (title == null) return null; + + return DesktopTitleBarPadding( + leftPadding: leading != null ? 0 : null, + child: title, + ); + } + + /// Builds the actions section with proper padding. + static List? buildActionsSection(List? actions) { + return DesktopAppBarHelper.buildAdjustedActions(actions); + } + + /// Calculates the leading width for the app bar. + static double? calculateLeadingWidthForSection({ + required Widget? leading, + required BuildContext context, + }) { + return DesktopAppBarHelper.calculateLeadingWidth(leading, context: context); + } + + /// Builds the flexible space section with gesture handling. + static Widget? buildFlexibleSpaceSection(Widget? flexibleSpace) { + return DesktopAppBarHelper.buildAdjustedFlexibleSpace(flexibleSpace); + } +} + /// A custom sliver app bar that automatically handles desktop window controls spacing. /// Use this instead of SliverAppBar for consistent desktop platform behavior. class DesktopSliverAppBar extends StatelessWidget { @@ -41,39 +137,21 @@ class DesktopSliverAppBar extends StatelessWidget { @override Widget build(BuildContext context) { - // Determine the effective leading widget - Widget? effectiveLeading = leading; - - // If no leading is provided but automaticallyImplyLeading is true, - // create a back button manually so it goes through our padding logic - if (leading == null && automaticallyImplyLeading) { - final parentRoute = ModalRoute.of(context); - final canPop = parentRoute?.canPop ?? false; - - if (canPop) { - effectiveLeading = IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.of(context).pop(), - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - ); - } - } + final effectiveLeading = DesktopAppBarSections.buildLeadingSection( + leading: leading, + automaticallyImplyLeading: automaticallyImplyLeading, + context: context, + ); return SliverAppBar( - title: title != null - ? DesktopTitleBarPadding( - leftPadding: effectiveLeading != null ? 0 : null, - child: title!, - ) - : null, - actions: DesktopAppBarHelper.buildAdjustedActions(actions), - leading: DesktopAppBarHelper.buildAdjustedLeading( - effectiveLeading, - includeGestureDetector: true, - context: context, + title: DesktopAppBarSections.buildTitleSection( + title: title, + leading: effectiveLeading, ), - leadingWidth: DesktopAppBarHelper.calculateLeadingWidth( - effectiveLeading, + actions: DesktopAppBarSections.buildActionsSection(actions), + leading: effectiveLeading, + leadingWidth: DesktopAppBarSections.calculateLeadingWidthForSection( + leading: effectiveLeading, context: context, ), automaticallyImplyLeading: @@ -86,7 +164,7 @@ class DesktopSliverAppBar extends StatelessWidget { floating: floating, pinned: pinned, expandedHeight: expandedHeight, - flexibleSpace: DesktopAppBarHelper.buildAdjustedFlexibleSpace( + flexibleSpace: DesktopAppBarSections.buildFlexibleSpaceSection( flexibleSpace, ), bottom: bottom, @@ -94,7 +172,69 @@ class DesktopSliverAppBar extends StatelessWidget { } } -/// Convenient wrapper for DesktopSliverAppBar with built-in back button handling +/// Unified widget for desktop top bars that handles fullscreen state and back button logic. +/// Reduces UI drift by centralizing the app bar implementation. +class DesktopTopBar extends StatelessWidget { + final DesktopAppBarConfig config; + final Widget? leading; + final VoidCallback? onBackPressed; + final bool automaticallyImplyLeading; + + const DesktopTopBar({ + super.key, + required this.config, + this.leading, + this.onBackPressed, + this.automaticallyImplyLeading = true, + }); + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: FullscreenStateManager(), + builder: (context, _) { + final isFullscreen = FullscreenStateManager().isFullscreen; + + // Determine the effective leading widget + Widget? effectiveLeading = leading; + if (effectiveLeading == null && automaticallyImplyLeading) { + final parentRoute = ModalRoute.of(context); + final canPop = parentRoute?.canPop ?? false; + + if (canPop) { + effectiveLeading = AppBarBackButton( + style: BackButtonStyle.plain, + onPressed: onBackPressed, + ); + } + } + + return DesktopSliverAppBar( + key: ValueKey('desktop_top_bar_$isFullscreen'), + title: config.title, + actions: config.actions, + leading: effectiveLeading, + automaticallyImplyLeading: false, + elevation: config.elevation, + backgroundColor: config.backgroundColor, + surfaceTintColor: config.surfaceTintColor, + shadowColor: config.shadowColor, + scrolledUnderElevation: config.scrolledUnderElevation, + floating: config.floating, + pinned: config.pinned, + expandedHeight: config.expandedHeight, + flexibleSpace: config.flexibleSpace, + bottom: config.bottom, + ); + }, + ); + } +} + +/// Convenient wrapper for DesktopSliverAppBar with built-in back button handling. +/// +/// This widget is maintained for backward compatibility. For new code, consider +/// using [DesktopTopBar] directly for a more unified approach. class CustomAppBar extends StatelessWidget { final Widget? title; final List? actions; @@ -129,39 +269,23 @@ class CustomAppBar extends StatelessWidget { @override Widget build(BuildContext context) { - return ListenableBuilder( - listenable: FullscreenStateManager(), - builder: (context, _) { - final isFullscreen = FullscreenStateManager().isFullscreen; - - return DesktopSliverAppBar( - key: ValueKey('plex_sliver_app_bar_$isFullscreen'), - title: title, - actions: actions, - leading: _shouldShowBackButton(context) - ? AppBarBackButton( - style: BackButtonStyle.plain, - onPressed: onBackPressed, - ) - : null, - automaticallyImplyLeading: false, - elevation: elevation, - backgroundColor: backgroundColor, - surfaceTintColor: surfaceTintColor, - shadowColor: shadowColor, - scrolledUnderElevation: scrolledUnderElevation, - floating: floating, - pinned: pinned, - expandedHeight: expandedHeight, - flexibleSpace: flexibleSpace, - bottom: bottom, - ); - }, + return DesktopTopBar( + config: DesktopAppBarConfig( + title: title, + actions: actions, + elevation: elevation, + backgroundColor: backgroundColor, + surfaceTintColor: surfaceTintColor, + shadowColor: shadowColor, + scrolledUnderElevation: scrolledUnderElevation, + floating: floating, + pinned: pinned, + expandedHeight: expandedHeight, + flexibleSpace: flexibleSpace, + bottom: bottom, + ), + onBackPressed: onBackPressed, + automaticallyImplyLeading: true, ); } - - bool _shouldShowBackButton(BuildContext context) { - final parentRoute = ModalRoute.of(context); - return parentRoute?.canPop ?? false; - } } diff --git a/lib/widgets/focus_builders.dart b/lib/widgets/focus_builders.dart new file mode 100644 index 00000000..3f85e975 --- /dev/null +++ b/lib/widgets/focus_builders.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; +import '../focus/focus_theme.dart'; +import '../focus/input_mode_tracker.dart'; + +/// Shared builders for focusable widgets to reduce code duplication. +/// +/// These builders provide consistent focus decoration patterns across +/// different focusable widgets (chips, cards, etc.). +class FocusBuilders { + /// Builds a chip-style focusable widget with background color changes. + /// + /// Used by FocusableTabChip and FocusableFilterChip. + /// + /// Parameters: + /// - [context]: Build context for theming + /// - [focusNode]: The focus node for this widget + /// - [isFocused]: Whether this widget currently has focus + /// - [onKeyEvent]: Callback for handling key events + /// - [onTap]: Callback for tap/click events + /// - [padding]: Padding inside the chip + /// - [backgroundColor]: Background color for the chip + /// - [borderRadius]: Border radius for the chip (defaults to 20) + /// - [child]: The content to display inside the chip + static Widget buildFocusableChip({ + required BuildContext context, + required FocusNode focusNode, + required bool isFocused, + required KeyEventResult Function(FocusNode, KeyEvent) onKeyEvent, + required VoidCallback onTap, + required EdgeInsetsGeometry padding, + required Color backgroundColor, + double borderRadius = 20, + required Widget child, + }) { + final duration = FocusTheme.getAnimationDuration(context); + + return Focus( + focusNode: focusNode, + onKeyEvent: onKeyEvent, + child: GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: duration, + curve: Curves.easeOutCubic, + padding: padding, + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(borderRadius), + ), + child: child, + ), + ), + ); + } + + /// Builds a card-style focusable widget with scale and border decoration. + /// + /// Used by FocusableMediaCard and _LockedHubItemWrapper. + /// + /// Parameters: + /// - [context]: Build context for theming + /// - [focusNode]: The focus node for this widget (optional for locked wrappers) + /// - [isFocused]: Whether this widget currently has focus + /// - [onKeyEvent]: Callback for handling key events (optional for locked wrappers) + /// - [onTap]: Callback for tap/click events + /// - [onLongPress]: Callback for long press events + /// - [borderRadius]: Border radius for the focus decoration + /// - [child]: The content to display inside the card + static Widget buildFocusableCard({ + required BuildContext context, + FocusNode? focusNode, + required bool isFocused, + KeyEventResult Function(FocusNode, KeyEvent)? onKeyEvent, + VoidCallback? onTap, + VoidCallback? onLongPress, + double borderRadius = FocusTheme.defaultBorderRadius, + required Widget child, + }) { + final duration = FocusTheme.getAnimationDuration(context); + // Only show focus effects during keyboard/d-pad navigation + final showFocus = isFocused && InputModeTracker.isKeyboardMode(context); + + final focusedWidget = AnimatedScale( + scale: showFocus ? FocusTheme.focusScale : 1.0, + duration: duration, + curve: Curves.easeOutCubic, + child: AnimatedContainer( + duration: duration, + curve: Curves.easeOutCubic, + decoration: FocusTheme.focusDecoration( + context, + isFocused: showFocus, + borderRadius: borderRadius, + ), + child: child, + ), + ); + + // Wrap in GestureDetector if tap/long press handlers provided + final gestureWidget = (onTap != null || onLongPress != null) + ? GestureDetector( + onTap: onTap, + onLongPress: onLongPress, + child: focusedWidget, + ) + : focusedWidget; + + // Wrap in Focus if focus node and key event handler provided + if (focusNode != null && onKeyEvent != null) { + return Focus( + focusNode: focusNode, + onKeyEvent: onKeyEvent, + child: gestureWidget, + ); + } + + return gestureWidget; + } + + /// Builds a simple locked wrapper (no Focus widget) with scale and border decoration. + /// + /// Used by _LockedHubItemWrapper where focus is managed at a higher level. + /// + /// Parameters: + /// - [context]: Build context for theming + /// - [isFocused]: Whether this widget should appear focused + /// - [onTap]: Callback for tap/click events + /// - [onLongPress]: Callback for long press events + /// - [borderRadius]: Border radius for the focus decoration + /// - [child]: The content to display inside the wrapper + static Widget buildLockedFocusWrapper({ + required BuildContext context, + required bool isFocused, + VoidCallback? onTap, + VoidCallback? onLongPress, + double borderRadius = FocusTheme.defaultBorderRadius, + required Widget child, + }) { + return buildFocusableCard( + context: context, + focusNode: null, + isFocused: isFocused, + onKeyEvent: null, + onTap: onTap, + onLongPress: onLongPress, + borderRadius: borderRadius, + child: child, + ); + } +} diff --git a/lib/widgets/focusable_filter_chip.dart b/lib/widgets/focusable_filter_chip.dart index 077a68eb..a5919051 100644 --- a/lib/widgets/focusable_filter_chip.dart +++ b/lib/widgets/focusable_filter_chip.dart @@ -2,8 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../focus/dpad_navigator.dart'; -import '../focus/focus_theme.dart'; +import '../focus/focusable_chip_mixin.dart'; import '../focus/input_mode_tracker.dart'; +import 'focus_builders.dart'; /// A focusable filter chip that shows a color change when focused. /// @@ -41,43 +42,32 @@ class FocusableFilterChip extends StatefulWidget { State createState() => _FocusableFilterChipState(); } -class _FocusableFilterChipState extends State { - FocusNode? _internalFocusNode; - bool _isFocused = false; +class _FocusableFilterChipState extends State + with FocusableChipStateMixin { + @override + FocusNode? get widgetFocusNode => widget.focusNode; - FocusNode get _focusNode { - return widget.focusNode ?? - (_internalFocusNode ??= FocusNode(debugLabel: 'filter_chip_${widget.label}')); - } + @override + String get debugLabel => 'filter_chip_${widget.label}'; @override void initState() { super.initState(); - _focusNode.addListener(_onFocusChange); + initFocusNode(); } @override void didUpdateWidget(FocusableFilterChip oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.focusNode != widget.focusNode) { - oldWidget.focusNode?.removeListener(_onFocusChange); - _focusNode.addListener(_onFocusChange); - } + updateFocusNode(oldWidget.focusNode); } @override void dispose() { - _focusNode.removeListener(_onFocusChange); - _internalFocusNode?.dispose(); + disposeFocusNode(); super.dispose(); } - void _onFocusChange() { - if (mounted) { - setState(() => _isFocused = _focusNode.hasFocus); - } - } - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return KeyEventResult.ignored; @@ -96,7 +86,8 @@ class _FocusableFilterChipState extends State { } // UP arrow navigates to tab bar - if (event.logicalKey == LogicalKeyboardKey.arrowUp && widget.onNavigateUp != null) { + if (event.logicalKey == LogicalKeyboardKey.arrowUp && + widget.onNavigateUp != null) { widget.onNavigateUp!(); return KeyEventResult.handled; } @@ -113,44 +104,37 @@ class _FocusableFilterChipState extends State { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final duration = FocusTheme.getAnimationDuration(context); // Only show focus effects during keyboard/d-pad navigation - final showFocus = _isFocused && InputModeTracker.isKeyboardMode(context); + final showFocus = isFocused && InputModeTracker.isKeyboardMode(context); // Use primary color when focused, surface color when not - final backgroundColor = - showFocus ? colorScheme.primary : colorScheme.surfaceContainerHighest; - final foregroundColor = - showFocus ? colorScheme.onPrimary : colorScheme.onSurfaceVariant; + final backgroundColor = showFocus + ? colorScheme.primary + : colorScheme.surfaceContainerHighest; + final foregroundColor = showFocus + ? colorScheme.onPrimary + : colorScheme.onSurfaceVariant; - return Focus( - focusNode: _focusNode, + return FocusBuilders.buildFocusableChip( + context: context, + focusNode: focusNode, + isFocused: isFocused, onKeyEvent: _handleKeyEvent, - child: GestureDetector( - onTap: widget.onPressed, - child: AnimatedContainer( - duration: duration, - curve: Curves.easeOutCubic, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(20), + onTap: widget.onPressed, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + backgroundColor: backgroundColor, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: foregroundColor), + const SizedBox(width: 6), + Text( + widget.label, + style: Theme.of( + context, + ).textTheme.labelMedium?.copyWith(color: foregroundColor), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: foregroundColor), - const SizedBox(width: 6), - Text( - widget.label, - style: Theme.of(context) - .textTheme - .labelMedium - ?.copyWith(color: foregroundColor), - ), - ], - ), - ), + ], ), ); } diff --git a/lib/widgets/focusable_media_card.dart b/lib/widgets/focusable_media_card.dart index 0fbef4a3..3fd65526 100644 --- a/lib/widgets/focusable_media_card.dart +++ b/lib/widgets/focusable_media_card.dart @@ -1,18 +1,13 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import '../focus/dpad_navigator.dart'; -import '../focus/focus_theme.dart'; -import '../focus/input_mode_tracker.dart'; +import '../focus/focusable_wrapper.dart'; import 'media_card.dart'; /// A focusable wrapper for MediaCard that handles D-pad navigation. /// /// Wraps MediaCard with focus handling for TV/desktop navigation: /// - Shows scale + border decoration when focused -/// - Handles SELECT key for activation +/// - Handles SELECT key for activation with long-press detection /// - Accepts optional external focusNode for programmatic focus control class FocusableMediaCard extends StatefulWidget { final dynamic item; // PlexMetadata or PlexPlaylist @@ -58,205 +53,31 @@ class FocusableMediaCard extends StatefulWidget { } class _FocusableMediaCardState extends State { - FocusNode? _internalFocusNode; - bool _isFocused = false; - // Key for accessing MediaCard's state final GlobalKey _mediaCardKey = GlobalKey(); - // Long-press detection for SELECT key - Timer? _longPressTimer; - bool _isSelectKeyDown = false; - static const _longPressDuration = Duration(milliseconds: 500); - - FocusNode get _focusNode { - // Use external focus node if provided, otherwise use internal - return widget.focusNode ?? - (_internalFocusNode ??= FocusNode( - debugLabel: 'focusable_media_card_${widget.item.ratingKey}', - )); - } - - @override - void initState() { - super.initState(); - _focusNode.addListener(_onFocusChange); - } - - @override - void didUpdateWidget(FocusableMediaCard oldWidget) { - super.didUpdateWidget(oldWidget); - // Handle focus node changes - if (oldWidget.focusNode != widget.focusNode) { - oldWidget.focusNode?.removeListener(_onFocusChange); - _focusNode.addListener(_onFocusChange); - } - } - - @override - void dispose() { - _longPressTimer?.cancel(); - _focusNode.removeListener(_onFocusChange); - // Only dispose the internal focus node, not external ones - _internalFocusNode?.dispose(); - super.dispose(); - } - - void _onFocusChange() { - if (mounted) { - final hasFocus = _focusNode.hasFocus; - setState(() => _isFocused = hasFocus); - - // Scroll to center when gaining focus - if (hasFocus) { - _scrollIntoView(); - } - } - } - - /// Scrolls this card into view, centering it vertically in the viewport. - /// Only scrolls if the item is outside the "comfortable zone" (middle 60%) - /// to prevent jitter when navigating horizontally within the same row. - void _scrollIntoView() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !_isFocused) return; - - final renderObject = context.findRenderObject(); - if (renderObject == null) return; - - // Get the scrollable ancestor - final scrollable = Scrollable.maybeOf(context); - if (scrollable == null) return; - - final viewport = scrollable.context.findRenderObject() as RenderBox?; - if (viewport == null) return; - - // Get item's position relative to viewport - final itemBox = renderObject as RenderBox; - final itemPosition = itemBox.localToGlobal( - Offset.zero, - ancestor: viewport, - ); - - // Check if item is already in the comfortable zone - final viewportHeight = viewport.size.height; - final itemHeight = itemBox.size.height; - final itemVerticalCenter = itemPosition.dy + itemHeight / 2; - - // Define comfortable zone - if item center is within middle 60% of viewport, don't scroll - final comfortZoneTop = viewportHeight * 0.2; - final comfortZoneBottom = viewportHeight * 0.8; - - if (itemVerticalCenter >= comfortZoneTop && - itemVerticalCenter <= comfortZoneBottom) { - // Item is in comfortable zone, no need to scroll - return; - } - - // Item is outside comfortable zone, scroll to center - Scrollable.ensureVisible( - context, - alignment: 0.5, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - ); - }); - } - - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { - final key = event.logicalKey; - - // Handle SELECT key with long-press detection - if (key.isSelectKey) { - if (event is KeyDownEvent) { - // Only start timer on initial press, not repeats - if (!_isSelectKeyDown) { - _isSelectKeyDown = true; - _longPressTimer?.cancel(); - _longPressTimer = Timer(_longPressDuration, () { - // Long press detected - show context menu immediately - if (mounted) { - _mediaCardKey.currentState?.showContextMenu(); - } - }); - } - return KeyEventResult.handled; - } else if (event is KeyRepeatEvent) { - // Consume repeat events to prevent system sounds - return KeyEventResult.handled; - } else if (event is KeyUpEvent) { - final timerWasActive = _longPressTimer?.isActive ?? false; - _longPressTimer?.cancel(); - if (timerWasActive && _isSelectKeyDown) { - // Timer still active - short press, trigger tap - _mediaCardKey.currentState?.handleTap(); - } - // If timer already fired, context menu was shown - do nothing on key up - _isSelectKeyDown = false; - return KeyEventResult.handled; - } - } - - // Ignore key repeat events for other keys - if (event is! KeyDownEvent && event is! KeyRepeatEvent) { - return KeyEventResult.ignored; - } - - // Context menu key shows context menu - if (key.isContextMenuKey) { - _mediaCardKey.currentState?.showContextMenu(); - return KeyEventResult.handled; - } - - // UP arrow - if callback provided, navigate up (to filter chips) - if (key == LogicalKeyboardKey.arrowUp && widget.onNavigateUp != null) { - widget.onNavigateUp!(); - return KeyEventResult.handled; - } - - // BACK key - navigate to tab bar - if (key.isBackKey && widget.onBack != null) { - widget.onBack!(); - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - } - @override Widget build(BuildContext context) { - final duration = FocusTheme.getAnimationDuration(context); - // Only show focus effects during keyboard/d-pad navigation - final showFocus = _isFocused && InputModeTracker.isKeyboardMode(context); - - return Focus( - focusNode: _focusNode, - onKeyEvent: _handleKeyEvent, - child: AnimatedScale( - scale: showFocus ? FocusTheme.focusScale : 1.0, - duration: duration, - curve: Curves.easeOutCubic, - child: AnimatedContainer( - duration: duration, - curve: Curves.easeOutCubic, - decoration: FocusTheme.focusDecoration( - context, - isFocused: showFocus, - borderRadius: FocusTheme.defaultBorderRadius, - ), - child: MediaCard( - key: _mediaCardKey, - item: widget.item, - width: widget.width, - height: widget.height, - onRefresh: widget.onRefresh, - onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, - onListRefresh: widget.onListRefresh, - forceGridMode: widget.forceGridMode, - isInContinueWatching: widget.isInContinueWatching, - collectionId: widget.collectionId, - ), - ), + return FocusableWrapper( + focusNode: widget.focusNode, + onSelect: () => _mediaCardKey.currentState?.handleTap(), + onLongPress: () => _mediaCardKey.currentState?.showContextMenu(), + onNavigateUp: widget.onNavigateUp, + onBack: widget.onBack, + enableLongPress: true, + useComfortableZone: true, + scrollAlignment: 0.5, + child: MediaCard( + key: _mediaCardKey, + item: widget.item, + width: widget.width, + height: widget.height, + onRefresh: widget.onRefresh, + onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, + onListRefresh: widget.onListRefresh, + forceGridMode: widget.forceGridMode, + isInContinueWatching: widget.isInContinueWatching, + collectionId: widget.collectionId, ), ); } diff --git a/lib/widgets/focusable_tab_chip.dart b/lib/widgets/focusable_tab_chip.dart index f437bcd1..b8972eec 100644 --- a/lib/widgets/focusable_tab_chip.dart +++ b/lib/widgets/focusable_tab_chip.dart @@ -2,8 +2,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../focus/dpad_navigator.dart'; -import '../focus/focus_theme.dart'; +import '../focus/focusable_chip_mixin.dart'; import '../focus/input_mode_tracker.dart'; +import 'focus_builders.dart'; /// A focusable tab chip that shows a color change when focused or selected. /// @@ -50,43 +51,32 @@ class FocusableTabChip extends StatefulWidget { State createState() => _FocusableTabChipState(); } -class _FocusableTabChipState extends State { - FocusNode? _internalFocusNode; - bool _isFocused = false; +class _FocusableTabChipState extends State + with FocusableChipStateMixin { + @override + FocusNode? get widgetFocusNode => widget.focusNode; - FocusNode get _focusNode { - return widget.focusNode ?? - (_internalFocusNode ??= FocusNode(debugLabel: 'tab_chip_${widget.label}')); - } + @override + String get debugLabel => 'tab_chip_${widget.label}'; @override void initState() { super.initState(); - _focusNode.addListener(_onFocusChange); + initFocusNode(); } @override void didUpdateWidget(FocusableTabChip oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.focusNode != widget.focusNode) { - oldWidget.focusNode?.removeListener(_onFocusChange); - _focusNode.addListener(_onFocusChange); - } + updateFocusNode(oldWidget.focusNode); } @override void dispose() { - _focusNode.removeListener(_onFocusChange); - _internalFocusNode?.dispose(); + disposeFocusNode(); super.dispose(); } - void _onFocusChange() { - if (mounted) { - setState(() => _isFocused = _focusNode.hasFocus); - } - } - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { if (event is! KeyDownEvent && event is! KeyRepeatEvent) { return KeyEventResult.ignored; @@ -130,9 +120,8 @@ class _FocusableTabChipState extends State { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - final duration = FocusTheme.getAnimationDuration(context); // Only show focus effects during keyboard/d-pad navigation - final showFocus = _isFocused && InputModeTracker.isKeyboardMode(context); + final showFocus = isFocused && InputModeTracker.isKeyboardMode(context); // Determine background color based on focus and selection state // - Selected + Focused: slightly dimmed primary (to show focus distinction) @@ -162,26 +151,19 @@ class _FocusableTabChipState extends State { final isHighlighted = showFocus || widget.isSelected; - return Focus( - focusNode: _focusNode, + return FocusBuilders.buildFocusableChip( + context: context, + focusNode: focusNode, + isFocused: isFocused, onKeyEvent: _handleKeyEvent, - child: GestureDetector( - onTap: widget.onSelect, - child: AnimatedContainer( - duration: duration, - curve: Curves.easeOutCubic, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - widget.label, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: foregroundColor, - fontWeight: isHighlighted ? FontWeight.w600 : FontWeight.normal, - ), - ), + onTap: widget.onSelect, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + backgroundColor: backgroundColor, + child: Text( + widget.label, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: foregroundColor, + fontWeight: isHighlighted ? FontWeight.w600 : FontWeight.normal, ), ), ); diff --git a/lib/widgets/focused_scroll_scaffold.dart b/lib/widgets/focused_scroll_scaffold.dart new file mode 100644 index 00000000..48e57e6f --- /dev/null +++ b/lib/widgets/focused_scroll_scaffold.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import '../focus/key_event_utils.dart'; +import 'desktop_app_bar.dart'; + +/// A scaffold widget that wraps Focus + Scaffold + CustomScrollView +/// with consistent keyboard navigation handling and app bar styling. +/// +/// This widget reduces boilerplate for screens that need: +/// - Keyboard navigation (back key handling) +/// - Custom scrollable content with slivers +/// - Consistent app bar with title and optional actions +class FocusedScrollScaffold extends StatelessWidget { + /// The title to display in the app bar. + /// Can be a Text widget or a more complex widget like Column. + final Widget title; + + /// The list of slivers to display in the scroll view. + /// Should not include the app bar (it's added automatically). + final List slivers; + + /// Optional actions to display in the app bar (e.g., IconButton widgets). + final List? actions; + + /// Whether the app bar should remain visible when scrolling. + /// Defaults to true. + final bool pinned; + + const FocusedScrollScaffold({ + super.key, + required this.title, + required this.slivers, + this.actions, + this.pinned = true, + }); + + @override + Widget build(BuildContext context) { + return Focus( + autofocus: true, + onKeyEvent: (_, event) => handleBackKeyNavigation(context, event), + child: Scaffold( + body: CustomScrollView( + slivers: [ + CustomAppBar(title: title, pinned: pinned, actions: actions), + ...slivers, + ], + ), + ), + ); + } +} diff --git a/lib/widgets/horizontal_scroll_with_arrows.dart b/lib/widgets/horizontal_scroll_with_arrows.dart index a751e7c3..4b9b1fbd 100644 --- a/lib/widgets/horizontal_scroll_with_arrows.dart +++ b/lib/widgets/horizontal_scroll_with_arrows.dart @@ -86,6 +86,31 @@ class _HorizontalScrollWithArrowsState ); } + Widget _buildArrowButton({ + required double position, + required IconData icon, + required VoidCallback onPressed, + required bool canScroll, + }) { + return Positioned( + left: position < 0 ? null : position, + right: position < 0 ? -position : null, + top: 0, + bottom: 0, + child: Center( + child: AnimatedOpacity( + opacity: (_isHovering && canScroll) ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: IgnorePointer( + ignoring: !(_isHovering && canScroll), + child: _NavigationArrow(icon: icon, onPressed: onPressed), + ), + ), + ), + ); + } + @override Widget build(BuildContext context) { final child = widget.builder(_scrollController); @@ -101,45 +126,17 @@ class _HorizontalScrollWithArrowsState child: Stack( children: [ child, - // Left arrow - Positioned( - left: 8, - top: 0, - bottom: 0, - child: Center( - child: AnimatedOpacity( - opacity: (_isHovering && _canScrollLeft) ? 1.0 : 0.0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - child: IgnorePointer( - ignoring: !(_isHovering && _canScrollLeft), - child: _NavigationArrow( - icon: Icons.chevron_left, - onPressed: _scrollLeft, - ), - ), - ), - ), + _buildArrowButton( + position: 8, + icon: Icons.chevron_left, + onPressed: _scrollLeft, + canScroll: _canScrollLeft, ), - // Right arrow - Positioned( - right: 8, - top: 0, - bottom: 0, - child: Center( - child: AnimatedOpacity( - opacity: (_isHovering && _canScrollRight) ? 1.0 : 0.0, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - child: IgnorePointer( - ignoring: !(_isHovering && _canScrollRight), - child: _NavigationArrow( - icon: Icons.chevron_right, - onPressed: _scrollRight, - ), - ), - ), - ), + _buildArrowButton( + position: -8, + icon: Icons.chevron_right, + onPressed: _scrollRight, + canScroll: _canScrollRight, ), ], ), diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index cf06c9c3..7153576c 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -1,16 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import '../focus/dpad_navigator.dart'; -import '../focus/focus_theme.dart'; -import '../focus/input_mode_tracker.dart'; import '../focus/locked_hub_controller.dart'; import '../models/plex_hub.dart'; -import '../models/plex_playlist.dart'; import '../screens/hub_detail_screen.dart'; -import '../screens/media_detail_screen.dart'; -import '../screens/season_detail_screen.dart'; -import '../screens/playlist/playlist_detail_screen.dart'; -import '../utils/video_player_navigation.dart'; +import '../utils/media_navigation_helper.dart'; +import 'focus_builders.dart'; import 'media_card.dart'; import 'horizontal_scroll_with_arrows.dart'; import '../i18n/strings.g.dart'; @@ -66,9 +61,7 @@ class HubSectionState extends State { @override void initState() { super.initState(); - _hubFocusNode = FocusNode( - debugLabel: 'hub_${widget.hub.hubKey}', - ); + _hubFocusNode = FocusNode(debugLabel: 'hub_${widget.hub.hubKey}'); _hubFocusNode.addListener(_onFocusChange); } @@ -77,7 +70,9 @@ class HubSectionState extends State { super.didUpdateWidget(oldWidget); // Clamp focus index if item count changed if (widget.hub.items.length != oldWidget.hub.items.length) { - final maxIndex = widget.hub.items.isEmpty ? 0 : widget.hub.items.length - 1; + final maxIndex = widget.hub.items.isEmpty + ? 0 + : widget.hub.items.length - 1; if (_focusedIndex > maxIndex) { _focusedIndex = maxIndex; } @@ -146,7 +141,8 @@ class HubSectionState extends State { if (!_scrollController.hasClients || _itemExtent <= 0) return; final viewport = _scrollController.position.viewportDimension; - final targetCenter = _leadingPadding + (index * _itemExtent) + (_itemExtent / 2); + final targetCenter = + _leadingPadding + (index * _itemExtent) + (_itemExtent / 2); final desiredOffset = (targetCenter - (viewport / 2)).clamp( 0.0, _scrollController.position.maxScrollExtent, @@ -249,45 +245,7 @@ class HubSectionState extends State { } Future _navigateToItem(dynamic item) async { - // Handle playlists - if (item is PlexPlaylist) { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => PlaylistDetailScreen(playlist: item), - ), - ); - return; - } - - final itemType = item.type.toLowerCase(); - - // For episodes, start playback directly - if (itemType == 'episode') { - final result = await navigateToVideoPlayer(context, metadata: item); - if (result == true) { - widget.onRefresh?.call(item.ratingKey); - } - } else if (itemType == 'season') { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => SeasonDetailScreen(season: item), - ), - ); - widget.onRefresh?.call(item.ratingKey); - } else { - // For all other types (shows, movies), show detail screen - final result = await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => MediaDetailScreen(metadata: item), - ), - ); - if (result == true) { - widget.onRefresh?.call(item.ratingKey); - } - } + await navigateToMediaItem(context, item, onRefresh: widget.onRefresh); } void _navigateToHubDetail(BuildContext context) { @@ -310,13 +268,12 @@ class HubSectionState extends State { padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), child: ExcludeFocus( child: InkWell( - onTap: widget.hub.more ? () => _navigateToHubDetail(context) : null, + onTap: widget.hub.more + ? () => _navigateToHubDetail(context) + : null, borderRadius: BorderRadius.circular(8), child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -353,10 +310,10 @@ class HubSectionState extends State { final cardWidth = screenWidth > 1600 ? 220.0 : screenWidth > 1200 - ? 200.0 - : screenWidth > 800 - ? 190.0 - : 160.0; + ? 200.0 + : screenWidth > 800 + ? 190.0 + : 160.0; // Store item extent for scroll calculations _itemExtent = cardWidth + 4; // 4px total horizontal padding @@ -382,7 +339,8 @@ class HubSectionState extends State { itemCount: widget.hub.items.length, itemBuilder: (context, index) { final item = widget.hub.items[index]; - final isItemFocused = hasFocus && index == _focusedIndex; + final isItemFocused = + hasFocus && index == _focusedIndex; return Padding( padding: const EdgeInsets.symmetric(horizontal: 2), @@ -465,29 +423,12 @@ class _LockedHubItemWrapperState extends State<_LockedHubItemWrapper> { @override Widget build(BuildContext context) { - final duration = FocusTheme.getAnimationDuration(context); - // Only show focus effects during keyboard/d-pad navigation - final showFocus = - widget.isFocused && InputModeTracker.isKeyboardMode(context); - - return GestureDetector( + return FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: widget.isFocused, onTap: widget.onTap, onLongPress: widget.onLongPress, - child: AnimatedScale( - scale: showFocus ? FocusTheme.focusScale : 1.0, - duration: duration, - curve: Curves.easeOutCubic, - child: AnimatedContainer( - duration: duration, - curve: Curves.easeOutCubic, - decoration: FocusTheme.focusDecoration( - context, - isFocused: showFocus, - borderRadius: FocusTheme.defaultBorderRadius, - ), - child: widget.child, - ), - ), + child: widget.child, ); } } diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 867d594b..de1114a3 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -297,91 +297,15 @@ class _MediaCardGrid extends StatelessWidget { ), ), 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(); - }, + _MediaCardHelpers.buildPlaylistMeta( + context, + item as PlexPlaylist, ) - 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(); - }, + else if (item is PlexMetadata) + _MediaCardHelpers.buildMetadataSubtitle( + context, + item as PlexMetadata, ), - ], ], ), ], @@ -713,7 +637,7 @@ Widget _buildPosterImage(BuildContext context, dynamic item) { posterUrl = item.displayImage; fallbackIcon = Icons.playlist_play; - return PlexPlaylistImage( + return PlexOptimizedImage.playlist( client: _getClientForItem(context, item), imagePath: posterUrl, width: double.infinity, @@ -724,7 +648,7 @@ Widget _buildPosterImage(BuildContext context, dynamic item) { final useSeasonPoster = context.watch().useSeasonPoster; posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster); - return PlexPosterImage( + return PlexOptimizedImage.poster( client: _getClientForItem(context, item), imagePath: posterUrl, width: double.infinity, @@ -751,8 +675,93 @@ class _PosterOverlay extends StatelessWidget { return const SizedBox.shrink(); } - final metadata = item as PlexMetadata; + return _MediaCardHelpers.buildWatchProgress(context, item as PlexMetadata); + } +} +/// Helper methods for building media card metadata and subtitles +class _MediaCardHelpers { + /// Builds playlist metadata (item count) + static Widget buildPlaylistMeta(BuildContext context, PlexPlaylist playlist) { + 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(); + } + + /// Builds metadata subtitle (for collections, episodes, movies, shows) + static Widget buildMetadataSubtitle( + BuildContext context, + PlexMetadata metadata, + ) { + // 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(); + } + + /// Builds watch progress overlay (checkmark for watched, progress bar for in-progress) + static Widget buildWatchProgress( + BuildContext context, + PlexMetadata metadata, + ) { return Stack( children: [ // Watched indicator (checkmark) diff --git a/lib/widgets/media_grid_delegate.dart b/lib/widgets/media_grid_delegate.dart new file mode 100644 index 00000000..0a7f81bd --- /dev/null +++ b/lib/widgets/media_grid_delegate.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import '../services/settings_service.dart'; +import '../utils/grid_size_calculator.dart'; +import '../utils/grid_cross_axis_extent.dart'; + +/// Shared grid delegate configuration for media item grids +/// Maintains consistent spacing (2/3.3 aspect ratio, 0 spacing) across all media grids +class MediaGridDelegate { + /// Standard aspect ratio for media cards (poster aspect) + static const double aspectRatio = 2 / 3.3; + + /// Standard cross-axis spacing between grid items + static const double crossAxisSpacing = 0; + + /// Standard main-axis spacing between grid items + static const double mainAxisSpacing = 0; + + /// Creates a standard grid delegate for media items + /// + /// Uses [GridSizeCalculator.getMaxCrossAxisExtent] by default. + /// Set [usePaddingAware] to true to use [getMaxCrossAxisExtentWithPadding] instead. + static SliverGridDelegateWithMaxCrossAxisExtent createDelegate({ + required BuildContext context, + required LibraryDensity density, + bool usePaddingAware = false, + double horizontalPadding = 16, + }) { + final maxCrossAxisExtent = usePaddingAware + ? getMaxCrossAxisExtentWithPadding(context, density, horizontalPadding) + : GridSizeCalculator.getMaxCrossAxisExtent(context, density); + + return SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: maxCrossAxisExtent, + childAspectRatio: aspectRatio, + crossAxisSpacing: crossAxisSpacing, + mainAxisSpacing: mainAxisSpacing, + ); + } +} diff --git a/lib/widgets/media_grid_sliver.dart b/lib/widgets/media_grid_sliver.dart new file mode 100644 index 00000000..9464af49 --- /dev/null +++ b/lib/widgets/media_grid_sliver.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/plex_metadata.dart'; +import '../providers/settings_provider.dart'; +import 'media_card.dart'; +import 'media_grid_delegate.dart'; + +/// Shared sliver grid builder for displaying media items +/// Used across hub detail, collection detail, playlist detail, and library browse screens +/// to maintain consistent spacing and focus behavior +class MediaGridSliver extends StatelessWidget { + /// The list of media items to display + final List items; + + /// Callback when an item needs to be refreshed + final void Function(String ratingKey)? onRefresh; + + /// Optional collection ID for collection-specific functionality + final String? collectionId; + + /// Optional callback to refresh the entire parent list + final VoidCallback? onListRefresh; + + /// Padding around the grid + /// Defaults to EdgeInsets.fromLTRB(8, 0, 8, 8) + final EdgeInsets padding; + + /// Whether to use the padding-aware cross axis extent calculation + /// Defaults to false (uses standard GridSizeCalculator) + final bool usePaddingAwareExtent; + + /// Horizontal padding to account for when usePaddingAwareExtent is true + /// Only used if usePaddingAwareExtent is true + final double horizontalPadding; + + const MediaGridSliver({ + super.key, + required this.items, + this.onRefresh, + this.collectionId, + this.onListRefresh, + this.padding = const EdgeInsets.fromLTRB(8, 0, 8, 8), + this.usePaddingAwareExtent = false, + this.horizontalPadding = 16, + }); + + @override + Widget build(BuildContext context) { + return SliverPadding( + padding: padding, + sliver: Consumer( + builder: (context, settingsProvider, child) { + return SliverGrid( + gridDelegate: MediaGridDelegate.createDelegate( + context: context, + density: settingsProvider.libraryDensity, + usePaddingAware: usePaddingAwareExtent, + horizontalPadding: horizontalPadding, + ), + delegate: SliverChildBuilderDelegate((context, index) { + final item = items[index]; + return MediaCard( + key: Key(item.ratingKey), + item: item, + onRefresh: onRefresh, + collectionId: collectionId, + onListRefresh: onListRefresh, + ); + }, childCount: items.length), + ); + }, + ), + ); + } +} diff --git a/lib/widgets/plex_optimized_image.dart b/lib/widgets/plex_optimized_image.dart index 16073700..4cc14e85 100644 --- a/lib/widgets/plex_optimized_image.dart +++ b/lib/widgets/plex_optimized_image.dart @@ -38,6 +38,60 @@ class PlexOptimizedImage extends StatelessWidget { this.imageType = ImageType.poster, }); + /// Named constructor for poster images with default fallback icon + const PlexOptimizedImage.poster({ + super.key, + required this.client, + required this.imagePath, + this.width, + this.height, + this.fit = BoxFit.cover, + this.filterQuality = FilterQuality.medium, + this.placeholder, + this.errorWidget, + this.fadeInDuration = const Duration(milliseconds: 300), + this.enableTranscoding = true, + this.cacheKey, + this.alignment = Alignment.center, + }) : fallbackIcon = Icons.movie, + imageType = ImageType.poster; + + /// Named constructor for episode thumbnails + const PlexOptimizedImage.thumb({ + super.key, + required this.client, + required this.imagePath, + this.width, + this.height, + this.fit = BoxFit.cover, + this.filterQuality = FilterQuality.medium, + this.placeholder, + this.errorWidget, + this.fadeInDuration = const Duration(milliseconds: 300), + this.enableTranscoding = true, + this.cacheKey, + this.alignment = Alignment.center, + }) : fallbackIcon = Icons.video_library, + imageType = ImageType.thumb; + + /// Named constructor for playlist images + const PlexOptimizedImage.playlist({ + super.key, + required this.client, + required this.imagePath, + this.width, + this.height, + this.fit = BoxFit.cover, + this.filterQuality = FilterQuality.medium, + this.placeholder, + this.errorWidget, + this.fadeInDuration = const Duration(milliseconds: 300), + this.enableTranscoding = true, + this.cacheKey, + this.alignment = Alignment.center, + }) : fallbackIcon = Icons.playlist_play, + imageType = ImageType.poster; + @override Widget build(BuildContext context) { double resolvedDimension( @@ -176,60 +230,3 @@ class PlexOptimizedImage extends StatelessWidget { return 'plex_optimized_${memWidth}x${memHeight}_$urlHash'; } } - -/// Specialized version for posters with default fallback icon -class PlexPosterImage extends PlexOptimizedImage { - const PlexPosterImage({ - super.key, - required super.client, - required super.imagePath, - super.width, - super.height, - super.fit = BoxFit.cover, - super.filterQuality = FilterQuality.medium, - super.placeholder, - super.errorWidget, - super.fadeInDuration = const Duration(milliseconds: 300), - super.enableTranscoding = true, - super.cacheKey, - super.alignment = Alignment.center, - }) : super(fallbackIcon: Icons.movie, imageType: ImageType.poster); -} - -/// Specialized version for episode thumbnails -class PlexThumbImage extends PlexOptimizedImage { - const PlexThumbImage({ - super.key, - required super.client, - required super.imagePath, - super.width, - super.height, - super.fit = BoxFit.cover, - super.filterQuality = FilterQuality.medium, - super.placeholder, - super.errorWidget, - super.fadeInDuration = const Duration(milliseconds: 300), - super.enableTranscoding = true, - super.cacheKey, - super.alignment = Alignment.center, - }) : super(fallbackIcon: Icons.video_library, imageType: ImageType.thumb); -} - -/// Specialized version for playlist images -class PlexPlaylistImage extends PlexOptimizedImage { - const PlexPlaylistImage({ - super.key, - required super.client, - required super.imagePath, - super.width, - super.height, - super.fit = BoxFit.cover, - super.filterQuality = FilterQuality.medium, - super.placeholder, - super.errorWidget, - super.fadeInDuration = const Duration(milliseconds: 300), - super.enableTranscoding = true, - super.cacheKey, - super.alignment = Alignment.center, - }) : super(fallbackIcon: Icons.playlist_play, imageType: ImageType.poster); -} diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index e471ace2..d7b98e21 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -13,6 +13,83 @@ import '../services/storage_service.dart'; import '../theme/theme_helper.dart'; import '../i18n/strings.g.dart'; +/// Reusable navigation rail item widget that handles focus, selection, and interaction +class NavigationRailItem extends StatelessWidget { + final IconData icon; + final IconData? selectedIcon; + final Widget label; + final bool isSelected; + final bool isFocused; + final VoidCallback onTap; + final FocusNode focusNode; + final bool autofocus; + final EdgeInsets padding; + final BorderRadius borderRadius; + final double iconSize; + + const NavigationRailItem({ + super.key, + required this.icon, + this.selectedIcon, + required this.label, + required this.isSelected, + required this.isFocused, + required this.onTap, + required this.focusNode, + this.autofocus = false, + this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + this.borderRadius = const BorderRadius.all(Radius.circular(12)), + this.iconSize = 22, + }); + + @override + Widget build(BuildContext context) { + final t = tokens(context); + + return Focus( + focusNode: focusNode, + autofocus: autofocus, + onKeyEvent: (node, event) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey.isSelectKey) { + onTap(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; + }, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: borderRadius, + child: Container( + padding: padding, + decoration: BoxDecoration( + color: isSelected + ? t.text.withValues(alpha: 0.1) + : isFocused + ? t.text.withValues(alpha: 0.08) + : null, + borderRadius: borderRadius, + ), + child: Row( + children: [ + Icon( + isSelected && selectedIcon != null ? selectedIcon! : icon, + size: iconSize, + color: isSelected ? t.text : t.textMuted, + ), + const SizedBox(width: 12), + Expanded(child: label), + ], + ), + ), + ), + ), + ); + } +} + /// Side navigation rail for Desktop and Android TV platforms class SideNavigationRail extends StatefulWidget { final int selectedIndex; @@ -61,18 +138,26 @@ class SideNavigationRailState extends State { _searchFocusNode = FocusNode(debugLabel: 'nav_search'); _settingsFocusNode = FocusNode(debugLabel: 'nav_settings'); - _homeFocusNode.addListener(() => _onFocusChange(_homeFocusNode, () { - setState(() => _isHomeFocused = _homeFocusNode.hasFocus); - })); - _librariesFocusNode.addListener(() => _onFocusChange(_librariesFocusNode, () { - setState(() => _isLibrariesFocused = _librariesFocusNode.hasFocus); - })); - _searchFocusNode.addListener(() => _onFocusChange(_searchFocusNode, () { - setState(() => _isSearchFocused = _searchFocusNode.hasFocus); - })); - _settingsFocusNode.addListener(() => _onFocusChange(_settingsFocusNode, () { - setState(() => _isSettingsFocused = _settingsFocusNode.hasFocus); - })); + _homeFocusNode.addListener( + () => _onFocusChange(_homeFocusNode, () { + setState(() => _isHomeFocused = _homeFocusNode.hasFocus); + }), + ); + _librariesFocusNode.addListener( + () => _onFocusChange(_librariesFocusNode, () { + setState(() => _isLibrariesFocused = _librariesFocusNode.hasFocus); + }), + ); + _searchFocusNode.addListener( + () => _onFocusChange(_searchFocusNode, () { + setState(() => _isSearchFocused = _searchFocusNode.hasFocus); + }), + ); + _settingsFocusNode.addListener( + () => _onFocusChange(_settingsFocusNode, () { + setState(() => _isSettingsFocused = _settingsFocusNode.hasFocus); + }), + ); _loadLibraries(); } @@ -95,24 +180,21 @@ class SideNavigationRailState extends State { /// Get or create a focus node for a library item FocusNode _getLibraryFocusNode(String globalKey) { - return _libraryFocusNodes.putIfAbsent( - globalKey, - () { - final node = FocusNode(debugLabel: 'nav_library_$globalKey'); - node.addListener(() { - if (mounted) { - setState(() { - if (node.hasFocus) { - _focusedLibraryKeys.add(globalKey); - } else { - _focusedLibraryKeys.remove(globalKey); - } - }); - } - }); - return node; - }, - ); + return _libraryFocusNodes.putIfAbsent(globalKey, () { + final node = FocusNode(debugLabel: 'nav_library_$globalKey'); + node.addListener(() { + if (mounted) { + setState(() { + if (node.hasFocus) { + _focusedLibraryKeys.add(globalKey); + } else { + _focusedLibraryKeys.remove(globalKey); + } + }); + } + }); + return node; + }); } /// Focus the currently selected nav item @@ -338,58 +420,28 @@ class SideNavigationRailState extends State { }) { final t = tokens(context); - return Focus( - focusNode: focusNode, - autofocus: autofocus, - onKeyEvent: (node, event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - if (event.logicalKey.isSelectKey) { - onTap(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: isSelected - ? t.text.withValues(alpha: 0.1) - : isFocused - ? t.text.withValues(alpha: 0.08) - : null, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - children: [ - Icon( - isSelected ? selectedIcon : icon, - size: 22, - color: isSelected ? t.text : t.textMuted, - ), - const SizedBox(width: 12), - Text( - label, - style: TextStyle( - fontSize: 14, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, - color: isSelected ? t.text : t.textMuted, - ), - ), - ], - ), - ), + return NavigationRailItem( + icon: icon, + selectedIcon: selectedIcon, + label: Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected ? t.text : t.textMuted, ), ), + isSelected: isSelected, + isFocused: isFocused, + onTap: onTap, + focusNode: focusNode, + autofocus: autofocus, ); } Widget _buildLibrariesSection(List visibleLibraries, dynamic t) { - final isLibrariesSelected = widget.selectedIndex == 1 && widget.selectedLibraryKey == null; + final isLibrariesSelected = + widget.selectedIndex == 1 && widget.selectedLibraryKey == null; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -417,13 +469,16 @@ class SideNavigationRailState extends State { }, borderRadius: BorderRadius.circular(12), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), decoration: BoxDecoration( color: isLibrariesSelected ? t.text.withValues(alpha: 0.1) : _isLibrariesFocused - ? t.text.withValues(alpha: 0.08) - : null, + ? t.text.withValues(alpha: 0.08) + : null, borderRadius: BorderRadius.circular(12), ), child: Row( @@ -444,12 +499,16 @@ class SideNavigationRailState extends State { fontWeight: widget.selectedIndex == 1 ? FontWeight.w600 : FontWeight.w400, - color: widget.selectedIndex == 1 ? t.text : t.textMuted, + color: widget.selectedIndex == 1 + ? t.text + : t.textMuted, ), ), ), Icon( - _librariesExpanded ? Icons.expand_less : Icons.expand_more, + _librariesExpanded + ? Icons.expand_less + : Icons.expand_more, size: 20, color: t.textMuted, ), @@ -527,82 +586,43 @@ class SideNavigationRailState extends State { final isFocused = _focusedLibraryKeys.contains(library.globalKey); final focusNode = _getLibraryFocusNode(library.globalKey); - return Focus( - focusNode: focusNode, - onKeyEvent: (node, event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - if (event.logicalKey.isSelectKey) { - widget.onLibrarySelected(library.globalKey); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => widget.onLibrarySelected(library.globalKey), - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.only( - left: 28, - right: 12, - top: 10, - bottom: 10, + return NavigationRailItem( + icon: _getLibraryIcon(library.type), + selectedIcon: _getLibraryIconFilled(library.type), + label: SizedBox( + height: 32, // Fixed height for consistent item sizing + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + library.title, + style: TextStyle( + fontSize: 13, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, + color: isSelected ? t.text : t.textMuted, + ), + overflow: TextOverflow.ellipsis, ), - decoration: BoxDecoration( - color: isSelected - ? t.text.withValues(alpha: 0.1) - : isFocused - ? t.text.withValues(alpha: 0.08) - : null, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon( - isSelected - ? _getLibraryIconFilled(library.type) - : _getLibraryIcon(library.type), - size: 18, - color: isSelected ? t.text : t.textMuted, + if (showServerName) + Text( + library.serverName!, + style: TextStyle( + fontSize: 9, + color: t.textMuted.withValues(alpha: 0.4), ), - const SizedBox(width: 10), - Expanded( - child: SizedBox( - height: 32, // Fixed height for consistent item sizing - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - library.title, - style: TextStyle( - fontSize: 13, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.w400, - color: isSelected ? t.text : t.textMuted, - ), - overflow: TextOverflow.ellipsis, - ), - if (showServerName) - Text( - library.serverName!, - style: TextStyle( - fontSize: 9, - color: t.textMuted.withValues(alpha: 0.4), - ), - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ), - ], - ), - ), + overflow: TextOverflow.ellipsis, + ), + ], ), ), + isSelected: isSelected, + isFocused: isFocused, + onTap: () => widget.onLibrarySelected(library.globalKey), + focusNode: focusNode, + padding: const EdgeInsets.only(left: 28, right: 12, top: 10, bottom: 10), + borderRadius: BorderRadius.circular(8), + iconSize: 18, ); } } diff --git a/lib/widgets/video_controls/helpers/track_filter_helper.dart b/lib/widgets/video_controls/helpers/track_filter_helper.dart new file mode 100644 index 00000000..be9a62a7 --- /dev/null +++ b/lib/widgets/video_controls/helpers/track_filter_helper.dart @@ -0,0 +1,48 @@ +import '../../../mpv/mpv.dart'; + +/// Helper class for filtering tracks to remove auto/no tracks +/// +/// This keeps track-filter rules in one place and eliminates duplication. +class TrackFilterHelper { + /// Filter out 'auto' and 'no' tracks from a list of audio tracks + static List filterAudioTracks(List tracks) { + return tracks + .where((track) => track.id != 'auto' && track.id != 'no') + .toList(); + } + + /// Filter out 'auto' and 'no' tracks from a list of subtitle tracks + static List filterSubtitleTracks(List tracks) { + return tracks + .where((track) => track.id != 'auto' && track.id != 'no') + .toList(); + } + + /// Generic method to filter tracks based on type + static List filterTracks(List tracks) { + if (T == AudioTrack) { + return filterAudioTracks(tracks as List) as List; + } else if (T == SubtitleTrack) { + return filterSubtitleTracks(tracks as List) as List; + } + return tracks; + } + + /// Extract and filter tracks from Tracks object + static List extractAndFilterTracks( + Tracks? tracks, + List Function(Tracks?) extractor, + ) { + return filterTracks(extractor(tracks)); + } + + /// Check if a track list has multiple tracks (excluding auto/no) + static bool hasMultipleTracks(List tracks) { + return filterTracks(tracks).length > 1; + } + + /// Check if a track list has any tracks (excluding auto/no) + static bool hasTracks(List tracks) { + return filterTracks(tracks).isNotEmpty; + } +} diff --git a/lib/widgets/video_controls/helpers/track_selection_helper.dart b/lib/widgets/video_controls/helpers/track_selection_helper.dart new file mode 100644 index 00000000..4afcd1fc --- /dev/null +++ b/lib/widgets/video_controls/helpers/track_selection_helper.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import '../../../mpv/mpv.dart'; + +/// Helper class for shared track selection logic +/// +/// Provides common utilities for empty states, "Off" handling, and selection logic +class TrackSelectionHelper { + /// Get the appropriate empty message based on track type + static String getEmptyMessage() { + if (T == SubtitleTrack) { + return 'No subtitles available'; + } else if (T == AudioTrack) { + return 'No audio tracks available'; + } + return 'No tracks available'; + } + + /// Build a centered empty state widget + static Widget buildEmptyState() { + return Center( + child: Text( + getEmptyMessage(), + style: const TextStyle(color: Colors.white70), + ), + ); + } + + /// Check if "Off" is selected for a track + static bool isOffSelected( + T? selectedTrack, + bool Function(T track)? isOffTrack, + ) { + return selectedTrack == null || (isOffTrack?.call(selectedTrack) ?? false); + } + + /// Get the track ID from a track object + static String getTrackId(T track) { + if (track is AudioTrack) { + return track.id; + } else if (track is SubtitleTrack) { + return track.id; + } + return ''; + } + + /// Build the "Off" list tile for track selection + static Widget buildOffTile({ + required bool isSelected, + required VoidCallback onTap, + }) { + return ListTile( + title: Text( + 'Off', + style: TextStyle(color: isSelected ? Colors.blue : Colors.white), + ), + trailing: isSelected ? const Icon(Icons.check, color: Colors.blue) : null, + onTap: onTap, + ); + } + + /// Build a track selection list tile + static Widget buildTrackTile({ + required String label, + required bool isSelected, + required VoidCallback onTap, + }) { + return ListTile( + title: Text( + label, + style: TextStyle(color: isSelected ? Colors.blue : Colors.white), + ), + trailing: isSelected ? const Icon(Icons.check, color: Colors.blue) : null, + onTap: onTap, + ); + } +} diff --git a/lib/widgets/video_controls/sheets/chapter_sheet.dart b/lib/widgets/video_controls/sheets/chapter_sheet.dart index ee5fb49b..4a2721cd 100644 --- a/lib/widgets/video_controls/sheets/chapter_sheet.dart +++ b/lib/widgets/video_controls/sheets/chapter_sheet.dart @@ -6,6 +6,7 @@ import '../../../models/plex_media_info.dart'; import '../../../utils/duration_formatter.dart'; import '../../../utils/provider_extensions.dart'; import 'base_video_control_sheet.dart'; +import 'video_control_sheet_launcher.dart'; import '../../plex_optimized_image.dart'; /// Bottom sheet for selecting chapters @@ -32,7 +33,7 @@ class ChapterSheet extends StatelessWidget { VoidCallback? onOpen, VoidCallback? onClose, }) { - BaseVideoControlSheet.showSheet( + VideoControlSheetLauncher.show( context: context, onOpen: onOpen, onClose: onClose, @@ -99,7 +100,7 @@ class ChapterSheet extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(4), - child: PlexThumbImage( + child: PlexOptimizedImage.thumb( client: _getClientForChapters(context), imagePath: chapter.thumb, width: 60, diff --git a/lib/widgets/video_controls/sheets/track_selection_sheet.dart b/lib/widgets/video_controls/sheets/track_selection_sheet.dart index 17989b14..854582c8 100644 --- a/lib/widgets/video_controls/sheets/track_selection_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_selection_sheet.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import '../../../mpv/mpv.dart'; import 'base_video_control_sheet.dart'; +import 'video_control_sheet_launcher.dart'; +import '../helpers/track_filter_helper.dart'; +import '../helpers/track_selection_helper.dart'; /// Generic track selection sheet for audio and subtitle tracks /// @@ -50,7 +53,7 @@ class TrackSelectionSheet extends StatelessWidget { VoidCallback? onOpen, VoidCallback? onClose, }) { - BaseVideoControlSheet.showSheet( + VideoControlSheetLauncher.show( context: context, onOpen: onOpen, onClose: onClose, @@ -70,15 +73,6 @@ class TrackSelectionSheet extends StatelessWidget { ); } - String _getEmptyMessage() { - if (T == SubtitleTrack) { - return 'No subtitles available'; - } else if (T == AudioTrack) { - return 'No audio tracks available'; - } - return 'No tracks available'; - } - @override Widget build(BuildContext context) { return StreamBuilder( @@ -86,26 +80,16 @@ class TrackSelectionSheet extends StatelessWidget { initialData: player.state.tracks, builder: (context, snapshot) { final tracks = snapshot.data; - final availableTracks = extractTracks(tracks).where((track) { - // Filter out 'auto' and 'no' tracks from the list - if (track is AudioTrack) { - return track.id != 'auto' && track.id != 'no'; - } else if (track is SubtitleTrack) { - return track.id != 'auto' && track.id != 'no'; - } - return true; - }).toList(); + final availableTracks = TrackFilterHelper.extractAndFilterTracks( + tracks, + extractTracks, + ); return BaseVideoControlSheet( title: title, icon: icon, child: availableTracks.isEmpty - ? Center( - child: Text( - _getEmptyMessage(), - style: const TextStyle(color: Colors.white70), - ), - ) + ? TrackSelectionHelper.buildEmptyState() : StreamBuilder( stream: player.streams.track, initialData: player.state.track, @@ -115,9 +99,10 @@ class TrackSelectionSheet extends StatelessWidget { final selectedTrack = getCurrentTrack(currentTrack); // Determine if "Off" is selected (null or explicit off) - final isOffSelected = - selectedTrack == null || - (isOffTrack?.call(selectedTrack) ?? false); + final isOffSelected = TrackSelectionHelper.isOffSelected( + selectedTrack, + isOffTrack, + ); final itemCount = availableTracks.length + (showOffOption ? 1 : 0); @@ -127,18 +112,8 @@ class TrackSelectionSheet extends StatelessWidget { itemBuilder: (context, index) { // First item is "Off" if enabled if (showOffOption && index == 0) { - return ListTile( - title: Text( - 'Off', - style: TextStyle( - color: isOffSelected - ? Colors.blue - : Colors.white, - ), - ), - trailing: isOffSelected - ? const Icon(Icons.check, color: Colors.blue) - : null, + return TrackSelectionHelper.buildOffTile( + isSelected: isOffSelected, onTap: () { if (createOffTrack != null) { final offTrack = createOffTrack!(); @@ -155,39 +130,17 @@ class TrackSelectionSheet extends StatelessWidget { final track = availableTracks[trackIndex]; // Check if this track is selected - String trackId; - if (track is AudioTrack) { - trackId = track.id; - } else if (track is SubtitleTrack) { - trackId = track.id; - } else { - trackId = ''; - } - - String selectedId; - if (selectedTrack == null) { - selectedId = ''; - } else if (selectedTrack is AudioTrack) { - selectedId = selectedTrack.id; - } else if (selectedTrack is SubtitleTrack) { - selectedId = selectedTrack.id; - } else { - selectedId = ''; - } + final trackId = TrackSelectionHelper.getTrackId(track); + final selectedId = selectedTrack == null + ? '' + : TrackSelectionHelper.getTrackId(selectedTrack); final isSelected = trackId == selectedId; final label = buildLabel(track, trackIndex); - return ListTile( - title: Text( - label, - style: TextStyle( - color: isSelected ? Colors.blue : Colors.white, - ), - ), - trailing: isSelected - ? const Icon(Icons.check, color: Colors.blue) - : null, + return TrackSelectionHelper.buildTrackTile( + label: label, + isSelected: isSelected, onTap: () { setTrack(track); onTrackChanged?.call(track); diff --git a/lib/widgets/video_controls/sheets/version_sheet.dart b/lib/widgets/video_controls/sheets/version_sheet.dart index 8caf23f6..7251450e 100644 --- a/lib/widgets/video_controls/sheets/version_sheet.dart +++ b/lib/widgets/video_controls/sheets/version_sheet.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../../models/plex_media_version.dart'; import 'base_video_control_sheet.dart'; +import 'video_control_sheet_launcher.dart'; /// Bottom sheet for selecting video version class VersionSheet extends StatelessWidget { @@ -23,7 +24,7 @@ class VersionSheet extends StatelessWidget { VoidCallback? onOpen, VoidCallback? onClose, }) { - BaseVideoControlSheet.showSheet( + VideoControlSheetLauncher.show( context: context, onOpen: onOpen, onClose: onClose, diff --git a/lib/widgets/video_controls/sheets/video_control_sheet_launcher.dart b/lib/widgets/video_controls/sheets/video_control_sheet_launcher.dart new file mode 100644 index 00000000..dedc4edf --- /dev/null +++ b/lib/widgets/video_controls/sheets/video_control_sheet_launcher.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'base_video_control_sheet.dart'; + +/// Helper class to launch video control sheets with consistent behavior +/// +/// This eliminates the need for each sheet to duplicate the showSheet wrapper. +class VideoControlSheetLauncher { + /// Show a video control sheet with consistent styling and callbacks + static Future show({ + required BuildContext context, + required WidgetBuilder builder, + VoidCallback? onOpen, + VoidCallback? onClose, + }) { + return BaseVideoControlSheet.showSheet( + context: context, + onOpen: onOpen, + onClose: onClose, + builder: builder, + ); + } +} diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 12e6a94c..6a02a8a5 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -10,7 +10,6 @@ import '../widgets/sync_offset_control.dart'; import '../widgets/sleep_timer_content.dart'; import '../../../i18n/strings.g.dart'; import 'base_video_control_sheet.dart'; -import 'video_sheet_header.dart'; enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync, audioDevice } @@ -80,20 +79,16 @@ class VideoSettingsSheet extends StatefulWidget { VoidCallback? onOpen, VoidCallback? onClose, }) { - onOpen?.call(); - return showModalBottomSheet( + return BaseVideoControlSheet.showSheet( context: context, - backgroundColor: Colors.grey[900], - isScrollControlled: true, - constraints: BaseVideoControlSheet.getBottomSheetConstraints(context), + onOpen: onOpen, + onClose: onClose, builder: (context) => VideoSettingsSheet( player: player, audioSyncOffset: audioSyncOffset, subtitleSyncOffset: subtitleSyncOffset, ), - ).whenComplete(() { - onClose?.call(); - }); + ); } @override @@ -204,22 +199,6 @@ class _VideoSettingsSheetState extends State { } } - Widget _buildHeader() { - final sleepTimer = SleepTimerService(); - final isIconActive = - _currentView == _SettingsView.menu && - (sleepTimer.isActive || - _audioSyncOffset != 0 || - _subtitleSyncOffset != 0); - - return VideoSheetHeader( - title: _getTitle(), - icon: _getIcon(), - iconColor: isIconActive ? Colors.amber : Colors.white, - onBack: _currentView != _SettingsView.menu ? _navigateBack : null, - ); - } - Widget _buildMenuView() { final sleepTimer = SleepTimerService(); final isDesktop = PlatformDetector.isDesktop(context); @@ -443,34 +422,34 @@ class _VideoSettingsSheetState extends State { @override Widget build(BuildContext context) { - return SafeArea( - child: SizedBox( - height: MediaQuery.of(context).size.height * 0.75, - child: Column( - children: [ - _buildHeader(), - const Divider(color: Colors.white24, height: 1), - Expanded( - child: () { - switch (_currentView) { - case _SettingsView.menu: - return _buildMenuView(); - case _SettingsView.speed: - return _buildSpeedView(); - case _SettingsView.sleep: - return _buildSleepView(); - case _SettingsView.audioSync: - return _buildAudioSyncView(); - case _SettingsView.subtitleSync: - return _buildSubtitleSyncView(); - case _SettingsView.audioDevice: - return _buildAudioDeviceView(); - } - }(), - ), - ], - ), - ), + final sleepTimer = SleepTimerService(); + final isIconActive = + _currentView == _SettingsView.menu && + (sleepTimer.isActive || + _audioSyncOffset != 0 || + _subtitleSyncOffset != 0); + + return BaseVideoControlSheet( + title: _getTitle(), + icon: _getIcon(), + iconColor: isIconActive ? Colors.amber : Colors.white, + onBack: _currentView != _SettingsView.menu ? _navigateBack : null, + child: () { + switch (_currentView) { + case _SettingsView.menu: + return _buildMenuView(); + case _SettingsView.speed: + return _buildSpeedView(); + case _SettingsView.sleep: + return _buildSleepView(); + case _SettingsView.audioSync: + return _buildAudioSyncView(); + case _SettingsView.subtitleSync: + return _buildSubtitleSyncView(); + case _SettingsView.audioDevice: + return _buildAudioDeviceView(); + } + }(), ); } } diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 91d99c9d..0065a296 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -13,6 +13,7 @@ import '../sheets/chapter_sheet.dart'; import '../sheets/subtitle_track_sheet.dart'; import '../sheets/version_sheet.dart'; import '../sheets/video_settings_sheet.dart'; +import '../helpers/track_filter_helper.dart'; import '../video_control_button.dart'; /// Row of track and chapter control buttons for the video player @@ -191,18 +192,12 @@ class TrackChapterControls extends StatelessWidget { bool _hasMultipleAudioTracks(Tracks? tracks) { if (tracks == null) return false; - final audioTracks = tracks.audio - .where((track) => track.id != 'auto' && track.id != 'no') - .toList(); - return audioTracks.length > 1; + return TrackFilterHelper.hasMultipleTracks(tracks.audio); } bool _hasSubtitles(Tracks? tracks) { if (tracks == null) return false; - final subtitles = tracks.subtitle - .where((track) => track.id != 'auto' && track.id != 'no') - .toList(); - return subtitles.isNotEmpty; + return TrackFilterHelper.hasTracks(tracks.subtitle); } IconData _getBoxFitIcon(int mode) {