refactor: deduplicate

This commit is contained in:
edde746
2025-12-07 15:00:28 +01:00
parent 223065cab0
commit d9ea099727
58 changed files with 3376 additions and 3061 deletions
+1 -5
View File
@@ -37,11 +37,7 @@ class DpadNavigator extends StatelessWidget {
return child; return child;
} }
return Focus( return Focus(autofocus: true, onKeyEvent: _handleKeyEvent, child: child);
autofocus: true,
onKeyEvent: _handleKeyEvent,
child: child,
);
} }
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
+63
View File
@@ -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<YourWidget>` 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<T extends StatefulWidget> on State<T> {
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);
}
}
}
-1
View File
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'focusable_wrapper.dart'; import 'focusable_wrapper.dart';
import 'focus_theme.dart';
/// A focusable list item for settings screens and menus. /// A focusable list item for settings screens and menus.
/// ///
+3 -6
View File
@@ -31,7 +31,7 @@ class FocusableScrollSection extends StatefulWidget {
/// Builder for each focusable item. /// Builder for each focusable item.
/// The [focusNode] should be passed to a FocusableWrapper or Focus widget. /// The [focusNode] should be passed to a FocusableWrapper or Focus widget.
final Widget Function(BuildContext context, int index, FocusNode focusNode) final Widget Function(BuildContext context, int index, FocusNode focusNode)
itemBuilder; itemBuilder;
/// Optional scroll controller for external control. /// Optional scroll controller for external control.
final ScrollController? scrollController; final ScrollController? scrollController;
@@ -108,9 +108,7 @@ class _FocusableScrollSectionState extends State<FocusableScrollSection> {
void _createFocusNodes() { void _createFocusNodes() {
_disposeFocusNodes(); _disposeFocusNodes();
for (int i = 0; i < widget.itemCount; i++) { for (int i = 0; i < widget.itemCount; i++) {
final node = FocusNode( final node = FocusNode(debugLabel: '${widget.sectionId}_item_$i');
debugLabel: '${widget.sectionId}_item_$i',
);
node.addListener(() => _handleItemFocusChange(i, node.hasFocus)); node.addListener(() => _handleItemFocusChange(i, node.hasFocus));
_itemFocusNodes.add(node); _itemFocusNodes.add(node);
} }
@@ -180,8 +178,7 @@ class _FocusableScrollSectionState extends State<FocusableScrollSection> {
// Check if section lost focus entirely // Check if section lost focus entirely
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
final stillHasFocus = final stillHasFocus = _itemFocusNodes.any((node) => node.hasFocus);
_itemFocusNodes.any((node) => node.hasFocus);
if (_hasFocus && !stillHasFocus) { if (_hasFocus && !stillHasFocus) {
_hasFocus = false; _hasFocus = false;
widget.onSectionBlurred?.call(); widget.onSectionBlurred?.call();
+139 -16
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'focus_theme.dart'; import 'focus_theme.dart';
@@ -9,19 +11,29 @@ import 'input_mode_tracker.dart';
/// - Visual focus indicator (border + scale animation) /// - Visual focus indicator (border + scale animation)
/// - Keyboard/D-pad event handling (Enter/Select to activate) /// - Keyboard/D-pad event handling (Enter/Select to activate)
/// - Optional auto-scroll to keep focused item visible /// - Optional auto-scroll to keep focused item visible
/// - Long-press detection for SELECT key
/// - Navigation callbacks (UP, BACK)
class FocusableWrapper extends StatefulWidget { class FocusableWrapper extends StatefulWidget {
/// The child widget to wrap. /// The child widget to wrap.
final Widget child; final Widget child;
/// Called when the item is selected (Enter/Select/GamepadA). /// Called when the item is selected (Enter/Select/GamepadA).
/// For short press when [enableLongPress] is true.
final VoidCallback? onSelect; 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; final VoidCallback? onLongPress;
/// Called when focus changes. /// Called when focus changes.
final ValueChanged<bool>? onFocusChange; final ValueChanged<bool>? 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. /// Whether this widget should request focus when first built.
final bool autofocus; final bool autofocus;
@@ -37,6 +49,10 @@ class FocusableWrapper extends StatefulWidget {
/// Alignment for auto-scroll (0.0 = start, 0.5 = center, 1.0 = end). /// Alignment for auto-scroll (0.0 = start, 0.5 = center, 1.0 = end).
final double scrollAlignment; 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. /// Optional semantic label for accessibility.
final String? semanticLabel; final String? semanticLabel;
@@ -47,20 +63,33 @@ class FocusableWrapper extends StatefulWidget {
/// This is called before the default key handling. /// This is called before the default key handling.
final KeyEventResult Function(FocusNode node, KeyEvent event)? onKeyEvent; 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({ const FocusableWrapper({
super.key, super.key,
required this.child, required this.child,
this.onSelect, this.onSelect,
this.onLongPress, this.onLongPress,
this.onFocusChange, this.onFocusChange,
this.onNavigateUp,
this.onBack,
this.autofocus = false, this.autofocus = false,
this.focusNode, this.focusNode,
this.borderRadius = FocusTheme.defaultBorderRadius, this.borderRadius = FocusTheme.defaultBorderRadius,
this.autoScroll = true, this.autoScroll = true,
this.scrollAlignment = 0.5, this.scrollAlignment = 0.5,
this.useComfortableZone = false,
this.semanticLabel, this.semanticLabel,
this.canRequestFocus = true, this.canRequestFocus = true,
this.onKeyEvent, this.onKeyEvent,
this.enableLongPress = false,
this.longPressDuration = const Duration(milliseconds: 500),
}); });
@override @override
@@ -76,6 +105,10 @@ class _FocusableWrapperState extends State<FocusableWrapper>
late AnimationController _animationController; late AnimationController _animationController;
late Animation<double> _scaleAnimation; late Animation<double> _scaleAnimation;
// Long-press detection for SELECT key
Timer? _longPressTimer;
bool _isSelectKeyDown = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@@ -102,13 +135,13 @@ class _FocusableWrapperState extends State<FocusableWrapper>
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
); );
_scaleAnimation = Tween<double>( _scaleAnimation = Tween<double>(begin: 1.0, end: FocusTheme.focusScale)
begin: 1.0, .animate(
end: FocusTheme.focusScale, CurvedAnimation(
).animate(CurvedAnimation( parent: _animationController,
parent: _animationController, curve: Curves.easeOutCubic,
curve: Curves.easeOutCubic, ),
)); );
} }
@override @override
@@ -131,6 +164,7 @@ class _FocusableWrapperState extends State<FocusableWrapper>
@override @override
void dispose() { void dispose() {
_longPressTimer?.cancel();
_animationController.dispose(); _animationController.dispose();
if (_ownsNode) { if (_ownsNode) {
_focusNode.dispose(); _focusNode.dispose();
@@ -163,11 +197,43 @@ class _FocusableWrapperState extends State<FocusableWrapper>
void _scrollIntoView() { void _scrollIntoView() {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted || !_isFocused) return;
final renderObject = context.findRenderObject(); final renderObject = context.findRenderObject();
if (renderObject == null) return; 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( Scrollable.ensureVisible(
context, context,
alignment: widget.scrollAlignment, alignment: widget.scrollAlignment,
@@ -178,7 +244,7 @@ class _FocusableWrapperState extends State<FocusableWrapper>
} }
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored; final key = event.logicalKey;
// Call custom key handler first // Call custom key handler first
if (widget.onKeyEvent != null) { if (widget.onKeyEvent != null) {
@@ -188,15 +254,65 @@ class _FocusableWrapperState extends State<FocusableWrapper>
} }
} }
// Handle Select/Enter for activation // Handle SELECT key with optional long-press detection
if (_isSelectKey(event.logicalKey)) { if (_isSelectKey(key)) {
widget.onSelect?.call(); 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; return KeyEventResult.handled;
} }
// Handle context menu key // UP arrow - if callback provided, navigate up
if (_isContextMenuKey(event.logicalKey)) { if (key == LogicalKeyboardKey.arrowUp && widget.onNavigateUp != null) {
widget.onLongPress?.call(); widget.onNavigateUp!();
return KeyEventResult.handled;
}
// BACK key
if (_isBackKey(key) && widget.onBack != null) {
widget.onBack!();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -215,6 +331,13 @@ class _FocusableWrapperState extends State<FocusableWrapper>
key == LogicalKeyboardKey.gameButtonX; key == LogicalKeyboardKey.gameButtonX;
} }
bool _isBackKey(LogicalKeyboardKey key) {
return key == LogicalKeyboardKey.escape ||
key == LogicalKeyboardKey.goBack ||
key == LogicalKeyboardKey.browserBack ||
key == LogicalKeyboardKey.gameButtonB;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final duration = FocusTheme.getAnimationDuration(context); final duration = FocusTheme.getAnimationDuration(context);
+7 -12
View File
@@ -28,8 +28,8 @@ class InputModeTracker extends StatefulWidget {
/// Get the current input mode. /// Get the current input mode.
static InputMode of(BuildContext context) { static InputMode of(BuildContext context) {
final provider = final provider = context
context.dependOnInheritedWidgetOfExactType<_InputModeProvider>(); .dependOnInheritedWidgetOfExactType<_InputModeProvider>();
return provider?.mode ?? InputMode.pointer; return provider?.mode ?? InputMode.pointer;
} }
@@ -44,8 +44,9 @@ class InputModeTracker extends StatefulWidget {
class _InputModeTrackerState extends State<InputModeTracker> { class _InputModeTrackerState extends State<InputModeTracker> {
// Default to keyboard mode on Android TV, pointer mode elsewhere // Default to keyboard mode on Android TV, pointer mode elsewhere
InputMode _mode = InputMode _mode = TvDetectionService.isTVSync()
TvDetectionService.isTVSync() ? InputMode.keyboard : InputMode.pointer; ? InputMode.keyboard
: InputMode.pointer;
@override @override
void initState() { void initState() {
@@ -82,10 +83,7 @@ class _InputModeTrackerState extends State<InputModeTracker> {
onPointerDown: (_) => _setMode(InputMode.pointer), onPointerDown: (_) => _setMode(InputMode.pointer),
onPointerHover: (_) => _setMode(InputMode.pointer), onPointerHover: (_) => _setMode(InputMode.pointer),
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
child: _InputModeProvider( child: _InputModeProvider(mode: _mode, child: widget.child),
mode: _mode,
child: widget.child,
),
); );
} }
} }
@@ -94,10 +92,7 @@ class _InputModeTrackerState extends State<InputModeTracker> {
class _InputModeProvider extends InheritedWidget { class _InputModeProvider extends InheritedWidget {
final InputMode mode; final InputMode mode;
const _InputModeProvider({ const _InputModeProvider({required this.mode, required super.child});
required this.mode,
required super.child,
});
@override @override
bool updateShouldNotify(_InputModeProvider oldWidget) { bool updateShouldNotify(_InputModeProvider oldWidget) {
+41
View File
@@ -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<T>(
BuildContext context,
KeyEvent event, {
T? result,
}) {
if (event is KeyDownEvent && event.logicalKey.isBackKey) {
Navigator.pop(context, result);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
+2 -1
View File
@@ -86,7 +86,8 @@ class LockedHubController extends ChangeNotifier {
if (!scrollController.hasClients) return; if (!scrollController.hasClients) return;
final viewport = scrollController.position.viewportDimension; 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( final desiredOffset = (targetCenter - (viewport / 2)).clamp(
0.0, 0.0,
scrollController.position.maxScrollExtent, scrollController.position.maxScrollExtent,
+34
View File
@@ -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<T extends StatefulWidget> on State<T> {
/// 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();
}
}
}
+52 -82
View File
@@ -330,37 +330,10 @@ class _AuthScreenState extends State<AuthScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (_isAuthenticating) ...[ if (_isAuthenticating) ...[
if (_useQrFlow && _qrAuthUrl != null) ...[ if (_useQrFlow && _qrAuthUrl != null)
// QR code flow - hint text above QR code _buildQrAuthWidget(qrSize: 300)
Text( else
t.auth.scanQRCodeInstruction, _buildBrowserAuthWidget(),
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(),
],
] else ...[ ] else ...[
// Initial state buttons // Initial state buttons
ElevatedButton( ElevatedButton(
@@ -436,57 +409,10 @@ class _AuthScreenState extends State<AuthScreen> {
), ),
const SizedBox(height: 48), const SizedBox(height: 48),
if (_isAuthenticating) ...[ if (_isAuthenticating) ...[
if (_useQrFlow && _qrAuthUrl != null) ...[ if (_useQrFlow && _qrAuthUrl != null)
// QR code flow - show QR code and scan instruction _buildQrAuthWidget(qrSize: 200)
Text( else
t.auth.scanQRCodeInstruction, _buildBrowserAuthWidget(),
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),
),
],
] else ...[ ] else ...[
// add QR button here // add QR button here
ElevatedButton( ElevatedButton(
@@ -560,4 +486,48 @@ class _AuthScreenState extends State<AuthScreen> {
], ],
); );
} }
/// 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(),
],
);
}
} }
+15 -59
View File
@@ -1,13 +1,8 @@
import 'package:flutter/material.dart'; 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 '../models/plex_metadata.dart';
import '../providers/settings_provider.dart'; import '../widgets/media_grid_sliver.dart';
import '../widgets/media_card.dart'; import '../widgets/focused_scroll_scaffold.dart';
import '../widgets/desktop_app_bar.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../utils/grid_size_calculator.dart';
import '../utils/dialogs.dart'; import '../utils/dialogs.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import 'base_media_list_detail_screen.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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Focus( return FocusedScrollScaffold(
autofocus: true, title: Text(widget.collection.title),
onKeyEvent: _handleKeyEvent, actions: buildAppBarActions(onDelete: _deleteCollection),
child: Scaffold( slivers: [
body: CustomScrollView( ...buildStateSlivers(),
slivers: [ if (items.isNotEmpty)
CustomAppBar( MediaGridSliver(
title: Text(widget.collection.title), items: items,
pinned: true, onRefresh: updateItem,
actions: buildAppBarActions(onDelete: _deleteCollection), 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<SettingsProvider>(
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),
);
},
),
),
],
),
),
); );
} }
} }
+120 -117
View File
@@ -747,8 +747,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
/// Show user menu programmatically (for D-pad select) /// Show user menu programmatically (for D-pad select)
void _showUserMenu(BuildContext context, UserProfileProvider userProvider) { void _showUserMenu(BuildContext context, UserProfileProvider userProvider) {
final RenderBox? button = _userButtonFocusNode.context final RenderBox? button =
?.findRenderObject() as RenderBox?; _userButtonFocusNode.context?.findRenderObject() as RenderBox?;
if (button == null) return; if (button == null) return;
final RenderBox overlay = final RenderBox overlay =
@@ -821,7 +821,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: _isRefreshFocused color: _isRefreshFocused
? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08) ? Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.08)
: Colors.transparent, : Colors.transparent,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
@@ -839,7 +841,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: _isUserFocused color: _isUserFocused
? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08) ? Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.08)
: Colors.transparent, : Colors.transparent,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
@@ -919,8 +923,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
// Hero Section (Continue Watching) // Hero Section (Continue Watching)
Consumer<SettingsProvider>( Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) { builder: (context, settingsProvider, child) {
if (_onDeck.isNotEmpty && if (_onDeck.isNotEmpty && settingsProvider.showHeroSection) {
settingsProvider.showHeroSection) {
return _buildHeroSection(); return _buildHeroSection();
} }
return const SliverToBoxAdapter(child: SizedBox.shrink()); return const SliverToBoxAdapter(child: SizedBox.shrink());
@@ -1054,124 +1057,124 @@ class _DiscoverScreenState extends State<DiscoverScreen>
child: Stack( child: Stack(
children: [ children: [
PageView.builder( PageView.builder(
controller: _heroController, controller: _heroController,
itemCount: _onDeck.length, itemCount: _onDeck.length,
onPageChanged: (index) { onPageChanged: (index) {
// Validate index is within bounds before updating // Validate index is within bounds before updating
if (index >= 0 && index < _onDeck.length) { if (index >= 0 && index < _onDeck.length) {
setState(() { setState(() {
_currentHeroIndex = index; _currentHeroIndex = index;
}); });
_resetAutoScrollTimer(); _resetAutoScrollTimer();
} }
}, },
itemBuilder: (context, index) { itemBuilder: (context, index) {
return _buildHeroItem(_onDeck[index]); return _buildHeroItem(_onDeck[index]);
}, },
), ),
// Page indicators with animated progress and pause/play button // Page indicators with animated progress and pause/play button
Positioned( Positioned(
bottom: 16, bottom: 16,
left: -26, left: -26,
right: 0, right: 0,
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Pause/Play button // Pause/Play button
GestureDetector( GestureDetector(
onTap: () { onTap: () {
if (_isAutoScrollPaused) { if (_isAutoScrollPaused) {
_resumeAutoScroll(); _resumeAutoScroll();
} else { } else {
_pauseAutoScroll(); _pauseAutoScroll();
} }
}, },
child: Icon( child: Icon(
_isAutoScrollPaused ? Icons.play_arrow : Icons.pause, _isAutoScrollPaused ? Icons.play_arrow : Icons.pause,
color: Colors.white, color: Colors.white,
size: 18, size: 18,
semanticLabel: semanticLabel:
'${_isAutoScrollPaused ? t.discover.play : t.discover.pause} auto-scroll', '${_isAutoScrollPaused ? t.discover.play : t.discover.pause} auto-scroll',
),
), ),
), // Spacer to separate indicators from button
// Spacer to separate indicators from button const SizedBox(width: 8),
const SizedBox(width: 8), // Page indicators (limited to 5 dots)
// Page indicators (limited to 5 dots) ...() {
...() { final range = _getVisibleDotRange();
final range = _getVisibleDotRange(); return List.generate(range.end - range.start + 1, (i) {
return List.generate(range.end - range.start + 1, (i) { final index = range.start + i;
final index = range.start + i; final isActive = _currentHeroIndex == index;
final isActive = _currentHeroIndex == index; final dotSize = _getDotSize(
final dotSize = _getDotSize( index,
index, range.start,
range.start, range.end,
range.end, );
);
if (isActive) { if (isActive) {
// Animated progress indicator for active page // Animated progress indicator for active page
return AnimatedBuilder( return AnimatedBuilder(
animation: _indicatorAnimationController, animation: _indicatorAnimationController,
builder: (context, child) { builder: (context, child) {
// Fill width animates based on dot size // Fill width animates based on dot size
final maxWidth = final maxWidth =
dotSize * dotSize *
3; // 24px for normal, 15px for small 3; // 24px for normal, 15px for small
final fillWidth = final fillWidth =
dotSize + dotSize +
((maxWidth - dotSize) * ((maxWidth - dotSize) *
_indicatorAnimationController.value); _indicatorAnimationController.value);
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut, curve: Curves.easeInOut,
margin: const EdgeInsets.symmetric( margin: const EdgeInsets.symmetric(
horizontal: 4, horizontal: 4,
),
width: maxWidth,
height: dotSize,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(
dotSize / 2,
), ),
), width: maxWidth,
child: Align( height: dotSize,
alignment: Alignment.centerLeft, decoration: BoxDecoration(
child: Container( color: Colors.white.withValues(alpha: 0.4),
width: fillWidth, borderRadius: BorderRadius.circular(
height: dotSize, dotSize / 2,
decoration: BoxDecoration( ),
color: Colors.white, ),
borderRadius: BorderRadius.circular( child: Align(
dotSize / 2, alignment: Alignment.centerLeft,
child: Container(
width: fillWidth,
height: dotSize,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(
dotSize / 2,
),
), ),
), ),
), ),
), );
); },
}, );
); } else {
} else { // Static indicator for inactive pages
// Static indicator for inactive pages return AnimatedContainer(
return AnimatedContainer( duration: const Duration(milliseconds: 300),
duration: const Duration(milliseconds: 300), curve: Curves.easeInOut,
curve: Curves.easeInOut, margin: const EdgeInsets.symmetric(horizontal: 4),
margin: const EdgeInsets.symmetric(horizontal: 4), width: dotSize,
width: dotSize, height: dotSize,
height: dotSize, decoration: BoxDecoration(
decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.4),
color: Colors.white.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(dotSize / 2),
borderRadius: BorderRadius.circular(dotSize / 2), ),
), );
); }
} });
}); }(),
}(), ],
], ),
), ),
), ],
], ),
),
), ),
), ),
); );
+42 -83
View File
@@ -1,17 +1,12 @@
import 'package:flutter/material.dart'; 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 '../../services/plex_client.dart';
import '../models/plex_hub.dart'; import '../models/plex_hub.dart';
import '../models/plex_metadata.dart'; import '../models/plex_metadata.dart';
import '../models/plex_sort.dart'; import '../models/plex_sort.dart';
import '../providers/settings_provider.dart';
import '../utils/provider_extensions.dart'; import '../utils/provider_extensions.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/grid_cross_axis_extent.dart'; import '../widgets/media_grid_sliver.dart';
import '../widgets/media_card.dart'; import '../widgets/focused_scroll_scaffold.dart';
import '../widgets/desktop_app_bar.dart';
import 'libraries/sort_bottom_sheet.dart'; import 'libraries/sort_bottom_sheet.dart';
import '../mixins/refreshable.dart'; import '../mixins/refreshable.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
@@ -242,87 +237,51 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
_loadMoreItems(); _loadMoreItems();
} }
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is KeyDownEvent && event.logicalKey.isBackKey) {
Navigator.pop(context);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Focus( return FocusedScrollScaffold(
autofocus: true, title: Text(widget.hub.title),
onKeyEvent: _handleKeyEvent, actions: [
child: Scaffold( IconButton(
body: CustomScrollView( icon: Icon(Icons.swap_vert, semanticLabel: t.libraries.sort),
slivers: [ onPressed: _showSortBottomSheet,
CustomAppBar( ),
title: Text(widget.hub.title), ],
pinned: true, slivers: [
actions: [ if (_errorMessage != null)
IconButton( SliverFillRemaining(
icon: Icon(Icons.swap_vert, semanticLabel: t.libraries.sort), child: Center(
onPressed: _showSortBottomSheet, child: Column(
), mainAxisAlignment: MainAxisAlignment.center,
], children: [
), const Icon(Icons.error_outline, size: 48, color: Colors.red),
if (_errorMessage != null) const SizedBox(height: 16),
SliverFillRemaining( Text(_errorMessage!),
child: Center( const SizedBox(height: 16),
child: Column( ElevatedButton(
mainAxisAlignment: MainAxisAlignment.center, onPressed: _loadMoreItems,
children: [ child: Text(t.common.retry),
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<SettingsProvider>().libraryDensity,
16,
), ),
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,
),
],
); );
} }
} }
+48 -47
View File
@@ -1,17 +1,20 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../models/plex_metadata.dart';
import '../../providers/settings_provider.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 '../../utils/grid_size_calculator.dart';
import '../../widgets/focusable_media_card.dart';
/// A widget that automatically switches between grid and list view /// A widget that automatically switches between grid and list view
/// based on user settings, providing a consistent layout pattern /// based on user settings, providing a consistent layout pattern
/// across all library screens /// across all library screens.
class AdaptiveMediaGrid extends StatelessWidget { ///
/// The list of media items to display /// Generic type T: The type of items being displayed
final List<PlexMetadata> items; class AdaptiveMediaGrid<T> extends StatelessWidget {
/// The list of items to display
final List<T> 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 /// Callback when the list needs to be refreshed
final VoidCallback? onRefresh; final VoidCallback? onRefresh;
@@ -31,6 +34,7 @@ class AdaptiveMediaGrid extends StatelessWidget {
const AdaptiveMediaGrid({ const AdaptiveMediaGrid({
super.key, super.key,
required this.items, required this.items,
required this.itemBuilder,
this.onRefresh, this.onRefresh,
this.padding = const EdgeInsets.fromLTRB(8, 8, 8, 8), this.padding = const EdgeInsets.fromLTRB(8, 8, 8, 8),
this.childAspectRatio = 2 / 3.3, this.childAspectRatio = 2 / 3.3,
@@ -42,47 +46,44 @@ class AdaptiveMediaGrid extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Consumer<SettingsProvider>( return Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) { builder: (context, settingsProvider, child) {
if (settingsProvider.viewMode == ViewMode.list) { return _buildItemsView(
return ListView.builder( context,
padding: padding, settingsProvider.viewMode,
itemCount: items.length, settingsProvider.libraryDensity,
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,
);
},
);
}
}, },
); );
} }
/// 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),
);
}
}
} }
+2 -31
View File
@@ -1,10 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../models/plex_metadata.dart'; import '../../models/plex_metadata.dart';
import '../media_detail_screen.dart';
import '../season_detail_screen.dart';
import '../../utils/app_logger.dart'; import '../../utils/app_logger.dart';
import '../../utils/media_navigation_helper.dart';
import '../../utils/provider_extensions.dart'; import '../../utils/provider_extensions.dart';
import '../../utils/video_player_navigation.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import 'folder_tree_item.dart'; import 'folder_tree_item.dart';
import 'empty_state_widget.dart'; import 'empty_state_widget.dart';
@@ -151,34 +149,7 @@ class _FolderTreeViewState extends State<FolderTreeView> {
} }
Future<void> _handleItemTap(PlexMetadata item) async { Future<void> _handleItemTap(PlexMetadata item) async {
final itemType = item.type.toLowerCase(); await navigateToMediaItem(context, item, onRefresh: widget.onRefresh);
// 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<bool>(
context,
MaterialPageRoute(
builder: (context) => MediaDetailScreen(metadata: item),
),
);
if (result == true) {
widget.onRefresh?.call(item.ratingKey);
}
}
} }
bool _isFolder(PlexMetadata item) { bool _isFolder(PlexMetadata item) {
+36 -65
View File
@@ -20,7 +20,6 @@ import 'context_menu_wrapper.dart';
import '../../services/storage_service.dart'; import '../../services/storage_service.dart';
import '../../mixins/refreshable.dart'; import '../../mixins/refreshable.dart';
import '../../mixins/item_updatable.dart'; import '../../mixins/item_updatable.dart';
import '../../theme/theme_helper.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import '../../utils/error_message_utils.dart'; import '../../utils/error_message_utils.dart';
import 'tabs/library_browse_tab.dart'; import 'tabs/library_browse_tab.dart';
@@ -66,7 +65,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
String? _errorMessage; String? _errorMessage;
String? _selectedLibraryGlobalKey; String? _selectedLibraryGlobalKey;
bool _isInitialLoad = true; bool _isInitialLoad = true;
List<String>? _serverOrder; // Cached server order from storage
/// When true, suppress auto-focus in tabs (used when navigating via tab bar) /// When true, suppress auto-focus in tabs (used when navigating via tab bar)
bool _suppressAutoFocus = false; bool _suppressAutoFocus = false;
@@ -91,10 +89,16 @@ class _LibrariesScreenState extends State<LibrariesScreen>
final _libraryDropdownKey = GlobalKey<PopupMenuButtonState<String>>(); final _libraryDropdownKey = GlobalKey<PopupMenuButtonState<String>>();
// Focus nodes for tab chips // 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 _browseTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_browse');
final _collectionsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_collections'); final _collectionsTabChipFocusNode = FocusNode(
final _playlistsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_playlists'); debugLabel: 'tab_chip_collections',
);
final _playlistsTabChipFocusNode = FocusNode(
debugLabel: 'tab_chip_playlists',
);
@override @override
void initState() { void initState() {
@@ -200,7 +204,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
if (_tabController.index == tabIndex && mounted) { if (_tabController.index == tabIndex && mounted) {
// Use post-frame callback to ensure the widget tree is fully built // Use post-frame callback to ensure the widget tree is fully built
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _tabController.index == tabIndex && !_suppressAutoFocus) { if (mounted &&
_tabController.index == tabIndex &&
!_suppressAutoFocus) {
_focusCurrentTab(); _focusCurrentTab();
} }
}); });
@@ -283,37 +289,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
return uniqueServerIds.length > 1; return uniqueServerIds.length > 1;
} }
/// Get ordered list of server IDs from libraries
List<String> _getOrderedServerIds(List<PlexLibrary> 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 = <String>[];
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<void> _loadLibraries() async { Future<void> _loadLibraries() async {
// Extract context dependencies before async gap // Extract context dependencies before async gap
final multiServerProvider = Provider.of<MultiServerProvider>( final multiServerProvider = Provider.of<MultiServerProvider>(
@@ -355,13 +330,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
savedOrder, savedOrder,
); );
// Load saved server order
final savedServerOrder = storage.getServerOrder();
_updateState(() { _updateState(() {
_allLibraries = _allLibraries =
orderedLibraries; // Store all libraries with ordering applied orderedLibraries; // Store all libraries with ordering applied
_serverOrder = savedServerOrder;
_isLoadingLibraries = false; _isLoadingLibraries = false;
}); });
@@ -955,8 +926,8 @@ class _LibrariesScreenState extends State<LibrariesScreen>
return visibleLibraries.map((library) { return visibleLibraries.map((library) {
final isSelected = library.globalKey == _selectedLibraryGlobalKey; final isSelected = library.globalKey == _selectedLibraryGlobalKey;
final showServerName = nonUniqueNames.contains(library.title) && final showServerName =
library.serverName != null; nonUniqueNames.contains(library.title) && library.serverName != null;
return PopupMenuItem<String>( return PopupMenuItem<String>(
value: library.globalKey, value: library.globalKey,
@@ -965,9 +936,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
Icon( Icon(
_getLibraryIcon(library.type), _getLibraryIcon(library.type),
size: 20, size: 20,
color: isSelected color: isSelected ? Theme.of(context).colorScheme.primary : null,
? Theme.of(context).colorScheme.primary
: null,
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
@@ -978,7 +947,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
Text( Text(
library.title, library.title,
style: TextStyle( style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400, fontWeight: isSelected
? FontWeight.w600
: FontWeight.w400,
color: isSelected color: isSelected
? Theme.of(context).colorScheme.primary ? Theme.of(context).colorScheme.primary
: null, : null,
@@ -989,11 +960,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
library.serverName!, library.serverName!,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: Theme.of(context) color: Theme.of(
.textTheme context,
.bodySmall ).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
?.color
?.withValues(alpha: 0.6),
), ),
), ),
], ],
@@ -1614,10 +1583,12 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
focusNode: _listFocusNode, focusNode: _listFocusNode,
autofocus: InputModeTracker.isKeyboardMode(context), autofocus: InputModeTracker.isKeyboardMode(context),
onKeyEvent: _handleKeyEvent, onKeyEvent: _handleKeyEvent,
child: _buildFlatLibraryList(scrollController, hiddenLibraryKeys), child: _buildFlatLibraryList(
scrollController,
hiddenLibraryKeys,
),
), ),
), ),
], ],
); );
}, },
@@ -1640,7 +1611,8 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
buildDefaultDragHandles: false, buildDefaultDragHandles: false,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final library = _tempLibraries[index]; final library = _tempLibraries[index];
final showServerName = nonUniqueNames.contains(library.title) && final showServerName =
nonUniqueNames.contains(library.title) &&
library.serverName != null; library.serverName != null;
final isFocused = isKeyboardMode && index == _focusedIndex; final isFocused = isKeyboardMode && index == _focusedIndex;
final isMoving = index == _movingIndex; final isMoving = index == _movingIndex;
@@ -1687,9 +1659,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
key: ValueKey(library.globalKey), key: ValueKey(library.globalKey),
opacity: isHidden ? 0.5 : 1.0, opacity: isHidden ? 0.5 : 1.0,
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(color: tileColor),
color: tileColor,
),
child: ListTile( child: ListTile(
leading: Row( leading: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -1713,11 +1683,9 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
library.serverName!, library.serverName!,
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
color: Theme.of(context) color: Theme.of(
.textTheme context,
.bodySmall ).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
?.color
?.withValues(alpha: 0.6),
), ),
) )
: null, : null,
@@ -1732,7 +1700,9 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
) )
: null, : null,
child: IconButton( child: IconButton(
icon: Icon(isHidden ? Icons.visibility_off : Icons.visibility), icon: Icon(
isHidden ? Icons.visibility_off : Icons.visibility,
),
tooltip: isHidden tooltip: isHidden
? t.libraries.showLibrary ? t.libraries.showLibrary
: t.libraries.hideLibrary, : t.libraries.hideLibrary,
@@ -1749,7 +1719,8 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
child: IconButton( child: IconButton(
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert),
tooltip: t.libraries.libraryOptions, tooltip: t.libraries.libraryOptions,
onPressed: () => _showLibraryMenuBottomSheet(context, library), onPressed: () =>
_showLibraryMenuBottomSheet(context, library),
), ),
), ),
], ],
+1 -4
View File
@@ -99,10 +99,7 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
), ),
ButtonSegment( ButtonSegment(
value: true, value: true,
icon: Icon( icon: Icon(Icons.arrow_downward, size: 16),
Icons.arrow_downward,
size: 16,
),
), ),
], ],
selected: {_currentDescending}, selected: {_currentDescending},
@@ -83,6 +83,17 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>>
List<T> get items => _items; List<T> get items => _items;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
bool get hasLoadedData => _hasLoadedData;
// Setters for subclasses that override loadItems with custom logic
@protected
set items(List<T> 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 @override
void initState() { void initState() {
@@ -125,7 +136,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>>
// Check if we should focus (became active after data loaded) // Check if we should focus (became active after data loaded)
if (widget.isActive && !oldWidget.isActive) { if (widget.isActive && !oldWidget.isActive) {
_tryFocus(); tryFocus();
} }
} }
@@ -151,11 +162,15 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>>
Stream<void>? getRefreshStream() => null; Stream<void>? getRefreshStream() => null;
/// Try to focus the first item if conditions are met (active + loaded + not yet focused) /// 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) // Don't auto-focus if suppressed (e.g., when navigating via tab bar)
if (widget.suppressAutoFocus) return; if (widget.suppressAutoFocus) return;
if (widget.isActive && _hasLoadedData && !_hasFocused && _items.isNotEmpty) { if (widget.isActive &&
_hasLoadedData &&
!_hasFocused &&
_items.isNotEmpty) {
_hasFocused = true; _hasFocused = true;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { if (mounted) {
@@ -190,7 +205,7 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>>
// Mark data as loaded and try to focus // Mark data as loaded and try to focus
_hasLoadedData = true; _hasLoadedData = true;
_tryFocus(); tryFocus();
// Notify parent that data has loaded // Notify parent that data has loaded
if (widget.onDataLoaded != null) { if (widget.onDataLoaded != null) {
+119 -159
View File
@@ -1,9 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../../../services/plex_client.dart'; import '../../../../services/plex_client.dart';
import '../../../models/plex_library.dart';
import '../../../models/plex_metadata.dart'; import '../../../models/plex_metadata.dart';
import '../../../models/plex_filter.dart'; import '../../../models/plex_filter.dart';
import '../../../models/plex_sort.dart'; import '../../../models/plex_sort.dart';
@@ -12,6 +10,8 @@ import '../../../utils/error_message_utils.dart';
import '../../../utils/grid_size_calculator.dart'; import '../../../utils/grid_size_calculator.dart';
import '../../../widgets/focusable_media_card.dart'; import '../../../widgets/focusable_media_card.dart';
import '../../../widgets/focusable_filter_chip.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 '../folder_tree_view.dart';
import '../filters_bottom_sheet.dart'; import '../filters_bottom_sheet.dart';
import '../sort_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 '../../../services/settings_service.dart' show ViewMode;
import '../../../mixins/item_updatable.dart'; import '../../../mixins/item_updatable.dart';
import '../../../mixins/library_tab_state.dart'; import '../../../mixins/library_tab_state.dart';
import '../../../mixins/refreshable.dart';
import '../../../i18n/strings.g.dart'; import '../../../i18n/strings.g.dart';
import 'base_library_tab.dart';
/// Browse tab for library screen /// Browse tab for library screen
/// Shows library items with grouping, filtering, and sorting /// Shows library items with grouping, filtering, and sorting
class LibraryBrowseTab extends StatefulWidget { class LibraryBrowseTab extends BaseLibraryTab<PlexMetadata> {
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;
const LibraryBrowseTab({ const LibraryBrowseTab({
super.key, super.key,
required this.library, required super.library,
this.viewMode, super.viewMode,
this.density, super.density,
this.onDataLoaded, super.onDataLoaded,
this.isActive = false, super.isActive,
this.suppressAutoFocus = false, super.suppressAutoFocus,
this.onBack, super.onBack,
}); });
@override @override
State<LibraryBrowseTab> createState() => _LibraryBrowseTabState(); State<LibraryBrowseTab> createState() => _LibraryBrowseTabState();
} }
class _LibraryBrowseTabState extends State<LibraryBrowseTab> class _LibraryBrowseTabState
with extends BaseLibraryTabState<PlexMetadata, LibraryBrowseTab>
AutomaticKeepAliveClientMixin, with ItemUpdatable, LibraryTabStateMixin, LibraryTabFocusMixin {
ItemUpdatable,
Refreshable,
LibraryTabStateMixin {
@override
bool get wantKeepAlive => true;
@override
PlexLibrary get library => widget.library;
@override @override
PlexClient get client => getClientForLibrary(); PlexClient get client => getClientForLibrary();
@override @override
void refresh() { String get focusNodeDebugLabel => 'browse_first_item';
_loadContent();
} @override
int get itemCount => items.length;
@override @override
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) { void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
setState(() { setState(() {
final index = _items.indexWhere((item) => item.ratingKey == ratingKey); final index = items.indexWhere((item) => item.ratingKey == ratingKey);
if (index != -1) { if (index != -1) {
_items[index] = updatedMetadata; items[index] = updatedMetadata;
} }
}); });
} }
List<PlexMetadata> _items = []; // Browse-specific state (not in base class)
List<PlexFilter> _filters = []; List<PlexFilter> _filters = [];
List<PlexSort> _sortOptions = []; List<PlexSort> _sortOptions = [];
bool _isLoading = false;
String? _errorMessage;
Map<String, String> _selectedFilters = {}; Map<String, String> _selectedFilters = {};
PlexSort? _selectedSort; PlexSort? _selectedSort;
bool _isSortDescending = false; bool _isSortDescending = false;
@@ -109,74 +79,59 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
int _requestId = 0; int _requestId = 0;
static const int _pageSize = 500; 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 // 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 _filtersChipFocusNode = FocusNode(debugLabel: 'filters_chip');
final FocusNode _sortChipFocusNode = FocusNode(debugLabel: 'sort_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 @override
void dispose() { void dispose() {
_cancelToken?.cancel(); _cancelToken?.cancel();
_firstItemFocusNode.dispose();
_groupingChipFocusNode.dispose(); _groupingChipFocusNode.dispose();
_filtersChipFocusNode.dispose(); _filtersChipFocusNode.dispose();
_sortChipFocusNode.dispose(); _sortChipFocusNode.dispose();
super.dispose(); super.dispose();
} }
/// Try to focus the first item if conditions are met (active + loaded + not yet focused) // Override loadData to use our custom _loadContent
void _tryFocus() { @override
// Don't auto-focus if suppressed (e.g., when navigating via tab bar) Future<List<PlexMetadata>> loadData() async {
if (widget.suppressAutoFocus) return; // This is called by base class loadItems(), but we override loadItems() entirely
// So this just returns empty - actual loading is done in _loadContent
if (widget.isActive && _hasLoadedData && !_hasFocused && _items.isNotEmpty) { return [];
_hasFocused = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
focusFirstItem();
}
});
}
} }
// Override loadItems to use our custom loading with pagination
@override
Future<void> 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<PlexMetadata> items) => const SizedBox.shrink();
/// Focus the first item in the grid/list (for tab activation) /// Focus the first item in the grid/list (for tab activation)
@override
void focusFirstItem() { void focusFirstItem() {
if (_items.isNotEmpty) { if (items.isNotEmpty) {
// Request immediately, then once more on the next frame to handle cases // Request immediately, then once more on the next frame to handle cases
// where the grid/list attaches after the initial focus attempt. // where the grid/list attaches after the initial focus attempt.
void request() { void request() {
if (mounted && _items.isNotEmpty && !_firstItemFocusNode.hasFocus) { if (mounted && items.isNotEmpty && !firstItemFocusNode.hasFocus) {
_firstItemFocusNode.requestFocus(); firstItemFocusNode.requestFocus();
} }
} }
@@ -195,9 +150,9 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
final client = getClientForLibrary(); final client = getClientForLibrary();
setState(() { setState(() {
_isLoading = true; isLoading = true;
_errorMessage = null; errorMessage = null;
_items = []; items = [];
_currentPage = 0; _currentPage = 0;
_hasMoreItems = true; _hasMoreItems = true;
// Clear filter/sort state while loading to prevent showing stale options // Clear filter/sort state while loading to prevent showing stale options
@@ -255,7 +210,7 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
} }
Future<void> _loadItems({bool loadMore = false}) async { Future<void> _loadItems({bool loadMore = false}) async {
if (loadMore && _isLoading) return; if (loadMore && isLoading) return;
if (!loadMore) { if (!loadMore) {
_currentPage = 0; _currentPage = 0;
@@ -269,9 +224,9 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
_cancelToken = CancelToken(); _cancelToken = CancelToken();
setState(() { setState(() {
_isLoading = true; isLoading = true;
if (!loadMore) { if (!loadMore) {
_items = []; items = [];
} }
}); });
@@ -298,7 +253,7 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
} }
// Items are automatically tagged with server info by PlexClient // Items are automatically tagged with server info by PlexClient
final items = await client.getLibraryContent( final loadedItems = await client.getLibraryContent(
widget.library.key, widget.library.key,
start: _currentPage * _pageSize, start: _currentPage * _pageSize,
size: _pageSize, size: _pageSize,
@@ -310,19 +265,19 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
setState(() { setState(() {
if (loadMore) { if (loadMore) {
_items.addAll(items); items.addAll(loadedItems);
} else { } else {
_items = items; items = loadedItems;
} }
_hasMoreItems = items.length >= _pageSize; _hasMoreItems = loadedItems.length >= _pageSize;
_currentPage++; _currentPage++;
_isLoading = false; isLoading = false;
}); });
// On initial load (not pagination), mark data as loaded and try to focus // On initial load (not pagination), mark data as loaded and try to focus
if (!loadMore) { if (!loadMore) {
_hasLoadedData = true; hasLoadedData = true;
_tryFocus(); tryFocus();
// Notify parent // Notify parent
if (widget.onDataLoaded != null) { if (widget.onDataLoaded != null) {
@@ -340,8 +295,8 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
if (currentRequestId != _requestId) return; if (currentRequestId != _requestId) return;
setState(() { setState(() {
_errorMessage = _getErrorMessage(error); errorMessage = _getErrorMessage(error);
_isLoading = false; isLoading = false;
}); });
} }
@@ -501,8 +456,8 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
/// Navigate focus from chips down to the first grid item /// Navigate focus from chips down to the first grid item
void _navigateToGrid() { void _navigateToGrid() {
if (_items.isNotEmpty) { if (items.isNotEmpty) {
_firstItemFocusNode.requestFocus(); firstItemFocusNode.requestFocus();
} }
} }
@@ -512,8 +467,12 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
} }
/// Calculate the number of columns in the current grid based on screen width /// Calculate the number of columns in the current grid based on screen width
int _getGridColumnCount(BuildContext context, SettingsProvider settingsProvider) { int _getGridColumnCount(
final screenWidth = MediaQuery.of(context).size.width - 16; // Subtract padding BuildContext context,
SettingsProvider settingsProvider,
) {
final screenWidth =
MediaQuery.of(context).size.width - 16; // Subtract padding
final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent( final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(
context, context,
settingsProvider.libraryDensity, settingsProvider.libraryDensity,
@@ -601,20 +560,20 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
); );
} }
if (_isLoading && _items.isEmpty) { if (isLoading && items.isEmpty) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
if (_errorMessage != null && _items.isEmpty) { if (errorMessage != null && items.isEmpty) {
return ErrorStateWidget( return ErrorStateWidget(
message: _errorMessage!, message: errorMessage!,
icon: Icons.error_outline, icon: Icons.error_outline,
onRetry: _loadContent, onRetry: _loadContent,
retryLabel: t.common.retry, retryLabel: t.common.retry,
); );
} }
if (_items.isEmpty) { if (items.isEmpty) {
return EmptyStateWidget( return EmptyStateWidget(
message: t.libraries.thisLibraryIsEmpty, message: t.libraries.thisLibraryIsEmpty,
icon: Icons.folder_open, icon: Icons.folder_open,
@@ -626,63 +585,64 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
if (notification.metrics.pixels >= if (notification.metrics.pixels >=
notification.metrics.maxScrollExtent - 300 && notification.metrics.maxScrollExtent - 300 &&
_hasMoreItems && _hasMoreItems &&
!_isLoading) { !isLoading) {
_loadItems(loadMore: true); _loadItems(loadMore: true);
} }
return false; return false;
}, },
child: Consumer<SettingsProvider>( child: Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) { builder: (context, settingsProvider, child) {
if (settingsProvider.viewMode == ViewMode.list) { return _buildItemsView(context, settingsProvider);
// 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),
),
);
}
}, },
), ),
); );
} }
/// 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}) { Widget _buildMediaCardItem(int index, {required bool isFirstRow}) {
if (index >= _items.length) { if (index >= items.length) {
return const Padding( return const Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
); );
} }
final item = _items[index]; final item = items[index];
return FocusableMediaCard( return FocusableMediaCard(
key: Key(item.ratingKey), key: Key(item.ratingKey),
item: item, item: item,
focusNode: index == 0 ? _firstItemFocusNode : null, focusNode: index == 0 ? firstItemFocusNode : null,
onRefresh: updateItem, onRefresh: updateItem,
onNavigateUp: isFirstRow ? _navigateToChips : null, onNavigateUp: isFirstRow ? _navigateToChips : null,
onBack: widget.onBack, onBack: widget.onBack,
@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../models/plex_metadata.dart'; import '../../../models/plex_metadata.dart';
import '../../../utils/library_refresh_notifier.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 '../../../i18n/strings.g.dart';
import '../adaptive_media_grid.dart'; import '../adaptive_media_grid.dart';
import 'base_library_tab.dart'; import 'base_library_tab.dart';
@@ -24,23 +26,13 @@ class LibraryCollectionsTab extends BaseLibraryTab<PlexMetadata> {
} }
class _LibraryCollectionsTabState class _LibraryCollectionsTabState
extends BaseLibraryTabState<PlexMetadata, LibraryCollectionsTab> { extends BaseLibraryTabState<PlexMetadata, LibraryCollectionsTab>
// Focus node for the first item (for programmatic focus) with LibraryTabFocusMixin {
final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'collections_first_item'); @override
String get focusNodeDebugLabel => 'collections_first_item';
@override @override
void dispose() { int get itemCount => items.length;
_firstItemFocusNode.dispose();
super.dispose();
}
/// Focus the first item in the grid/list (for tab activation)
@override
void focusFirstItem() {
if (items.isNotEmpty) {
_firstItemFocusNode.requestFocus();
}
}
@override @override
IconData get emptyIcon => Icons.collections; IconData get emptyIcon => Icons.collections;
@@ -66,10 +58,19 @@ class _LibraryCollectionsTabState
@override @override
Widget buildContent(List<PlexMetadata> items) { Widget buildContent(List<PlexMetadata> items) {
return AdaptiveMediaGrid( return AdaptiveMediaGrid<PlexMetadata>(
items: items, 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, onRefresh: loadItems,
firstItemFocusNode: _firstItemFocusNode, firstItemFocusNode: firstItemFocusNode,
onBack: widget.onBack, onBack: widget.onBack,
); );
} }
@@ -1,12 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../models/plex_playlist.dart'; import '../../../models/plex_playlist.dart';
import '../../../providers/settings_provider.dart';
import '../../../utils/library_refresh_notifier.dart'; import '../../../utils/library_refresh_notifier.dart';
import '../../../services/settings_service.dart' show ViewMode; import '../../../mixins/library_tab_focus_mixin.dart';
import '../../../utils/grid_size_calculator.dart';
import '../../../widgets/focusable_media_card.dart'; import '../../../widgets/focusable_media_card.dart';
import '../../../i18n/strings.g.dart'; import '../../../i18n/strings.g.dart';
import '../adaptive_media_grid.dart';
import 'base_library_tab.dart'; import 'base_library_tab.dart';
/// Playlists tab for library screen /// Playlists tab for library screen
@@ -28,23 +26,13 @@ class LibraryPlaylistsTab extends BaseLibraryTab<PlexPlaylist> {
} }
class _LibraryPlaylistsTabState class _LibraryPlaylistsTabState
extends BaseLibraryTabState<PlexPlaylist, LibraryPlaylistsTab> { extends BaseLibraryTabState<PlexPlaylist, LibraryPlaylistsTab>
// Focus node for the first item (for programmatic focus) with LibraryTabFocusMixin {
final FocusNode _firstItemFocusNode = FocusNode(debugLabel: 'playlists_first_item'); @override
String get focusNodeDebugLabel => 'playlists_first_item';
@override @override
void dispose() { int get itemCount => items.length;
_firstItemFocusNode.dispose();
super.dispose();
}
/// Focus the first item in the grid/list (for tab activation)
@override
void focusFirstItem() {
if (items.isNotEmpty) {
_firstItemFocusNode.requestFocus();
}
}
@override @override
IconData get emptyIcon => Icons.playlist_play; IconData get emptyIcon => Icons.playlist_play;
@@ -72,42 +60,19 @@ class _LibraryPlaylistsTabState
@override @override
Widget buildContent(List<PlexPlaylist> items) { Widget buildContent(List<PlexPlaylist> items) {
return Consumer<SettingsProvider>( return AdaptiveMediaGrid<PlexPlaylist>(
builder: (context, settingsProvider, child) { items: items,
if (settingsProvider.viewMode == ViewMode.list) { itemBuilder: (context, playlist, index) {
return ListView.builder( return FocusableMediaCard(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), key: Key(playlist.ratingKey),
itemCount: items.length, item: playlist,
itemBuilder: (context, index) => focusNode: index == 0 ? firstItemFocusNode : null,
_buildPlaylistItem(items[index], index), onListRefresh: loadItems,
); onBack: widget.onBack,
} 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),
);
}
}, },
); onRefresh: loadItems,
} firstItemFocusNode: firstItemFocusNode,
Widget _buildPlaylistItem(PlexPlaylist playlist, int index) {
return FocusableMediaCard(
key: Key(playlist.ratingKey),
item: playlist,
focusNode: index == 0 ? _firstItemFocusNode : null,
onListRefresh: loadItems,
onBack: widget.onBack, onBack: widget.onBack,
); );
} }
@@ -162,7 +162,8 @@ class _LibraryRecommendedTabState
onRemoveFromContinueWatching: isContinueWatching onRemoveFromContinueWatching: isContinueWatching
? _refreshContinueWatching ? _refreshContinueWatching
: null, : null,
onVerticalNavigation: (isUp) => _handleVerticalNavigation(index, isUp), onVerticalNavigation: (isUp) =>
_handleVerticalNavigation(index, isUp),
onBack: widget.onBack, onBack: widget.onBack,
); );
}, },
+12 -4
View File
@@ -66,8 +66,12 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
final GlobalKey<SideNavigationRailState> _sideNavKey = GlobalKey(); final GlobalKey<SideNavigationRailState> _sideNavKey = GlobalKey();
// Focus management for sidebar/content switching // Focus management for sidebar/content switching
final FocusScopeNode _sidebarFocusScope = FocusScopeNode(debugLabel: 'Sidebar'); final FocusScopeNode _sidebarFocusScope = FocusScopeNode(
final FocusScopeNode _contentFocusScope = FocusScopeNode(debugLabel: 'Content'); debugLabel: 'Sidebar',
);
final FocusScopeNode _contentFocusScope = FocusScopeNode(
debugLabel: 'Content',
);
bool _isSidebarFocused = false; bool _isSidebarFocused = false;
@override @override
@@ -138,7 +142,8 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
KeyEventResult _handleBackKey(KeyEvent event) { KeyEventResult _handleBackKey(KeyEvent event) {
if (event is! KeyDownEvent) return KeyEventResult.ignored; 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.goBack ||
event.logicalKey == LogicalKeyboardKey.browserBack || event.logicalKey == LogicalKeyboardKey.browserBack ||
event.logicalKey == LogicalKeyboardKey.gameButtonB; event.logicalKey == LogicalKeyboardKey.gameButtonB;
@@ -310,7 +315,10 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
child: FocusScope( child: FocusScope(
node: _contentFocusScope, node: _contentFocusScope,
autofocus: true, autofocus: true,
child: IndexedStack(index: _currentIndex, children: _screens), child: IndexedStack(
index: _currentIndex,
children: _screens,
),
), ),
), ),
], ],
File diff suppressed because it is too large Load Diff
@@ -1,22 +1,18 @@
import 'package:flutter/material.dart'; 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 '../../services/plex_client.dart';
import '../../models/plex_playlist.dart'; import '../../models/plex_playlist.dart';
import '../../models/plex_metadata.dart'; import '../../models/plex_metadata.dart';
import '../../providers/settings_provider.dart';
import '../../providers/playback_state_provider.dart'; import '../../providers/playback_state_provider.dart';
import '../../utils/app_logger.dart'; import '../../utils/app_logger.dart';
import '../../utils/provider_extensions.dart'; import '../../utils/provider_extensions.dart';
import '../../utils/video_player_navigation.dart'; import '../../utils/video_player_navigation.dart';
import '../../utils/grid_size_calculator.dart'; import '../../widgets/media_grid_sliver.dart';
import '../../widgets/media_card.dart';
import 'playlist_item_card.dart'; import 'playlist_item_card.dart';
import '../../widgets/desktop_app_bar.dart'; import '../../widgets/focused_scroll_scaffold.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import '../../utils/dialogs.dart'; import '../../utils/dialogs.dart';
import '../base_media_list_detail_screen.dart'; import '../base_media_list_detail_screen.dart';
import 'package:provider/provider.dart';
/// Screen to display the contents of a playlist /// Screen to display the contents of a playlist
class PlaylistDetailScreen extends StatefulWidget { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Focus( return FocusedScrollScaffold(
autofocus: true, title: Column(
onKeyEvent: _handleKeyEvent, crossAxisAlignment: CrossAxisAlignment.start,
child: Scaffold( children: [
body: CustomScrollView( Text(widget.playlist.title, style: const TextStyle(fontSize: 16)),
slivers: [ if (widget.playlist.smart)
CustomAppBar( Row(
title: Column( mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon(Icons.auto_awesome, size: 12, color: Colors.blue[300]),
const SizedBox(width: 4),
Text( Text(
widget.playlist.title, t.playlists.smartPlaylist,
style: const TextStyle(fontSize: 16), style: TextStyle(
), fontSize: 11,
if (widget.playlist.smart) color: Colors.blue[300],
Row( fontWeight: FontWeight.normal,
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,
),
),
],
), ),
),
], ],
), ),
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<SettingsProvider>().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,
),
],
); );
} }
} }
+1 -1
View File
@@ -144,7 +144,7 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> {
final posterUrl = widget.item.posterThumb(); final posterUrl = widget.item.posterThumb();
return ClipRRect( return ClipRRect(
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
child: PlexPosterImage( child: PlexOptimizedImage.poster(
client: _getClientForItem(context), client: _getClientForItem(context),
imagePath: posterUrl, imagePath: posterUrl,
width: 60, width: 60,
+109 -106
View File
@@ -35,64 +35,106 @@ class UserAvatarWidget extends StatelessWidget {
); );
} }
List<Widget> _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<Widget> _buildTextLabels(BuildContext context, ThemeData theme) {
if (!useTextLabels || !showIndicators) return []; if (!useTextLabels || !showIndicators) return [];
final labels = <Widget>[]; final labels = <Widget>[];
if (user.isAdminUser) { if (user.isAdminUser) {
labels.add( labels.add(
Container( _buildLabelChip(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), context: context,
decoration: BoxDecoration( text: t.userStatus.admin,
color: theme.colorScheme.primary, backgroundColor: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(8), textColor: theme.colorScheme.onPrimary,
),
child: Text(
t.userStatus.admin,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
),
), ),
); );
} }
if (user.isRestrictedUser && !user.isAdminUser) { if (user.isRestrictedUser && !user.isAdminUser) {
labels.add( labels.add(
Container( _buildLabelChip(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), context: context,
decoration: BoxDecoration( text: t.userStatus.restricted,
color: theme.colorScheme.warning ?? Colors.orange, backgroundColor: theme.colorScheme.warning ?? Colors.orange,
borderRadius: BorderRadius.circular(8), textColor: theme.colorScheme.onPrimary,
),
child: Text(
t.userStatus.restricted,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onPrimary,
fontWeight: FontWeight.bold,
),
),
), ),
); );
} }
if (user.requiresPassword) { if (user.requiresPassword) {
labels.add( labels.add(
Container( _buildLabelChip(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), context: context,
decoration: BoxDecoration( text: t.userStatus.protected,
color: theme.colorScheme.secondary, backgroundColor: theme.colorScheme.secondary,
borderRadius: BorderRadius.circular(8), textColor: theme.colorScheme.onSecondary,
),
child: Text(
t.userStatus.protected,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSecondary,
fontWeight: FontWeight.bold,
),
),
), ),
); );
} }
@@ -110,7 +152,7 @@ class UserAvatarWidget extends StatelessWidget {
]; ];
} }
Widget _buildAvatar(ThemeData theme) { Widget _buildAvatar(BuildContext context, ThemeData theme) {
return SizedBox( return SizedBox(
width: size, width: size,
height: size, height: size,
@@ -123,9 +165,8 @@ class UserAvatarWidget extends StatelessWidget {
width: size, width: size,
height: size, height: size,
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: (context, url) => _buildPlaceholderAvatar(theme), placeholder: (ctx, url) => _buildPlaceholderAvatar(theme),
errorWidget: (context, url, error) => errorWidget: (ctx, url, error) => _buildPlaceholderAvatar(theme),
_buildPlaceholderAvatar(theme),
), ),
), ),
@@ -133,74 +174,33 @@ class UserAvatarWidget extends StatelessWidget {
if (showIndicators && !useTextLabels) ...[ if (showIndicators && !useTextLabels) ...[
// Admin badge // Admin badge
if (user.isAdminUser) if (user.isAdminUser)
Positioned( _buildBadge(
top: 0, context: context,
right: 0, icon: Icons.admin_panel_settings,
child: Container( color: theme.colorScheme.primary,
width: size * 0.3, iconColor: theme.colorScheme.onPrimary,
height: size * 0.3, position: 'topRight',
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,
),
),
), ),
// Restricted badge // Restricted badge
if (user.isRestrictedUser && !user.isAdminUser) if (user.isRestrictedUser && !user.isAdminUser)
Positioned( _buildBadge(
top: 0, context: context,
right: 0, icon: Icons.security,
child: Container( color: theme.colorScheme.warning ?? Colors.orange,
width: size * 0.3, iconColor: theme.colorScheme.onPrimary,
height: size * 0.3, position: 'topRight',
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,
),
),
), ),
// Password indicator // Password indicator
if (user.requiresPassword) if (user.requiresPassword)
Positioned( _buildBadge(
bottom: 0, context: context,
right: 0, icon: Icons.lock,
child: Container( color: theme.colorScheme.secondary,
width: size * 0.25, iconColor: theme.colorScheme.onSecondary,
height: size * 0.25, position: 'bottomRight',
decoration: BoxDecoration( sizeRatio: 0.25,
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,
),
),
), ),
], ],
], ],
@@ -218,12 +218,15 @@ class UserAvatarWidget extends StatelessWidget {
onTap: onTap, onTap: onTap,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [_buildAvatar(theme), ..._buildTextLabels(theme)], children: [
_buildAvatar(context, theme),
..._buildTextLabels(context, theme),
],
), ),
); );
} else { } else {
// Return just the avatar (original behavior) // Return just the avatar (original behavior)
return GestureDetector(onTap: onTap, child: _buildAvatar(theme)); return GestureDetector(onTap: onTap, child: _buildAvatar(context, theme));
} }
} }
} }
+18 -41
View File
@@ -7,9 +7,8 @@ import '../mixins/refreshable.dart';
import '../models/plex_metadata.dart'; import '../models/plex_metadata.dart';
import '../providers/multi_server_provider.dart'; import '../providers/multi_server_provider.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../services/settings_service.dart';
import '../utils/app_logger.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/desktop_app_bar.dart';
import '../widgets/media_card.dart'; import '../widgets/media_card.dart';
@@ -255,45 +254,23 @@ class _SearchScreenState extends State<SearchScreen> with Refreshable {
else else
Consumer<SettingsProvider>( Consumer<SettingsProvider>(
builder: (context, settingsProvider, child) { builder: (context, settingsProvider, child) {
if (settingsProvider.viewMode == ViewMode.list) { return buildAdaptiveMediaSliverBuilder<PlexMetadata>(
return SliverPadding( context: context,
padding: const EdgeInsets.all(16), items: _searchResults,
sliver: SliverList( itemBuilder: (context, item, index) {
delegate: SliverChildBuilderDelegate((context, index) { return MediaCard(
final item = _searchResults[index]; key: Key(item.ratingKey),
return MediaCard( item: item,
key: Key(item.ratingKey), onRefresh: updateItem,
item: item, );
onRefresh: updateItem, },
); viewMode: settingsProvider.viewMode,
}, childCount: _searchResults.length), density: settingsProvider.libraryDensity,
), padding: const EdgeInsets.all(16),
); childAspectRatio: 2 / 3.3,
} else { crossAxisSpacing: 8,
return SliverPadding( mainAxisSpacing: 8,
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),
),
);
}
}, },
), ),
], ],
+88 -96
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../services/plex_client.dart'; import '../../services/plex_client.dart';
import '../focus/dpad_navigator.dart'; import '../focus/key_event_utils.dart';
import '../focus/input_mode_tracker.dart'; import '../focus/input_mode_tracker.dart';
import '../widgets/plex_optimized_image.dart'; import '../widgets/plex_optimized_image.dart';
import '../models/plex_metadata.dart'; import '../models/plex_metadata.dart';
@@ -83,71 +82,65 @@ class _SeasonDetailScreenState extends State<SeasonDetailScreen>
} }
} }
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is KeyDownEvent && event.logicalKey.isBackKey) {
Navigator.pop(context, _watchStateChanged);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Focus( return Focus(
onKeyEvent: _handleKeyEvent, onKeyEvent: (_, event) =>
handleBackKeyNavigation(context, event, result: _watchStateChanged),
child: Scaffold( child: Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
CustomAppBar( CustomAppBar(
title: Text(widget.season.title), title: Text(widget.season.title),
pinned: true, pinned: true,
onBackPressed: () => Navigator.pop(context, _watchStateChanged), onBackPressed: () => Navigator.pop(context, _watchStateChanged),
), ),
if (_isLoadingEpisodes) if (_isLoadingEpisodes)
const SliverFillRemaining( const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
) )
else if (_episodes.isEmpty) else if (_episodes.isEmpty)
SliverFillRemaining( SliverFillRemaining(
child: Center( child: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
Icons.movie_outlined, Icons.movie_outlined,
size: 64, 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, 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, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasProgress = final hasProgress =
@@ -210,7 +237,7 @@ class _EpisodeCard extends StatelessWidget {
child: AspectRatio( child: AspectRatio(
aspectRatio: 16 / 9, aspectRatio: 16 / 9,
child: episode.thumb != null child: episode.thumb != null
? PlexThumbImage( ? PlexOptimizedImage.thumb(
client: client, client: client,
imagePath: episode.thumb, imagePath: episode.thumb,
filterQuality: FilterQuality.medium, filterQuality: FilterQuality.medium,
@@ -352,42 +379,7 @@ class _EpisodeCard extends StatelessWidget {
// Metadata row (duration, watched status) // Metadata row (duration, watched status)
const SizedBox(height: 8), const SizedBox(height: 8),
Row( _buildEpisodeMetaRow(context),
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,
),
),
],
],
),
], ],
), ),
), ),
+58 -74
View File
@@ -1,8 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import '../../focus/dpad_navigator.dart'; import '../../widgets/focused_scroll_scaffold.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import 'licenses_screen.dart'; import 'licenses_screen.dart';
@@ -31,86 +29,72 @@ class _AboutScreenState extends State<AboutScreen> {
}); });
} }
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is KeyDownEvent && event.logicalKey.isBackKey) {
Navigator.pop(context);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final appName = _appName; final appName = _appName;
final appVersion = _appVersion; final appVersion = _appVersion;
return Focus( return FocusedScrollScaffold(
autofocus: true, title: Text(t.about.title),
onKeyEvent: _handleKeyEvent, slivers: [
child: Scaffold( SliverPadding(
body: CustomScrollView( padding: const EdgeInsets.all(16),
slivers: [ sliver: SliverList(
CustomAppBar(title: Text(t.about.title), pinned: true), delegate: SliverChildListDelegate([
SliverPadding( // App Icon and Name
padding: const EdgeInsets.all(16), Center(
sliver: SliverList( child: Column(
delegate: SliverChildListDelegate([ children: [
// App Icon and Name const SizedBox(height: 24),
Center( Image.asset('assets/plezy.png', width: 80, height: 80),
child: Column( const SizedBox(height: 16),
children: [ Text(
const SizedBox(height: 24), appName,
Image.asset('assets/plezy.png', width: 80, height: 80), style: Theme.of(context).textTheme.headlineMedium
const SizedBox(height: 16), ?.copyWith(fontWeight: FontWeight.bold),
Text( ),
appName, const SizedBox(height: 8),
style: Theme.of(context).textTheme.headlineMedium Text(
?.copyWith(fontWeight: FontWeight.bold), t.about.versionLabel(version: appVersion),
), style: Theme.of(
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(
context, context,
MaterialPageRoute( ).textTheme.bodyMedium?.copyWith(color: Colors.grey),
builder: (context) => const LicensesScreen(), ),
), 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),
]),
), ),
], ),
), ],
),
); );
} }
} }
+111 -140
View File
@@ -1,8 +1,6 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import '../../widgets/focused_scroll_scaffold.dart';
import '../../focus/dpad_navigator.dart';
import '../../widgets/desktop_app_bar.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
class MergedLicenseEntry { class MergedLicenseEntry {
@@ -67,64 +65,53 @@ class _LicensesScreenState extends State<LicensesScreen> {
} }
} }
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is KeyDownEvent && event.logicalKey.isBackKey) {
Navigator.pop(context);
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_isLoading) { if (_isLoading) {
return Focus( return FocusedScrollScaffold(
autofocus: true, title: Text(t.screens.licenses),
onKeyEvent: _handleKeyEvent, slivers: const [
child: const Scaffold(body: Center(child: CircularProgressIndicator())), SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
),
],
); );
} }
return Focus( return FocusedScrollScaffold(
autofocus: true, title: Text(t.screens.licenses),
onKeyEvent: _handleKeyEvent, slivers: [
child: Scaffold( SliverPadding(
body: CustomScrollView( padding: const EdgeInsets.all(16),
slivers: [ sliver: SliverList(
CustomAppBar(title: Text(t.screens.licenses), pinned: true), delegate: SliverChildBuilderDelegate((context, index) {
SliverPadding( final mergedLicense = _mergedLicenses[index];
padding: const EdgeInsets.all(16), final packageName = mergedLicense.packageName;
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final mergedLicense = _mergedLicenses[index];
final packageName = mergedLicense.packageName;
return Card( return Card(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
child: ListTile( child: ListTile(
title: Text( title: Text(
packageName, packageName,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold, 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),
), ),
); subtitle: mergedLicense.licenseEntries.length > 1
}, childCount: _mergedLicenses.length), ? 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}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final packageName = mergedLicense.packageName; final packageName = mergedLicense.packageName;
final licenseEntries = mergedLicense.licenseEntries; final licenseEntries = mergedLicense.licenseEntries;
return Focus( return FocusedScrollScaffold(
autofocus: true, title: Text(packageName),
onKeyEvent: (node, event) => _handleKeyEvent(context, node, event), slivers: [
child: Scaffold( SliverPadding(
body: CustomScrollView( padding: const EdgeInsets.all(16),
slivers: [ sliver: SliverList(
CustomAppBar(title: Text(packageName), pinned: true), delegate: SliverChildListDelegate([
SliverPadding( // Package info card
padding: const EdgeInsets.all(16), if (mergedLicense.allPackageNames.length > 1)
sliver: SliverList( Card(
delegate: SliverChildListDelegate([ child: Padding(
// Package info card padding: const EdgeInsets.all(16),
if (mergedLicense.allPackageNames.length > 1) child: Column(
Card( crossAxisAlignment: CrossAxisAlignment.start,
child: Padding( children: [
padding: const EdgeInsets.all(16), Text(
child: Column( t.licenses.relatedPackages,
crossAxisAlignment: CrossAxisAlignment.start, style: Theme.of(context).textTheme.titleMedium
children: [ ?.copyWith(fontWeight: FontWeight.bold),
Text( ),
t.licenses.relatedPackages, const SizedBox(height: 8),
style: Theme.of(context).textTheme.titleMedium Text(
?.copyWith(fontWeight: FontWeight.bold), mergedLicense.allPackageNames.join(', '),
), style: Theme.of(context).textTheme.bodyMedium,
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 // License cards
...licenseEntries.asMap().entries.map((entry) { ...licenseEntries.asMap().entries.map((entry) {
final index = entry.key; final index = entry.key;
final license = entry.value; final license = entry.value;
final isMultipleLicenses = licenseEntries.length > 1; final isMultipleLicenses = licenseEntries.length > 1;
return Column( return Column(
children: [ children: [
Card( Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
isMultipleLicenses isMultipleLicenses
? t.licenses.licenseNumber( ? t.licenses.licenseNumber(number: index + 1)
number: index + 1, : t.licenses.license,
) style: Theme.of(context).textTheme.titleMedium
: t.licenses.license, ?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(context).textTheme.titleMedium ),
?.copyWith(fontWeight: FontWeight.bold), const SizedBox(height: 16),
), ...license.paragraphs.map((paragraph) {
const SizedBox(height: 16), return Padding(
...license.paragraphs.map((paragraph) { padding: const EdgeInsets.only(bottom: 16),
return Padding( child: SelectableText(
padding: const EdgeInsets.only(bottom: 16), paragraph.text,
child: SelectableText( style: TextStyle(
paragraph.text, fontFamily: paragraph.indent > 0
style: TextStyle( ? 'monospace'
fontFamily: paragraph.indent > 0 : null,
? 'monospace' fontSize: 14,
: null,
fontSize: 14,
),
), ),
); ),
}), );
], }),
), ],
), ),
), ),
if (index < licenseEntries.length - 1) ),
const SizedBox(height: 16), if (index < licenseEntries.length - 1)
], const SizedBox(height: 16),
); ],
}), );
]), }),
), ]),
), ),
], ),
), ],
),
); );
} }
} }
@@ -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<Type, BaseSharedPreferencesService> _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<T> initializeInstance<T extends BaseSharedPreferencesService>(
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<void> onInit() async {
// Default implementation does nothing
}
}
+98 -108
View File
@@ -1,9 +1,9 @@
import 'dart:convert'; import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:hotkey_manager/hotkey_manager.dart';
import 'package:plezy/utils/app_logger.dart'; import 'package:plezy/utils/app_logger.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import 'base_shared_preferences_service.dart';
enum ThemeMode { system, light, dark } enum ThemeMode { system, light, dark }
@@ -11,7 +11,7 @@ enum LibraryDensity { compact, normal, comfortable }
enum ViewMode { grid, list } enum ViewMode { grid, list }
class SettingsService { class SettingsService extends BaseSharedPreferencesService {
static const String _keyThemeMode = 'theme_mode'; static const String _keyThemeMode = 'theme_mode';
static const String _keyEnableDebugLogging = 'enable_debug_logging'; static const String _keyEnableDebugLogging = 'enable_debug_logging';
static const String _keyBufferSize = 'buffer_size'; static const String _keyBufferSize = 'buffer_size';
@@ -46,30 +46,21 @@ class SettingsService {
static const String _keyAutoSkipCredits = 'auto_skip_credits'; static const String _keyAutoSkipCredits = 'auto_skip_credits';
static const String _keyAutoSkipDelay = 'auto_skip_delay'; static const String _keyAutoSkipDelay = 'auto_skip_delay';
static SettingsService? _instance;
late SharedPreferences _prefs;
SettingsService._(); SettingsService._();
static Future<SettingsService> getInstance() async { static Future<SettingsService> getInstance() async {
if (_instance == null) { return BaseSharedPreferencesService.initializeInstance(
_instance = SettingsService._(); () => SettingsService._(),
await _instance!._init(); );
}
return _instance!;
}
Future<void> _init() async {
_prefs = await SharedPreferences.getInstance();
} }
// Theme Mode // Theme Mode
Future<void> setThemeMode(ThemeMode mode) async { Future<void> setThemeMode(ThemeMode mode) async {
await _prefs.setString(_keyThemeMode, mode.name); await prefs.setString(_keyThemeMode, mode.name);
} }
ThemeMode getThemeMode() { ThemeMode getThemeMode() {
final modeString = _prefs.getString(_keyThemeMode); final modeString = prefs.getString(_keyThemeMode);
return ThemeMode.values.firstWhere( return ThemeMode.values.firstWhere(
(mode) => mode.name == modeString, (mode) => mode.name == modeString,
orElse: () => ThemeMode.system, orElse: () => ThemeMode.system,
@@ -78,68 +69,67 @@ class SettingsService {
// Debug Logging // Debug Logging
Future<void> setEnableDebugLogging(bool enabled) async { Future<void> setEnableDebugLogging(bool enabled) async {
await _prefs.setBool(_keyEnableDebugLogging, enabled); await prefs.setBool(_keyEnableDebugLogging, enabled);
// Update logger level immediately when setting changes // Update logger level immediately when setting changes
setLoggerLevel(enabled); setLoggerLevel(enabled);
} }
bool getEnableDebugLogging() { bool getEnableDebugLogging() {
return _prefs.getBool(_keyEnableDebugLogging) ?? false; return prefs.getBool(_keyEnableDebugLogging) ?? false;
} }
// Buffer Size (in MB) // Buffer Size (in MB)
Future<void> setBufferSize(int sizeInMB) async { Future<void> setBufferSize(int sizeInMB) async {
await _prefs.setInt(_keyBufferSize, sizeInMB); await prefs.setInt(_keyBufferSize, sizeInMB);
} }
int getBufferSize() { int getBufferSize() {
return _prefs.getInt(_keyBufferSize) ?? 128; // Default 128MB return prefs.getInt(_keyBufferSize) ?? 128; // Default 128MB
} }
// Hardware Decoding // Hardware Decoding
Future<void> setEnableHardwareDecoding(bool enabled) async { Future<void> setEnableHardwareDecoding(bool enabled) async {
await _prefs.setBool(_keyEnableHardwareDecoding, enabled); await prefs.setBool(_keyEnableHardwareDecoding, enabled);
} }
bool getEnableHardwareDecoding() { bool getEnableHardwareDecoding() {
return _prefs.getBool(_keyEnableHardwareDecoding) ?? return prefs.getBool(_keyEnableHardwareDecoding) ?? true; // Default enabled
true; // Default enabled
} }
// HDR (High Dynamic Range) // HDR (High Dynamic Range)
Future<void> setEnableHDR(bool enabled) async { Future<void> setEnableHDR(bool enabled) async {
await _prefs.setBool(_keyEnableHDR, enabled); await prefs.setBool(_keyEnableHDR, enabled);
} }
bool getEnableHDR() { bool getEnableHDR() {
return _prefs.getBool(_keyEnableHDR) ?? true; // Default enabled return prefs.getBool(_keyEnableHDR) ?? true; // Default enabled
} }
// Preferred Video Codec // Preferred Video Codec
Future<void> setPreferredVideoCodec(String codec) async { Future<void> setPreferredVideoCodec(String codec) async {
await _prefs.setString(_keyPreferredVideoCodec, codec); await prefs.setString(_keyPreferredVideoCodec, codec);
} }
String getPreferredVideoCodec() { String getPreferredVideoCodec() {
return _prefs.getString(_keyPreferredVideoCodec) ?? 'auto'; return prefs.getString(_keyPreferredVideoCodec) ?? 'auto';
} }
// Preferred Audio Codec // Preferred Audio Codec
Future<void> setPreferredAudioCodec(String codec) async { Future<void> setPreferredAudioCodec(String codec) async {
await _prefs.setString(_keyPreferredAudioCodec, codec); await prefs.setString(_keyPreferredAudioCodec, codec);
} }
String getPreferredAudioCodec() { String getPreferredAudioCodec() {
return _prefs.getString(_keyPreferredAudioCodec) ?? 'auto'; return prefs.getString(_keyPreferredAudioCodec) ?? 'auto';
} }
// Library Density // Library Density
Future<void> setLibraryDensity(LibraryDensity density) async { Future<void> setLibraryDensity(LibraryDensity density) async {
await _prefs.setString(_keyLibraryDensity, density.name); await prefs.setString(_keyLibraryDensity, density.name);
} }
LibraryDensity getLibraryDensity() { LibraryDensity getLibraryDensity() {
final densityString = _prefs.getString(_keyLibraryDensity); final densityString = prefs.getString(_keyLibraryDensity);
return LibraryDensity.values.firstWhere( return LibraryDensity.values.firstWhere(
(density) => density.name == densityString, (density) => density.name == densityString,
orElse: () => LibraryDensity.normal, orElse: () => LibraryDensity.normal,
@@ -148,11 +138,11 @@ class SettingsService {
// View Mode // View Mode
Future<void> setViewMode(ViewMode mode) async { Future<void> setViewMode(ViewMode mode) async {
await _prefs.setString(_keyViewMode, mode.name); await prefs.setString(_keyViewMode, mode.name);
} }
ViewMode getViewMode() { ViewMode getViewMode() {
final modeString = _prefs.getString(_keyViewMode); final modeString = prefs.getString(_keyViewMode);
return ViewMode.values.firstWhere( return ViewMode.values.firstWhere(
(mode) => mode.name == modeString, (mode) => mode.name == modeString,
orElse: () => ViewMode.grid, orElse: () => ViewMode.grid,
@@ -161,86 +151,86 @@ class SettingsService {
// Use Season Poster // Use Season Poster
Future<void> setUseSeasonPoster(bool enabled) async { Future<void> setUseSeasonPoster(bool enabled) async {
await _prefs.setBool(_keyUseSeasonPoster, enabled); await prefs.setBool(_keyUseSeasonPoster, enabled);
} }
bool getUseSeasonPoster() { bool getUseSeasonPoster() {
return _prefs.getBool(_keyUseSeasonPoster) ?? return prefs.getBool(_keyUseSeasonPoster) ??
false; // Default: false (use series poster) false; // Default: false (use series poster)
} }
// Show Hero Section // Show Hero Section
Future<void> setShowHeroSection(bool enabled) async { Future<void> setShowHeroSection(bool enabled) async {
await _prefs.setBool(_keyShowHeroSection, enabled); await prefs.setBool(_keyShowHeroSection, enabled);
} }
bool getShowHeroSection() { bool getShowHeroSection() {
return _prefs.getBool(_keyShowHeroSection) ?? return prefs.getBool(_keyShowHeroSection) ??
true; // Default: true (show hero section) true; // Default: true (show hero section)
} }
// Seek Time Small (in seconds) // Seek Time Small (in seconds)
Future<void> setSeekTimeSmall(int seconds) async { Future<void> setSeekTimeSmall(int seconds) async {
await _prefs.setInt(_keySeekTimeSmall, seconds); await prefs.setInt(_keySeekTimeSmall, seconds);
} }
int getSeekTimeSmall() { int getSeekTimeSmall() {
return _prefs.getInt(_keySeekTimeSmall) ?? 10; // Default: 10 seconds return prefs.getInt(_keySeekTimeSmall) ?? 10; // Default: 10 seconds
} }
// Seek Time Large (in seconds) // Seek Time Large (in seconds)
Future<void> setSeekTimeLarge(int seconds) async { Future<void> setSeekTimeLarge(int seconds) async {
await _prefs.setInt(_keySeekTimeLarge, seconds); await prefs.setInt(_keySeekTimeLarge, seconds);
} }
int getSeekTimeLarge() { int getSeekTimeLarge() {
return _prefs.getInt(_keySeekTimeLarge) ?? 30; // Default: 30 seconds return prefs.getInt(_keySeekTimeLarge) ?? 30; // Default: 30 seconds
} }
// Sleep Timer Duration (in minutes) // Sleep Timer Duration (in minutes)
Future<void> setSleepTimerDuration(int minutes) async { Future<void> setSleepTimerDuration(int minutes) async {
await _prefs.setInt(_keySleepTimerDuration, minutes); await prefs.setInt(_keySleepTimerDuration, minutes);
} }
int getSleepTimerDuration() { int getSleepTimerDuration() {
return _prefs.getInt(_keySleepTimerDuration) ?? 30; // Default: 30 minutes return prefs.getInt(_keySleepTimerDuration) ?? 30; // Default: 30 minutes
} }
// Audio Sync Offset (in milliseconds) // Audio Sync Offset (in milliseconds)
Future<void> setAudioSyncOffset(int milliseconds) async { Future<void> setAudioSyncOffset(int milliseconds) async {
await _prefs.setInt(_keyAudioSyncOffset, milliseconds); await prefs.setInt(_keyAudioSyncOffset, milliseconds);
} }
int getAudioSyncOffset() { 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) // Subtitle Sync Offset (in milliseconds)
Future<void> setSubtitleSyncOffset(int milliseconds) async { Future<void> setSubtitleSyncOffset(int milliseconds) async {
await _prefs.setInt(_keySubtitleSyncOffset, milliseconds); await prefs.setInt(_keySubtitleSyncOffset, milliseconds);
} }
int getSubtitleSyncOffset() { int getSubtitleSyncOffset() {
return _prefs.getInt(_keySubtitleSyncOffset) ?? return prefs.getInt(_keySubtitleSyncOffset) ??
0; // Default: 0ms (no offset) 0; // Default: 0ms (no offset)
} }
// Volume (0.0 to 100.0) // Volume (0.0 to 100.0)
Future<void> setVolume(double volume) async { Future<void> setVolume(double volume) async {
await _prefs.setDouble(_keyVolume, volume); await prefs.setDouble(_keyVolume, volume);
} }
double getVolume() { double getVolume() {
return _prefs.getDouble(_keyVolume) ?? 100.0; // Default: full volume return prefs.getDouble(_keyVolume) ?? 100.0; // Default: full volume
} }
// Rotation Lock (mobile only) // Rotation Lock (mobile only)
Future<void> setRotationLocked(bool locked) async { Future<void> setRotationLocked(bool locked) async {
await _prefs.setBool(_keyRotationLocked, locked); await prefs.setBool(_keyRotationLocked, locked);
} }
bool getRotationLocked() { bool getRotationLocked() {
return _prefs.getBool(_keyRotationLocked) ?? return prefs.getBool(_keyRotationLocked) ??
true; // Default: locked (landscape only) true; // Default: locked (landscape only)
} }
@@ -248,56 +238,56 @@ class SettingsService {
// Font Size (30-80, default 55) // Font Size (30-80, default 55)
Future<void> setSubtitleFontSize(int size) async { Future<void> setSubtitleFontSize(int size) async {
await _prefs.setInt(_keySubtitleFontSize, size); await prefs.setInt(_keySubtitleFontSize, size);
} }
int getSubtitleFontSize() { int getSubtitleFontSize() {
return _prefs.getInt(_keySubtitleFontSize) ?? 55; return prefs.getInt(_keySubtitleFontSize) ?? 55;
} }
// Text Color (hex format #RRGGBB, default white) // Text Color (hex format #RRGGBB, default white)
Future<void> setSubtitleTextColor(String color) async { Future<void> setSubtitleTextColor(String color) async {
await _prefs.setString(_keySubtitleTextColor, color); await prefs.setString(_keySubtitleTextColor, color);
} }
String getSubtitleTextColor() { String getSubtitleTextColor() {
return _prefs.getString(_keySubtitleTextColor) ?? '#FFFFFF'; return prefs.getString(_keySubtitleTextColor) ?? '#FFFFFF';
} }
// Border Size (0-5, default 3) // Border Size (0-5, default 3)
Future<void> setSubtitleBorderSize(int size) async { Future<void> setSubtitleBorderSize(int size) async {
await _prefs.setInt(_keySubtitleBorderSize, size); await prefs.setInt(_keySubtitleBorderSize, size);
} }
int getSubtitleBorderSize() { int getSubtitleBorderSize() {
return _prefs.getInt(_keySubtitleBorderSize) ?? 3; return prefs.getInt(_keySubtitleBorderSize) ?? 3;
} }
// Border Color (hex format #RRGGBB, default black) // Border Color (hex format #RRGGBB, default black)
Future<void> setSubtitleBorderColor(String color) async { Future<void> setSubtitleBorderColor(String color) async {
await _prefs.setString(_keySubtitleBorderColor, color); await prefs.setString(_keySubtitleBorderColor, color);
} }
String getSubtitleBorderColor() { String getSubtitleBorderColor() {
return _prefs.getString(_keySubtitleBorderColor) ?? '#000000'; return prefs.getString(_keySubtitleBorderColor) ?? '#000000';
} }
// Background Color (hex format #RRGGBB, default black) // Background Color (hex format #RRGGBB, default black)
Future<void> setSubtitleBackgroundColor(String color) async { Future<void> setSubtitleBackgroundColor(String color) async {
await _prefs.setString(_keySubtitleBackgroundColor, color); await prefs.setString(_keySubtitleBackgroundColor, color);
} }
String getSubtitleBackgroundColor() { String getSubtitleBackgroundColor() {
return _prefs.getString(_keySubtitleBackgroundColor) ?? '#000000'; return prefs.getString(_keySubtitleBackgroundColor) ?? '#000000';
} }
// Background Opacity (0-100, default 0 for transparent) // Background Opacity (0-100, default 0 for transparent)
Future<void> setSubtitleBackgroundOpacity(int opacity) async { Future<void> setSubtitleBackgroundOpacity(int opacity) async {
await _prefs.setInt(_keySubtitleBackgroundOpacity, opacity); await prefs.setInt(_keySubtitleBackgroundOpacity, opacity);
} }
int getSubtitleBackgroundOpacity() { int getSubtitleBackgroundOpacity() {
return _prefs.getInt(_keySubtitleBackgroundOpacity) ?? 0; return prefs.getInt(_keySubtitleBackgroundOpacity) ?? 0;
} }
// Keyboard Shortcuts (Legacy String-based) // Keyboard Shortcuts (Legacy String-based)
@@ -357,11 +347,11 @@ class SettingsService {
Future<void> setKeyboardShortcuts(Map<String, String> shortcuts) async { Future<void> setKeyboardShortcuts(Map<String, String> shortcuts) async {
final jsonString = json.encode(shortcuts); final jsonString = json.encode(shortcuts);
await _prefs.setString(_keyKeyboardShortcuts, jsonString); await prefs.setString(_keyKeyboardShortcuts, jsonString);
} }
Map<String, String> getKeyboardShortcuts() { Map<String, String> getKeyboardShortcuts() {
final jsonString = _prefs.getString(_keyKeyboardShortcuts); final jsonString = prefs.getString(_keyKeyboardShortcuts);
if (jsonString == null) return getDefaultKeyboardShortcuts(); if (jsonString == null) return getDefaultKeyboardShortcuts();
try { try {
@@ -401,11 +391,11 @@ class SettingsService {
serializedHotkeys[entry.key] = _serializeHotKey(entry.value); serializedHotkeys[entry.key] = _serializeHotKey(entry.value);
} }
final jsonString = json.encode(serializedHotkeys); final jsonString = json.encode(serializedHotkeys);
await _prefs.setString(_keyKeyboardHotkeys, jsonString); await prefs.setString(_keyKeyboardHotkeys, jsonString);
} }
Future<Map<String, HotKey>> getKeyboardHotkeys() async { Future<Map<String, HotKey>> getKeyboardHotkeys() async {
final jsonString = _prefs.getString(_keyKeyboardHotkeys); final jsonString = prefs.getString(_keyKeyboardHotkeys);
if (jsonString == null) { if (jsonString == null) {
return getDefaultKeyboardHotkeys(); return getDefaultKeyboardHotkeys();
} }
@@ -763,7 +753,7 @@ class SettingsService {
preferences[seriesRatingKey] = mediaIndex; preferences[seriesRatingKey] = mediaIndex;
final jsonString = json.encode(preferences); final jsonString = json.encode(preferences);
await _prefs.setString(_keyMediaVersionPreferences, jsonString); await prefs.setString(_keyMediaVersionPreferences, jsonString);
} }
/// Get saved media version preference for a series /// Get saved media version preference for a series
@@ -779,12 +769,12 @@ class SettingsService {
preferences.remove(seriesRatingKey); preferences.remove(seriesRatingKey);
final jsonString = json.encode(preferences); final jsonString = json.encode(preferences);
await _prefs.setString(_keyMediaVersionPreferences, jsonString); await prefs.setString(_keyMediaVersionPreferences, jsonString);
} }
/// Get all media version preferences /// Get all media version preferences
Map<String, int> _getMediaVersionPreferences() { Map<String, int> _getMediaVersionPreferences() {
final jsonString = _prefs.getString(_keyMediaVersionPreferences); final jsonString = prefs.getString(_keyMediaVersionPreferences);
if (jsonString == null) return {}; if (jsonString == null) return {};
final decoded = _decodeJsonStringToMap(jsonString); final decoded = _decodeJsonStringToMap(jsonString);
@@ -802,11 +792,11 @@ class SettingsService {
// App Locale // App Locale
Future<void> setAppLocale(AppLocale locale) async { Future<void> setAppLocale(AppLocale locale) async {
await _prefs.setString(_keyAppLocale, locale.languageCode); await prefs.setString(_keyAppLocale, locale.languageCode);
} }
AppLocale getAppLocale() { AppLocale getAppLocale() {
final localeString = _prefs.getString(_keyAppLocale); final localeString = prefs.getString(_keyAppLocale);
if (localeString == null) return AppLocale.en; // Default to English if (localeString == null) return AppLocale.en; // Default to English
return AppLocale.values.firstWhere( return AppLocale.values.firstWhere(
@@ -819,71 +809,71 @@ class SettingsService {
/// Remember Track Selections - Save per-media audio/subtitle language preferences /// Remember Track Selections - Save per-media audio/subtitle language preferences
Future<void> setRememberTrackSelections(bool value) async { Future<void> setRememberTrackSelections(bool value) async {
await _prefs.setBool(_keyRememberTrackSelections, value); await prefs.setBool(_keyRememberTrackSelections, value);
} }
bool getRememberTrackSelections() { bool getRememberTrackSelections() {
return _prefs.getBool(_keyRememberTrackSelections) ?? true; return prefs.getBool(_keyRememberTrackSelections) ?? true;
} }
// Auto Skip Intro // Auto Skip Intro
Future<void> setAutoSkipIntro(bool value) async { Future<void> setAutoSkipIntro(bool value) async {
await _prefs.setBool(_keyAutoSkipIntro, value); await prefs.setBool(_keyAutoSkipIntro, value);
} }
bool getAutoSkipIntro() { bool getAutoSkipIntro() {
return _prefs.getBool(_keyAutoSkipIntro) ?? true; // Default: enabled return prefs.getBool(_keyAutoSkipIntro) ?? true; // Default: enabled
} }
// Auto Skip Credits // Auto Skip Credits
Future<void> setAutoSkipCredits(bool value) async { Future<void> setAutoSkipCredits(bool value) async {
await _prefs.setBool(_keyAutoSkipCredits, value); await prefs.setBool(_keyAutoSkipCredits, value);
} }
bool getAutoSkipCredits() { bool getAutoSkipCredits() {
return _prefs.getBool(_keyAutoSkipCredits) ?? true; // Default: enabled return prefs.getBool(_keyAutoSkipCredits) ?? true; // Default: enabled
} }
// Auto Skip Delay (in seconds) // Auto Skip Delay (in seconds)
Future<void> setAutoSkipDelay(int seconds) async { Future<void> setAutoSkipDelay(int seconds) async {
await _prefs.setInt(_keyAutoSkipDelay, seconds); await prefs.setInt(_keyAutoSkipDelay, seconds);
} }
int getAutoSkipDelay() { int getAutoSkipDelay() {
return _prefs.getInt(_keyAutoSkipDelay) ?? 5; // Default: 5 seconds return prefs.getInt(_keyAutoSkipDelay) ?? 5; // Default: 5 seconds
} }
// Reset all settings to defaults // Reset all settings to defaults
Future<void> resetAllSettings() async { Future<void> resetAllSettings() async {
await Future.wait([ await Future.wait([
_prefs.remove(_keyThemeMode), prefs.remove(_keyThemeMode),
_prefs.remove(_keyEnableDebugLogging), prefs.remove(_keyEnableDebugLogging),
_prefs.remove(_keyBufferSize), prefs.remove(_keyBufferSize),
_prefs.remove(_keyKeyboardShortcuts), prefs.remove(_keyKeyboardShortcuts),
_prefs.remove(_keyKeyboardHotkeys), prefs.remove(_keyKeyboardHotkeys),
_prefs.remove(_keyEnableHardwareDecoding), prefs.remove(_keyEnableHardwareDecoding),
_prefs.remove(_keyEnableHDR), prefs.remove(_keyEnableHDR),
_prefs.remove(_keyPreferredVideoCodec), prefs.remove(_keyPreferredVideoCodec),
_prefs.remove(_keyPreferredAudioCodec), prefs.remove(_keyPreferredAudioCodec),
_prefs.remove(_keyLibraryDensity), prefs.remove(_keyLibraryDensity),
_prefs.remove(_keyViewMode), prefs.remove(_keyViewMode),
_prefs.remove(_keyUseSeasonPoster), prefs.remove(_keyUseSeasonPoster),
_prefs.remove(_keyShowHeroSection), prefs.remove(_keyShowHeroSection),
_prefs.remove(_keySeekTimeSmall), prefs.remove(_keySeekTimeSmall),
_prefs.remove(_keySeekTimeLarge), prefs.remove(_keySeekTimeLarge),
_prefs.remove(_keyMediaVersionPreferences), prefs.remove(_keyMediaVersionPreferences),
_prefs.remove(_keySleepTimerDuration), prefs.remove(_keySleepTimerDuration),
_prefs.remove(_keyAudioSyncOffset), prefs.remove(_keyAudioSyncOffset),
_prefs.remove(_keySubtitleSyncOffset), prefs.remove(_keySubtitleSyncOffset),
_prefs.remove(_keyVolume), prefs.remove(_keyVolume),
_prefs.remove(_keySubtitleFontSize), prefs.remove(_keySubtitleFontSize),
_prefs.remove(_keySubtitleTextColor), prefs.remove(_keySubtitleTextColor),
_prefs.remove(_keySubtitleBorderSize), prefs.remove(_keySubtitleBorderSize),
_prefs.remove(_keySubtitleBorderColor), prefs.remove(_keySubtitleBorderColor),
_prefs.remove(_keySubtitleBackgroundColor), prefs.remove(_keySubtitleBackgroundColor),
_prefs.remove(_keySubtitleBackgroundOpacity), prefs.remove(_keySubtitleBackgroundOpacity),
_prefs.remove(_keyAppLocale), prefs.remove(_keyAppLocale),
_prefs.remove(_keyRememberTrackSelections), prefs.remove(_keyRememberTrackSelections),
]); ]);
} }
+69 -75
View File
@@ -1,10 +1,9 @@
import 'dart:convert'; import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../utils/log_redaction_manager.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 _keyServerUrl = 'server_url';
static const String _keyToken = 'token'; static const String _keyToken = 'token';
static const String _keyPlexToken = 'plex_token'; static const String _keyPlexToken = 'plex_token';
@@ -23,21 +22,16 @@ class StorageService {
static const String _keyEnabledServers = 'enabled_servers'; static const String _keyEnabledServers = 'enabled_servers';
static const String _keyServerOrder = 'server_order'; static const String _keyServerOrder = 'server_order';
static StorageService? _instance;
late SharedPreferences _prefs;
StorageService._(); StorageService._();
static Future<StorageService> getInstance() async { static Future<StorageService> getInstance() async {
if (_instance == null) { return BaseSharedPreferencesService.initializeInstance(
_instance = StorageService._(); () => StorageService._(),
await _instance!._init(); );
}
return _instance!;
} }
Future<void> _init() async { @override
_prefs = await SharedPreferences.getInstance(); Future<void> onInit() async {
// Seed known values so logs can redact immediately on startup. // Seed known values so logs can redact immediately on startup.
LogRedactionManager.registerServerUrl(getServerUrl()); LogRedactionManager.registerServerUrl(getServerUrl());
LogRedactionManager.registerToken(getToken()); LogRedactionManager.registerToken(getToken());
@@ -46,36 +40,36 @@ class StorageService {
// Server URL // Server URL
Future<void> saveServerUrl(String url) async { Future<void> saveServerUrl(String url) async {
await _prefs.setString(_keyServerUrl, url); await prefs.setString(_keyServerUrl, url);
LogRedactionManager.registerServerUrl(url); LogRedactionManager.registerServerUrl(url);
} }
String? getServerUrl() { String? getServerUrl() {
return _prefs.getString(_keyServerUrl); return prefs.getString(_keyServerUrl);
} }
// Per-Server Endpoint URL (for multi-server connection caching) // Per-Server Endpoint URL (for multi-server connection caching)
Future<void> saveServerEndpoint(String serverId, String url) async { Future<void> saveServerEndpoint(String serverId, String url) async {
await _prefs.setString('server_endpoint_$serverId', url); await prefs.setString('server_endpoint_$serverId', url);
LogRedactionManager.registerServerUrl(url); LogRedactionManager.registerServerUrl(url);
} }
String? getServerEndpoint(String serverId) { String? getServerEndpoint(String serverId) {
return _prefs.getString('server_endpoint_$serverId'); return prefs.getString('server_endpoint_$serverId');
} }
Future<void> clearServerEndpoint(String serverId) async { Future<void> clearServerEndpoint(String serverId) async {
await _prefs.remove('server_endpoint_$serverId'); await prefs.remove('server_endpoint_$serverId');
} }
// Server Access Token // Server Access Token
Future<void> saveToken(String token) async { Future<void> saveToken(String token) async {
await _prefs.setString(_keyToken, token); await prefs.setString(_keyToken, token);
LogRedactionManager.registerToken(token); LogRedactionManager.registerToken(token);
} }
String? getToken() { String? getToken() {
return _prefs.getString(_keyToken); return prefs.getString(_keyToken);
} }
// Alias for server access token for clarity // Alias for server access token for clarity
@@ -89,18 +83,18 @@ class StorageService {
// Plex.tv Token (for API access) // Plex.tv Token (for API access)
Future<void> savePlexToken(String token) async { Future<void> savePlexToken(String token) async {
await _prefs.setString(_keyPlexToken, token); await prefs.setString(_keyPlexToken, token);
LogRedactionManager.registerToken(token); LogRedactionManager.registerToken(token);
} }
String? getPlexToken() { String? getPlexToken() {
return _prefs.getString(_keyPlexToken); return prefs.getString(_keyPlexToken);
} }
// Server Data (full PlexServer object as JSON) // Server Data (full PlexServer object as JSON)
Future<void> saveServerData(Map<String, dynamic> serverJson) async { Future<void> saveServerData(Map<String, dynamic> serverJson) async {
final jsonString = json.encode(serverJson); final jsonString = json.encode(serverJson);
await _prefs.setString(_keyServerData, jsonString); await prefs.setString(_keyServerData, jsonString);
} }
Map<String, dynamic>? getServerData() { Map<String, dynamic>? getServerData() {
@@ -109,11 +103,11 @@ class StorageService {
// Client Identifier // Client Identifier
Future<void> saveClientIdentifier(String clientId) async { Future<void> saveClientIdentifier(String clientId) async {
await _prefs.setString(_keyClientId, clientId); await prefs.setString(_keyClientId, clientId);
} }
String? getClientIdentifier() { String? getClientIdentifier() {
return _prefs.getString(_keyClientId); return prefs.getString(_keyClientId);
} }
// Save all credentials at once // Save all credentials at once
@@ -137,15 +131,15 @@ class StorageService {
// Clear all credentials // Clear all credentials
Future<void> clearCredentials() async { Future<void> clearCredentials() async {
await Future.wait([ await Future.wait([
_prefs.remove(_keyServerUrl), prefs.remove(_keyServerUrl),
_prefs.remove(_keyToken), prefs.remove(_keyToken),
_prefs.remove(_keyPlexToken), prefs.remove(_keyPlexToken),
_prefs.remove(_keyServerData), prefs.remove(_keyServerData),
_prefs.remove(_keyClientId), prefs.remove(_keyClientId),
_prefs.remove(_keyUserProfile), prefs.remove(_keyUserProfile),
_prefs.remove(_keyCurrentUserUUID), prefs.remove(_keyCurrentUserUUID),
_prefs.remove(_keyHomeUsersCache), prefs.remove(_keyHomeUsersCache),
_prefs.remove(_keyHomeUsersCacheExpiry), prefs.remove(_keyHomeUsersCacheExpiry),
clearMultiServerData(), clearMultiServerData(),
]); ]);
LogRedactionManager.clearTrackedValues(); LogRedactionManager.clearTrackedValues();
@@ -161,16 +155,16 @@ class StorageService {
} }
int? getSelectedLibraryIndex() { int? getSelectedLibraryIndex() {
return _prefs.getInt(_keySelectedLibraryIndex); return prefs.getInt(_keySelectedLibraryIndex);
} }
// Selected Library Key (replaces index-based selection) // Selected Library Key (replaces index-based selection)
Future<void> saveSelectedLibraryKey(String key) async { Future<void> saveSelectedLibraryKey(String key) async {
await _prefs.setString(_keySelectedLibraryKey, key); await prefs.setString(_keySelectedLibraryKey, key);
} }
String? getSelectedLibraryKey() { String? getSelectedLibraryKey() {
return _prefs.getString(_keySelectedLibraryKey); return prefs.getString(_keySelectedLibraryKey);
} }
// Library Filters (stored as JSON string) // Library Filters (stored as JSON string)
@@ -182,7 +176,7 @@ class StorageService {
final key = sectionId != null final key = sectionId != null
? 'library_filters_$sectionId' ? 'library_filters_$sectionId'
: _keyLibraryFilters; : _keyLibraryFilters;
await _prefs.setString(key, jsonString); await prefs.setString(key, jsonString);
} }
Map<String, String> getLibraryFilters({String? sectionId}) { Map<String, String> getLibraryFilters({String? sectionId}) {
@@ -192,9 +186,9 @@ class StorageService {
// Prefer per-library filters when available // Prefer per-library filters when available
final jsonString = final jsonString =
_prefs.getString(scopedKey) ?? prefs.getString(scopedKey) ??
// Legacy support: fall back to global filters if present // Legacy support: fall back to global filters if present
_prefs.getString(_keyLibraryFilters); prefs.getString(_keyLibraryFilters);
if (jsonString == null) return {}; if (jsonString == null) return {};
final decoded = _decodeJsonStringToMap(jsonString); final decoded = _decodeJsonStringToMap(jsonString);
@@ -208,7 +202,7 @@ class StorageService {
bool descending = false, bool descending = false,
}) async { }) async {
final sortData = {'key': sortKey, 'descending': descending}; 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<String, dynamic>? getLibrarySort(String sectionId) { Map<String, dynamic>? getLibrarySort(String sectionId) {
@@ -217,31 +211,31 @@ class StorageService {
// Library Grouping (per-library, e.g., 'movies', 'shows', 'seasons', 'episodes') // Library Grouping (per-library, e.g., 'movies', 'shows', 'seasons', 'episodes')
Future<void> saveLibraryGrouping(String sectionId, String grouping) async { Future<void> saveLibraryGrouping(String sectionId, String grouping) async {
await _prefs.setString('library_grouping_$sectionId', grouping); await prefs.setString('library_grouping_$sectionId', grouping);
} }
String? getLibraryGrouping(String sectionId) { 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) // Library Tab (per-library, saves last selected tab index)
Future<void> saveLibraryTab(String sectionId, int tabIndex) async { Future<void> saveLibraryTab(String sectionId, int tabIndex) async {
await _prefs.setInt('library_tab_$sectionId', tabIndex); await prefs.setInt('library_tab_$sectionId', tabIndex);
} }
int? getLibraryTab(String sectionId) { 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) // Hidden Libraries (stored as JSON array of library section IDs)
Future<void> saveHiddenLibraries(Set<String> libraryKeys) async { Future<void> saveHiddenLibraries(Set<String> libraryKeys) async {
final list = libraryKeys.toList(); final list = libraryKeys.toList();
final jsonString = json.encode(list); final jsonString = json.encode(list);
await _prefs.setString(_keyHiddenLibraries, jsonString); await prefs.setString(_keyHiddenLibraries, jsonString);
} }
Set<String> getHiddenLibraries() { Set<String> getHiddenLibraries() {
final jsonString = _prefs.getString(_keyHiddenLibraries); final jsonString = prefs.getString(_keyHiddenLibraries);
if (jsonString == null) return {}; if (jsonString == null) return {};
try { try {
@@ -255,22 +249,22 @@ class StorageService {
// Clear library preferences // Clear library preferences
Future<void> clearLibraryPreferences() async { Future<void> clearLibraryPreferences() async {
await Future.wait([ await Future.wait([
_prefs.remove(_keySelectedLibraryIndex), prefs.remove(_keySelectedLibraryIndex),
_prefs.remove(_keyLibraryFilters), prefs.remove(_keyLibraryFilters),
_prefs.remove(_keyLibraryOrder), prefs.remove(_keyLibraryOrder),
_prefs.remove(_keyHiddenLibraries), prefs.remove(_keyHiddenLibraries),
]); ]);
// Also clear all library sort preferences // Also clear all library sort preferences
final keys = _prefs.getKeys(); final keys = prefs.getKeys();
final sortKeys = keys.where((key) => key.startsWith('library_sort_')); 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) // Library Order (stored as JSON list of library keys)
Future<void> saveLibraryOrder(List<String> libraryKeys) async { Future<void> saveLibraryOrder(List<String> libraryKeys) async {
final jsonString = json.encode(libraryKeys); final jsonString = json.encode(libraryKeys);
await _prefs.setString(_keyLibraryOrder, jsonString); await prefs.setString(_keyLibraryOrder, jsonString);
} }
List<String>? getLibraryOrder() => _getStringList(_keyLibraryOrder); List<String>? getLibraryOrder() => _getStringList(_keyLibraryOrder);
@@ -278,7 +272,7 @@ class StorageService {
// User Profile (stored as JSON string) // User Profile (stored as JSON string)
Future<void> saveUserProfile(Map<String, dynamic> profileJson) async { Future<void> saveUserProfile(Map<String, dynamic> profileJson) async {
final jsonString = json.encode(profileJson); final jsonString = json.encode(profileJson);
await _prefs.setString(_keyUserProfile, jsonString); await prefs.setString(_keyUserProfile, jsonString);
} }
Map<String, dynamic>? getUserProfile() { Map<String, dynamic>? getUserProfile() {
@@ -287,27 +281,27 @@ class StorageService {
// Current User UUID // Current User UUID
Future<void> saveCurrentUserUUID(String uuid) async { Future<void> saveCurrentUserUUID(String uuid) async {
await _prefs.setString(_keyCurrentUserUUID, uuid); await prefs.setString(_keyCurrentUserUUID, uuid);
} }
String? getCurrentUserUUID() { String? getCurrentUserUUID() {
return _prefs.getString(_keyCurrentUserUUID); return prefs.getString(_keyCurrentUserUUID);
} }
// Home Users Cache (stored as JSON string with expiry) // Home Users Cache (stored as JSON string with expiry)
Future<void> saveHomeUsersCache(Map<String, dynamic> homeData) async { Future<void> saveHomeUsersCache(Map<String, dynamic> homeData) async {
final jsonString = json.encode(homeData); final jsonString = json.encode(homeData);
await _prefs.setString(_keyHomeUsersCache, jsonString); await prefs.setString(_keyHomeUsersCache, jsonString);
// Set cache expiry to 1 hour from now // Set cache expiry to 1 hour from now
final expiry = DateTime.now() final expiry = DateTime.now()
.add(const Duration(hours: 1)) .add(const Duration(hours: 1))
.millisecondsSinceEpoch; .millisecondsSinceEpoch;
await _prefs.setInt(_keyHomeUsersCacheExpiry, expiry); await prefs.setInt(_keyHomeUsersCacheExpiry, expiry);
} }
Map<String, dynamic>? getHomeUsersCache() { Map<String, dynamic>? getHomeUsersCache() {
final expiry = _prefs.getInt(_keyHomeUsersCacheExpiry); final expiry = prefs.getInt(_keyHomeUsersCacheExpiry);
if (expiry == null || DateTime.now().millisecondsSinceEpoch > expiry) { if (expiry == null || DateTime.now().millisecondsSinceEpoch > expiry) {
// Cache expired, clear it // Cache expired, clear it
clearHomeUsersCache(); clearHomeUsersCache();
@@ -319,14 +313,14 @@ class StorageService {
Future<void> clearHomeUsersCache() async { Future<void> clearHomeUsersCache() async {
await Future.wait([ await Future.wait([
_prefs.remove(_keyHomeUsersCache), prefs.remove(_keyHomeUsersCache),
_prefs.remove(_keyHomeUsersCacheExpiry), prefs.remove(_keyHomeUsersCacheExpiry),
]); ]);
} }
// Clear current user UUID (for server switching) // Clear current user UUID (for server switching)
Future<void> clearCurrentUserUUID() async { Future<void> clearCurrentUserUUID() async {
await _prefs.remove(_keyCurrentUserUUID); await prefs.remove(_keyCurrentUserUUID);
} }
// Clear all user-related data (for logout) // Clear all user-related data (for logout)
@@ -346,38 +340,38 @@ class StorageService {
/// Get servers list as JSON string /// Get servers list as JSON string
String? getServersListJson() { String? getServersListJson() {
return _prefs.getString(_keyServersList); return prefs.getString(_keyServersList);
} }
/// Save servers list as JSON string /// Save servers list as JSON string
Future<void> saveServersListJson(String serversJson) async { Future<void> saveServersListJson(String serversJson) async {
await _prefs.setString(_keyServersList, serversJson); await prefs.setString(_keyServersList, serversJson);
} }
/// Get enabled servers as JSON string /// Get enabled servers as JSON string
String? getEnabledServersJson() { String? getEnabledServersJson() {
return _prefs.getString(_keyEnabledServers); return prefs.getString(_keyEnabledServers);
} }
/// Save enabled servers as JSON string /// Save enabled servers as JSON string
Future<void> saveEnabledServersJson(String enabledJson) async { Future<void> saveEnabledServersJson(String enabledJson) async {
await _prefs.setString(_keyEnabledServers, enabledJson); await prefs.setString(_keyEnabledServers, enabledJson);
} }
/// Clear servers list /// Clear servers list
Future<void> clearServersList() async { Future<void> clearServersList() async {
await _prefs.remove(_keyServersList); await prefs.remove(_keyServersList);
} }
/// Clear enabled servers /// Clear enabled servers
Future<void> clearEnabledServers() async { Future<void> clearEnabledServers() async {
await _prefs.remove(_keyEnabledServers); await prefs.remove(_keyEnabledServers);
} }
/// Clear all multi-server data /// Clear all multi-server data
Future<void> clearMultiServerData() async { Future<void> clearMultiServerData() async {
// Clear all server endpoint caches // Clear all server endpoint caches
final keys = _prefs.getKeys(); final keys = prefs.getKeys();
final endpointKeys = keys.where( final endpointKeys = keys.where(
(key) => key.startsWith('server_endpoint_'), (key) => key.startsWith('server_endpoint_'),
); );
@@ -386,28 +380,28 @@ class StorageService {
clearServersList(), clearServersList(),
clearEnabledServers(), clearEnabledServers(),
clearServerOrder(), clearServerOrder(),
...endpointKeys.map((key) => _prefs.remove(key)), ...endpointKeys.map((key) => prefs.remove(key)),
]); ]);
} }
/// Server Order (stored as JSON list of server IDs) /// Server Order (stored as JSON list of server IDs)
Future<void> saveServerOrder(List<String> serverIds) async { Future<void> saveServerOrder(List<String> serverIds) async {
final jsonString = json.encode(serverIds); final jsonString = json.encode(serverIds);
await _prefs.setString(_keyServerOrder, jsonString); await prefs.setString(_keyServerOrder, jsonString);
} }
List<String>? getServerOrder() => _getStringList(_keyServerOrder); List<String>? getServerOrder() => _getStringList(_keyServerOrder);
/// Clear server order /// Clear server order
Future<void> clearServerOrder() async { Future<void> clearServerOrder() async {
await _prefs.remove(_keyServerOrder); await prefs.remove(_keyServerOrder);
} }
// Private helper methods // Private helper methods
/// Helper to read and decode JSON `List<String>` from preferences /// Helper to read and decode JSON `List<String>` from preferences
List<String>? _getStringList(String key) { List<String>? _getStringList(String key) {
final jsonString = _prefs.getString(key); final jsonString = prefs.getString(key);
if (jsonString == null) return null; if (jsonString == null) return null;
try { try {
@@ -427,7 +421,7 @@ class StorageService {
String key, { String key, {
bool legacyStringOk = false, bool legacyStringOk = false,
}) { }) {
final jsonString = _prefs.getString(key); final jsonString = prefs.getString(key);
if (jsonString == null) return null; if (jsonString == null) return null;
return _decodeJsonStringToMap(jsonString, legacyStringOk: legacyStringOk); return _decodeJsonStringToMap(jsonString, legacyStringOk: legacyStringOk);
+7 -6
View File
@@ -6,13 +6,11 @@ import '../services/fullscreen_state_manager.dart';
/// When present, app bars should skip their left padding since the side nav /// When present, app bars should skip their left padding since the side nav
/// already handles the macOS traffic lights area. /// already handles the macOS traffic lights area.
class SideNavigationScope extends InheritedWidget { class SideNavigationScope extends InheritedWidget {
const SideNavigationScope({ const SideNavigationScope({super.key, required super.child});
super.key,
required super.child,
});
static bool isPresent(BuildContext context) { static bool isPresent(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<SideNavigationScope>() != null; return context.dependOnInheritedWidgetOfExactType<SideNavigationScope>() !=
null;
} }
@override @override
@@ -126,7 +124,10 @@ class DesktopAppBarHelper {
/// Calculates the leading width for SliverAppBar to account for macOS traffic lights /// Calculates the leading width for SliverAppBar to account for macOS traffic lights
/// [context] - Required to check if side navigation is visible /// [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) { if (!Platform.isMacOS || leading == null) {
return null; return null;
} }
+60
View File
@@ -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<void> 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<bool>(
context,
MaterialPageRoute(
builder: (context) => MediaDetailScreen(metadata: item),
),
);
if (result == true) {
onRefresh?.call(item.ratingKey);
}
}
}
@@ -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<T>({
required BuildContext context,
required List<T> 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),
),
);
}
}
+189 -65
View File
@@ -3,6 +3,102 @@ import '../utils/desktop_window_padding.dart';
import '../services/fullscreen_state_manager.dart'; import '../services/fullscreen_state_manager.dart';
import 'app_bar_back_button.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<Widget>? 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<Widget>? buildActionsSection(List<Widget>? 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. /// A custom sliver app bar that automatically handles desktop window controls spacing.
/// Use this instead of SliverAppBar for consistent desktop platform behavior. /// Use this instead of SliverAppBar for consistent desktop platform behavior.
class DesktopSliverAppBar extends StatelessWidget { class DesktopSliverAppBar extends StatelessWidget {
@@ -41,39 +137,21 @@ class DesktopSliverAppBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Determine the effective leading widget final effectiveLeading = DesktopAppBarSections.buildLeadingSection(
Widget? effectiveLeading = leading; leading: leading,
automaticallyImplyLeading: automaticallyImplyLeading,
// If no leading is provided but automaticallyImplyLeading is true, context: context,
// 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 SliverAppBar( return SliverAppBar(
title: title != null title: DesktopAppBarSections.buildTitleSection(
? DesktopTitleBarPadding( title: title,
leftPadding: effectiveLeading != null ? 0 : null, leading: effectiveLeading,
child: title!,
)
: null,
actions: DesktopAppBarHelper.buildAdjustedActions(actions),
leading: DesktopAppBarHelper.buildAdjustedLeading(
effectiveLeading,
includeGestureDetector: true,
context: context,
), ),
leadingWidth: DesktopAppBarHelper.calculateLeadingWidth( actions: DesktopAppBarSections.buildActionsSection(actions),
effectiveLeading, leading: effectiveLeading,
leadingWidth: DesktopAppBarSections.calculateLeadingWidthForSection(
leading: effectiveLeading,
context: context, context: context,
), ),
automaticallyImplyLeading: automaticallyImplyLeading:
@@ -86,7 +164,7 @@ class DesktopSliverAppBar extends StatelessWidget {
floating: floating, floating: floating,
pinned: pinned, pinned: pinned,
expandedHeight: expandedHeight, expandedHeight: expandedHeight,
flexibleSpace: DesktopAppBarHelper.buildAdjustedFlexibleSpace( flexibleSpace: DesktopAppBarSections.buildFlexibleSpaceSection(
flexibleSpace, flexibleSpace,
), ),
bottom: bottom, 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 { class CustomAppBar extends StatelessWidget {
final Widget? title; final Widget? title;
final List<Widget>? actions; final List<Widget>? actions;
@@ -129,39 +269,23 @@ class CustomAppBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListenableBuilder( return DesktopTopBar(
listenable: FullscreenStateManager(), config: DesktopAppBarConfig(
builder: (context, _) { title: title,
final isFullscreen = FullscreenStateManager().isFullscreen; actions: actions,
elevation: elevation,
return DesktopSliverAppBar( backgroundColor: backgroundColor,
key: ValueKey('plex_sliver_app_bar_$isFullscreen'), surfaceTintColor: surfaceTintColor,
title: title, shadowColor: shadowColor,
actions: actions, scrolledUnderElevation: scrolledUnderElevation,
leading: _shouldShowBackButton(context) floating: floating,
? AppBarBackButton( pinned: pinned,
style: BackButtonStyle.plain, expandedHeight: expandedHeight,
onPressed: onBackPressed, flexibleSpace: flexibleSpace,
) bottom: bottom,
: null, ),
automaticallyImplyLeading: false, onBackPressed: onBackPressed,
elevation: elevation, automaticallyImplyLeading: true,
backgroundColor: backgroundColor,
surfaceTintColor: surfaceTintColor,
shadowColor: shadowColor,
scrolledUnderElevation: scrolledUnderElevation,
floating: floating,
pinned: pinned,
expandedHeight: expandedHeight,
flexibleSpace: flexibleSpace,
bottom: bottom,
);
},
); );
} }
bool _shouldShowBackButton(BuildContext context) {
final parentRoute = ModalRoute.of(context);
return parentRoute?.canPop ?? false;
}
} }
+150
View File
@@ -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,
);
}
}
+38 -54
View File
@@ -2,8 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../focus/dpad_navigator.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/input_mode_tracker.dart';
import 'focus_builders.dart';
/// A focusable filter chip that shows a color change when focused. /// A focusable filter chip that shows a color change when focused.
/// ///
@@ -41,43 +42,32 @@ class FocusableFilterChip extends StatefulWidget {
State<FocusableFilterChip> createState() => _FocusableFilterChipState(); State<FocusableFilterChip> createState() => _FocusableFilterChipState();
} }
class _FocusableFilterChipState extends State<FocusableFilterChip> { class _FocusableFilterChipState extends State<FocusableFilterChip>
FocusNode? _internalFocusNode; with FocusableChipStateMixin<FocusableFilterChip> {
bool _isFocused = false; @override
FocusNode? get widgetFocusNode => widget.focusNode;
FocusNode get _focusNode { @override
return widget.focusNode ?? String get debugLabel => 'filter_chip_${widget.label}';
(_internalFocusNode ??= FocusNode(debugLabel: 'filter_chip_${widget.label}'));
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_focusNode.addListener(_onFocusChange); initFocusNode();
} }
@override @override
void didUpdateWidget(FocusableFilterChip oldWidget) { void didUpdateWidget(FocusableFilterChip oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.focusNode != widget.focusNode) { updateFocusNode(oldWidget.focusNode);
oldWidget.focusNode?.removeListener(_onFocusChange);
_focusNode.addListener(_onFocusChange);
}
} }
@override @override
void dispose() { void dispose() {
_focusNode.removeListener(_onFocusChange); disposeFocusNode();
_internalFocusNode?.dispose();
super.dispose(); super.dispose();
} }
void _onFocusChange() {
if (mounted) {
setState(() => _isFocused = _focusNode.hasFocus);
}
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) { if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
@@ -96,7 +86,8 @@ class _FocusableFilterChipState extends State<FocusableFilterChip> {
} }
// UP arrow navigates to tab bar // UP arrow navigates to tab bar
if (event.logicalKey == LogicalKeyboardKey.arrowUp && widget.onNavigateUp != null) { if (event.logicalKey == LogicalKeyboardKey.arrowUp &&
widget.onNavigateUp != null) {
widget.onNavigateUp!(); widget.onNavigateUp!();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
@@ -113,44 +104,37 @@ class _FocusableFilterChipState extends State<FocusableFilterChip> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final duration = FocusTheme.getAnimationDuration(context);
// Only show focus effects during keyboard/d-pad navigation // 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 // Use primary color when focused, surface color when not
final backgroundColor = final backgroundColor = showFocus
showFocus ? colorScheme.primary : colorScheme.surfaceContainerHighest; ? colorScheme.primary
final foregroundColor = : colorScheme.surfaceContainerHighest;
showFocus ? colorScheme.onPrimary : colorScheme.onSurfaceVariant; final foregroundColor = showFocus
? colorScheme.onPrimary
: colorScheme.onSurfaceVariant;
return Focus( return FocusBuilders.buildFocusableChip(
focusNode: _focusNode, context: context,
focusNode: focusNode,
isFocused: isFocused,
onKeyEvent: _handleKeyEvent, onKeyEvent: _handleKeyEvent,
child: GestureDetector( onTap: widget.onPressed,
onTap: widget.onPressed, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: AnimatedContainer( backgroundColor: backgroundColor,
duration: duration, child: Row(
curve: Curves.easeOutCubic, mainAxisSize: MainAxisSize.min,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), children: [
decoration: BoxDecoration( Icon(icon, size: 16, color: foregroundColor),
color: backgroundColor, const SizedBox(width: 6),
borderRadius: BorderRadius.circular(20), 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),
),
],
),
),
), ),
); );
} }
+22 -201
View File
@@ -1,18 +1,13 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../focus/dpad_navigator.dart'; import '../focus/focusable_wrapper.dart';
import '../focus/focus_theme.dart';
import '../focus/input_mode_tracker.dart';
import 'media_card.dart'; import 'media_card.dart';
/// A focusable wrapper for MediaCard that handles D-pad navigation. /// A focusable wrapper for MediaCard that handles D-pad navigation.
/// ///
/// Wraps MediaCard with focus handling for TV/desktop navigation: /// Wraps MediaCard with focus handling for TV/desktop navigation:
/// - Shows scale + border decoration when focused /// - 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 /// - Accepts optional external focusNode for programmatic focus control
class FocusableMediaCard extends StatefulWidget { class FocusableMediaCard extends StatefulWidget {
final dynamic item; // PlexMetadata or PlexPlaylist final dynamic item; // PlexMetadata or PlexPlaylist
@@ -58,205 +53,31 @@ class FocusableMediaCard extends StatefulWidget {
} }
class _FocusableMediaCardState extends State<FocusableMediaCard> { class _FocusableMediaCardState extends State<FocusableMediaCard> {
FocusNode? _internalFocusNode;
bool _isFocused = false;
// Key for accessing MediaCard's state // Key for accessing MediaCard's state
final GlobalKey<MediaCardState> _mediaCardKey = GlobalKey(); final GlobalKey<MediaCardState> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final duration = FocusTheme.getAnimationDuration(context); return FocusableWrapper(
// Only show focus effects during keyboard/d-pad navigation focusNode: widget.focusNode,
final showFocus = _isFocused && InputModeTracker.isKeyboardMode(context); onSelect: () => _mediaCardKey.currentState?.handleTap(),
onLongPress: () => _mediaCardKey.currentState?.showContextMenu(),
return Focus( onNavigateUp: widget.onNavigateUp,
focusNode: _focusNode, onBack: widget.onBack,
onKeyEvent: _handleKeyEvent, enableLongPress: true,
child: AnimatedScale( useComfortableZone: true,
scale: showFocus ? FocusTheme.focusScale : 1.0, scrollAlignment: 0.5,
duration: duration, child: MediaCard(
curve: Curves.easeOutCubic, key: _mediaCardKey,
child: AnimatedContainer( item: widget.item,
duration: duration, width: widget.width,
curve: Curves.easeOutCubic, height: widget.height,
decoration: FocusTheme.focusDecoration( onRefresh: widget.onRefresh,
context, onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
isFocused: showFocus, onListRefresh: widget.onListRefresh,
borderRadius: FocusTheme.defaultBorderRadius, forceGridMode: widget.forceGridMode,
), isInContinueWatching: widget.isInContinueWatching,
child: MediaCard( collectionId: widget.collectionId,
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,
),
),
), ),
); );
} }
+24 -42
View File
@@ -2,8 +2,9 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../focus/dpad_navigator.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/input_mode_tracker.dart';
import 'focus_builders.dart';
/// A focusable tab chip that shows a color change when focused or selected. /// A focusable tab chip that shows a color change when focused or selected.
/// ///
@@ -50,43 +51,32 @@ class FocusableTabChip extends StatefulWidget {
State<FocusableTabChip> createState() => _FocusableTabChipState(); State<FocusableTabChip> createState() => _FocusableTabChipState();
} }
class _FocusableTabChipState extends State<FocusableTabChip> { class _FocusableTabChipState extends State<FocusableTabChip>
FocusNode? _internalFocusNode; with FocusableChipStateMixin<FocusableTabChip> {
bool _isFocused = false; @override
FocusNode? get widgetFocusNode => widget.focusNode;
FocusNode get _focusNode { @override
return widget.focusNode ?? String get debugLabel => 'tab_chip_${widget.label}';
(_internalFocusNode ??= FocusNode(debugLabel: 'tab_chip_${widget.label}'));
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_focusNode.addListener(_onFocusChange); initFocusNode();
} }
@override @override
void didUpdateWidget(FocusableTabChip oldWidget) { void didUpdateWidget(FocusableTabChip oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.focusNode != widget.focusNode) { updateFocusNode(oldWidget.focusNode);
oldWidget.focusNode?.removeListener(_onFocusChange);
_focusNode.addListener(_onFocusChange);
}
} }
@override @override
void dispose() { void dispose() {
_focusNode.removeListener(_onFocusChange); disposeFocusNode();
_internalFocusNode?.dispose();
super.dispose(); super.dispose();
} }
void _onFocusChange() {
if (mounted) {
setState(() => _isFocused = _focusNode.hasFocus);
}
}
KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
if (event is! KeyDownEvent && event is! KeyRepeatEvent) { if (event is! KeyDownEvent && event is! KeyRepeatEvent) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
@@ -130,9 +120,8 @@ class _FocusableTabChipState extends State<FocusableTabChip> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final duration = FocusTheme.getAnimationDuration(context);
// Only show focus effects during keyboard/d-pad navigation // 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 // Determine background color based on focus and selection state
// - Selected + Focused: slightly dimmed primary (to show focus distinction) // - Selected + Focused: slightly dimmed primary (to show focus distinction)
@@ -162,26 +151,19 @@ class _FocusableTabChipState extends State<FocusableTabChip> {
final isHighlighted = showFocus || widget.isSelected; final isHighlighted = showFocus || widget.isSelected;
return Focus( return FocusBuilders.buildFocusableChip(
focusNode: _focusNode, context: context,
focusNode: focusNode,
isFocused: isFocused,
onKeyEvent: _handleKeyEvent, onKeyEvent: _handleKeyEvent,
child: GestureDetector( onTap: widget.onSelect,
onTap: widget.onSelect, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: AnimatedContainer( backgroundColor: backgroundColor,
duration: duration, child: Text(
curve: Curves.easeOutCubic, widget.label,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), style: Theme.of(context).textTheme.labelLarge?.copyWith(
decoration: BoxDecoration( color: foregroundColor,
color: backgroundColor, fontWeight: isHighlighted ? FontWeight.w600 : FontWeight.normal,
borderRadius: BorderRadius.circular(20),
),
child: Text(
widget.label,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: foregroundColor,
fontWeight: isHighlighted ? FontWeight.w600 : FontWeight.normal,
),
),
), ),
), ),
); );
+51
View File
@@ -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<Widget> slivers;
/// Optional actions to display in the app bar (e.g., IconButton widgets).
final List<Widget>? 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,
],
),
),
);
}
}
+35 -38
View File
@@ -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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final child = widget.builder(_scrollController); final child = widget.builder(_scrollController);
@@ -101,45 +126,17 @@ class _HorizontalScrollWithArrowsState
child: Stack( child: Stack(
children: [ children: [
child, child,
// Left arrow _buildArrowButton(
Positioned( position: 8,
left: 8, icon: Icons.chevron_left,
top: 0, onPressed: _scrollLeft,
bottom: 0, canScroll: _canScrollLeft,
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,
),
),
),
),
), ),
// Right arrow _buildArrowButton(
Positioned( position: -8,
right: 8, icon: Icons.chevron_right,
top: 0, onPressed: _scrollRight,
bottom: 0, canScroll: _canScrollRight,
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,
),
),
),
),
), ),
], ],
), ),
+23 -82
View File
@@ -1,16 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import '../focus/dpad_navigator.dart'; import '../focus/dpad_navigator.dart';
import '../focus/focus_theme.dart';
import '../focus/input_mode_tracker.dart';
import '../focus/locked_hub_controller.dart'; import '../focus/locked_hub_controller.dart';
import '../models/plex_hub.dart'; import '../models/plex_hub.dart';
import '../models/plex_playlist.dart';
import '../screens/hub_detail_screen.dart'; import '../screens/hub_detail_screen.dart';
import '../screens/media_detail_screen.dart'; import '../utils/media_navigation_helper.dart';
import '../screens/season_detail_screen.dart'; import 'focus_builders.dart';
import '../screens/playlist/playlist_detail_screen.dart';
import '../utils/video_player_navigation.dart';
import 'media_card.dart'; import 'media_card.dart';
import 'horizontal_scroll_with_arrows.dart'; import 'horizontal_scroll_with_arrows.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
@@ -66,9 +61,7 @@ class HubSectionState extends State<HubSection> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_hubFocusNode = FocusNode( _hubFocusNode = FocusNode(debugLabel: 'hub_${widget.hub.hubKey}');
debugLabel: 'hub_${widget.hub.hubKey}',
);
_hubFocusNode.addListener(_onFocusChange); _hubFocusNode.addListener(_onFocusChange);
} }
@@ -77,7 +70,9 @@ class HubSectionState extends State<HubSection> {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
// Clamp focus index if item count changed // Clamp focus index if item count changed
if (widget.hub.items.length != oldWidget.hub.items.length) { 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) { if (_focusedIndex > maxIndex) {
_focusedIndex = maxIndex; _focusedIndex = maxIndex;
} }
@@ -146,7 +141,8 @@ class HubSectionState extends State<HubSection> {
if (!_scrollController.hasClients || _itemExtent <= 0) return; if (!_scrollController.hasClients || _itemExtent <= 0) return;
final viewport = _scrollController.position.viewportDimension; 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( final desiredOffset = (targetCenter - (viewport / 2)).clamp(
0.0, 0.0,
_scrollController.position.maxScrollExtent, _scrollController.position.maxScrollExtent,
@@ -249,45 +245,7 @@ class HubSectionState extends State<HubSection> {
} }
Future<void> _navigateToItem(dynamic item) async { Future<void> _navigateToItem(dynamic item) async {
// Handle playlists await navigateToMediaItem(context, item, onRefresh: widget.onRefresh);
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<bool>(
context,
MaterialPageRoute(
builder: (context) => MediaDetailScreen(metadata: item),
),
);
if (result == true) {
widget.onRefresh?.call(item.ratingKey);
}
}
} }
void _navigateToHubDetail(BuildContext context) { void _navigateToHubDetail(BuildContext context) {
@@ -310,13 +268,12 @@ class HubSectionState extends State<HubSection> {
padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), padding: const EdgeInsets.fromLTRB(16, 24, 16, 8),
child: ExcludeFocus( child: ExcludeFocus(
child: InkWell( child: InkWell(
onTap: widget.hub.more ? () => _navigateToHubDetail(context) : null, onTap: widget.hub.more
? () => _navigateToHubDetail(context)
: null,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
horizontal: 8,
vertical: 4,
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -353,10 +310,10 @@ class HubSectionState extends State<HubSection> {
final cardWidth = screenWidth > 1600 final cardWidth = screenWidth > 1600
? 220.0 ? 220.0
: screenWidth > 1200 : screenWidth > 1200
? 200.0 ? 200.0
: screenWidth > 800 : screenWidth > 800
? 190.0 ? 190.0
: 160.0; : 160.0;
// Store item extent for scroll calculations // Store item extent for scroll calculations
_itemExtent = cardWidth + 4; // 4px total horizontal padding _itemExtent = cardWidth + 4; // 4px total horizontal padding
@@ -382,7 +339,8 @@ class HubSectionState extends State<HubSection> {
itemCount: widget.hub.items.length, itemCount: widget.hub.items.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = widget.hub.items[index]; final item = widget.hub.items[index];
final isItemFocused = hasFocus && index == _focusedIndex; final isItemFocused =
hasFocus && index == _focusedIndex;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.symmetric(horizontal: 2),
@@ -465,29 +423,12 @@ class _LockedHubItemWrapperState extends State<_LockedHubItemWrapper> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final duration = FocusTheme.getAnimationDuration(context); return FocusBuilders.buildLockedFocusWrapper(
// Only show focus effects during keyboard/d-pad navigation context: context,
final showFocus = isFocused: widget.isFocused,
widget.isFocused && InputModeTracker.isKeyboardMode(context);
return GestureDetector(
onTap: widget.onTap, onTap: widget.onTap,
onLongPress: widget.onLongPress, onLongPress: widget.onLongPress,
child: AnimatedScale( child: widget.child,
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,
),
),
); );
} }
} }
+95 -86
View File
@@ -297,91 +297,15 @@ class _MediaCardGrid extends StatelessWidget {
), ),
), ),
if (item is PlexPlaylist) if (item is PlexPlaylist)
Builder( _MediaCardHelpers.buildPlaylistMeta(
builder: (context) { context,
final playlist = item as PlexPlaylist; item as PlexPlaylist,
if (playlist.leafCount != null &&
playlist.leafCount! > 0) {
return Text(
t.playlists.itemCount(count: playlist.leafCount!),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: tokens(context).textMuted,
fontSize: 11,
height: 1.1,
),
);
}
return const SizedBox.shrink();
},
) )
else if (item is PlexMetadata) ...[ else if (item is PlexMetadata)
Builder( _MediaCardHelpers.buildMetadataSubtitle(
builder: (context) { context,
final metadata = item as PlexMetadata; 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();
},
), ),
],
], ],
), ),
], ],
@@ -713,7 +637,7 @@ Widget _buildPosterImage(BuildContext context, dynamic item) {
posterUrl = item.displayImage; posterUrl = item.displayImage;
fallbackIcon = Icons.playlist_play; fallbackIcon = Icons.playlist_play;
return PlexPlaylistImage( return PlexOptimizedImage.playlist(
client: _getClientForItem(context, item), client: _getClientForItem(context, item),
imagePath: posterUrl, imagePath: posterUrl,
width: double.infinity, width: double.infinity,
@@ -724,7 +648,7 @@ Widget _buildPosterImage(BuildContext context, dynamic item) {
final useSeasonPoster = context.watch<SettingsProvider>().useSeasonPoster; final useSeasonPoster = context.watch<SettingsProvider>().useSeasonPoster;
posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster); posterUrl = item.posterThumb(useSeasonPoster: useSeasonPoster);
return PlexPosterImage( return PlexOptimizedImage.poster(
client: _getClientForItem(context, item), client: _getClientForItem(context, item),
imagePath: posterUrl, imagePath: posterUrl,
width: double.infinity, width: double.infinity,
@@ -751,8 +675,93 @@ class _PosterOverlay extends StatelessWidget {
return const SizedBox.shrink(); 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( return Stack(
children: [ children: [
// Watched indicator (checkmark) // Watched indicator (checkmark)
+39
View File
@@ -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,
);
}
}
+75
View File
@@ -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<PlexMetadata> 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<SettingsProvider>(
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),
);
},
),
);
}
}
+54 -57
View File
@@ -38,6 +38,60 @@ class PlexOptimizedImage extends StatelessWidget {
this.imageType = ImageType.poster, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
double resolvedDimension( double resolvedDimension(
@@ -176,60 +230,3 @@ class PlexOptimizedImage extends StatelessWidget {
return 'plex_optimized_${memWidth}x${memHeight}_$urlHash'; 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);
}
+173 -153
View File
@@ -13,6 +13,83 @@ import '../services/storage_service.dart';
import '../theme/theme_helper.dart'; import '../theme/theme_helper.dart';
import '../i18n/strings.g.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 /// Side navigation rail for Desktop and Android TV platforms
class SideNavigationRail extends StatefulWidget { class SideNavigationRail extends StatefulWidget {
final int selectedIndex; final int selectedIndex;
@@ -61,18 +138,26 @@ class SideNavigationRailState extends State<SideNavigationRail> {
_searchFocusNode = FocusNode(debugLabel: 'nav_search'); _searchFocusNode = FocusNode(debugLabel: 'nav_search');
_settingsFocusNode = FocusNode(debugLabel: 'nav_settings'); _settingsFocusNode = FocusNode(debugLabel: 'nav_settings');
_homeFocusNode.addListener(() => _onFocusChange(_homeFocusNode, () { _homeFocusNode.addListener(
setState(() => _isHomeFocused = _homeFocusNode.hasFocus); () => _onFocusChange(_homeFocusNode, () {
})); setState(() => _isHomeFocused = _homeFocusNode.hasFocus);
_librariesFocusNode.addListener(() => _onFocusChange(_librariesFocusNode, () { }),
setState(() => _isLibrariesFocused = _librariesFocusNode.hasFocus); );
})); _librariesFocusNode.addListener(
_searchFocusNode.addListener(() => _onFocusChange(_searchFocusNode, () { () => _onFocusChange(_librariesFocusNode, () {
setState(() => _isSearchFocused = _searchFocusNode.hasFocus); setState(() => _isLibrariesFocused = _librariesFocusNode.hasFocus);
})); }),
_settingsFocusNode.addListener(() => _onFocusChange(_settingsFocusNode, () { );
setState(() => _isSettingsFocused = _settingsFocusNode.hasFocus); _searchFocusNode.addListener(
})); () => _onFocusChange(_searchFocusNode, () {
setState(() => _isSearchFocused = _searchFocusNode.hasFocus);
}),
);
_settingsFocusNode.addListener(
() => _onFocusChange(_settingsFocusNode, () {
setState(() => _isSettingsFocused = _settingsFocusNode.hasFocus);
}),
);
_loadLibraries(); _loadLibraries();
} }
@@ -95,24 +180,21 @@ class SideNavigationRailState extends State<SideNavigationRail> {
/// Get or create a focus node for a library item /// Get or create a focus node for a library item
FocusNode _getLibraryFocusNode(String globalKey) { FocusNode _getLibraryFocusNode(String globalKey) {
return _libraryFocusNodes.putIfAbsent( return _libraryFocusNodes.putIfAbsent(globalKey, () {
globalKey, final node = FocusNode(debugLabel: 'nav_library_$globalKey');
() { node.addListener(() {
final node = FocusNode(debugLabel: 'nav_library_$globalKey'); if (mounted) {
node.addListener(() { setState(() {
if (mounted) { if (node.hasFocus) {
setState(() { _focusedLibraryKeys.add(globalKey);
if (node.hasFocus) { } else {
_focusedLibraryKeys.add(globalKey); _focusedLibraryKeys.remove(globalKey);
} else { }
_focusedLibraryKeys.remove(globalKey); });
} }
}); });
} return node;
}); });
return node;
},
);
} }
/// Focus the currently selected nav item /// Focus the currently selected nav item
@@ -338,58 +420,28 @@ class SideNavigationRailState extends State<SideNavigationRail> {
}) { }) {
final t = tokens(context); final t = tokens(context);
return Focus( return NavigationRailItem(
focusNode: focusNode, icon: icon,
autofocus: autofocus, selectedIcon: selectedIcon,
onKeyEvent: (node, event) { label: Text(
if (event is! KeyDownEvent) return KeyEventResult.ignored; label,
if (event.logicalKey.isSelectKey) { style: TextStyle(
onTap(); fontSize: 14,
return KeyEventResult.handled; fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
} color: isSelected ? t.text : t.textMuted,
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,
),
),
],
),
),
), ),
), ),
isSelected: isSelected,
isFocused: isFocused,
onTap: onTap,
focusNode: focusNode,
autofocus: autofocus,
); );
} }
Widget _buildLibrariesSection(List<PlexLibrary> visibleLibraries, dynamic t) { Widget _buildLibrariesSection(List<PlexLibrary> visibleLibraries, dynamic t) {
final isLibrariesSelected = widget.selectedIndex == 1 && widget.selectedLibraryKey == null; final isLibrariesSelected =
widget.selectedIndex == 1 && widget.selectedLibraryKey == null;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -417,13 +469,16 @@ class SideNavigationRailState extends State<SideNavigationRail> {
}, },
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isLibrariesSelected color: isLibrariesSelected
? t.text.withValues(alpha: 0.1) ? t.text.withValues(alpha: 0.1)
: _isLibrariesFocused : _isLibrariesFocused
? t.text.withValues(alpha: 0.08) ? t.text.withValues(alpha: 0.08)
: null, : null,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: Row(
@@ -444,12 +499,16 @@ class SideNavigationRailState extends State<SideNavigationRail> {
fontWeight: widget.selectedIndex == 1 fontWeight: widget.selectedIndex == 1
? FontWeight.w600 ? FontWeight.w600
: FontWeight.w400, : FontWeight.w400,
color: widget.selectedIndex == 1 ? t.text : t.textMuted, color: widget.selectedIndex == 1
? t.text
: t.textMuted,
), ),
), ),
), ),
Icon( Icon(
_librariesExpanded ? Icons.expand_less : Icons.expand_more, _librariesExpanded
? Icons.expand_less
: Icons.expand_more,
size: 20, size: 20,
color: t.textMuted, color: t.textMuted,
), ),
@@ -527,82 +586,43 @@ class SideNavigationRailState extends State<SideNavigationRail> {
final isFocused = _focusedLibraryKeys.contains(library.globalKey); final isFocused = _focusedLibraryKeys.contains(library.globalKey);
final focusNode = _getLibraryFocusNode(library.globalKey); final focusNode = _getLibraryFocusNode(library.globalKey);
return Focus( return NavigationRailItem(
focusNode: focusNode, icon: _getLibraryIcon(library.type),
onKeyEvent: (node, event) { selectedIcon: _getLibraryIconFilled(library.type),
if (event is! KeyDownEvent) return KeyEventResult.ignored; label: SizedBox(
if (event.logicalKey.isSelectKey) { height: 32, // Fixed height for consistent item sizing
widget.onLibrarySelected(library.globalKey); child: Column(
return KeyEventResult.handled; crossAxisAlignment: CrossAxisAlignment.start,
} mainAxisAlignment: MainAxisAlignment.center,
return KeyEventResult.ignored; children: [
}, Text(
child: Material( library.title,
color: Colors.transparent, style: TextStyle(
child: InkWell( fontSize: 13,
onTap: () => widget.onLibrarySelected(library.globalKey), fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
borderRadius: BorderRadius.circular(8), color: isSelected ? t.text : t.textMuted,
child: Container( ),
padding: const EdgeInsets.only( overflow: TextOverflow.ellipsis,
left: 28,
right: 12,
top: 10,
bottom: 10,
), ),
decoration: BoxDecoration( if (showServerName)
color: isSelected Text(
? t.text.withValues(alpha: 0.1) library.serverName!,
: isFocused style: TextStyle(
? t.text.withValues(alpha: 0.08) fontSize: 9,
: null, color: t.textMuted.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
isSelected
? _getLibraryIconFilled(library.type)
: _getLibraryIcon(library.type),
size: 18,
color: isSelected ? t.text : t.textMuted,
), ),
const SizedBox(width: 10), overflow: TextOverflow.ellipsis,
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,
),
],
),
),
),
],
),
),
), ),
), ),
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,
); );
} }
} }
@@ -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<AudioTrack> filterAudioTracks(List<AudioTrack> 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<SubtitleTrack> filterSubtitleTracks(List<SubtitleTrack> tracks) {
return tracks
.where((track) => track.id != 'auto' && track.id != 'no')
.toList();
}
/// Generic method to filter tracks based on type
static List<T> filterTracks<T>(List<T> tracks) {
if (T == AudioTrack) {
return filterAudioTracks(tracks as List<AudioTrack>) as List<T>;
} else if (T == SubtitleTrack) {
return filterSubtitleTracks(tracks as List<SubtitleTrack>) as List<T>;
}
return tracks;
}
/// Extract and filter tracks from Tracks object
static List<T> extractAndFilterTracks<T>(
Tracks? tracks,
List<T> Function(Tracks?) extractor,
) {
return filterTracks<T>(extractor(tracks));
}
/// Check if a track list has multiple tracks (excluding auto/no)
static bool hasMultipleTracks<T>(List<T> tracks) {
return filterTracks<T>(tracks).length > 1;
}
/// Check if a track list has any tracks (excluding auto/no)
static bool hasTracks<T>(List<T> tracks) {
return filterTracks<T>(tracks).isNotEmpty;
}
}
@@ -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<T>() {
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<T>() {
return Center(
child: Text(
getEmptyMessage<T>(),
style: const TextStyle(color: Colors.white70),
),
);
}
/// Check if "Off" is selected for a track
static bool isOffSelected<T>(
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>(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<T>({
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<T>({
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,
);
}
}
@@ -6,6 +6,7 @@ import '../../../models/plex_media_info.dart';
import '../../../utils/duration_formatter.dart'; import '../../../utils/duration_formatter.dart';
import '../../../utils/provider_extensions.dart'; import '../../../utils/provider_extensions.dart';
import 'base_video_control_sheet.dart'; import 'base_video_control_sheet.dart';
import 'video_control_sheet_launcher.dart';
import '../../plex_optimized_image.dart'; import '../../plex_optimized_image.dart';
/// Bottom sheet for selecting chapters /// Bottom sheet for selecting chapters
@@ -32,7 +33,7 @@ class ChapterSheet extends StatelessWidget {
VoidCallback? onOpen, VoidCallback? onOpen,
VoidCallback? onClose, VoidCallback? onClose,
}) { }) {
BaseVideoControlSheet.showSheet( VideoControlSheetLauncher.show(
context: context, context: context,
onOpen: onOpen, onOpen: onOpen,
onClose: onClose, onClose: onClose,
@@ -99,7 +100,7 @@ class ChapterSheet extends StatelessWidget {
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
child: PlexThumbImage( child: PlexOptimizedImage.thumb(
client: _getClientForChapters(context), client: _getClientForChapters(context),
imagePath: chapter.thumb, imagePath: chapter.thumb,
width: 60, width: 60,
@@ -2,6 +2,9 @@ import 'package:flutter/material.dart';
import '../../../mpv/mpv.dart'; import '../../../mpv/mpv.dart';
import 'base_video_control_sheet.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 /// Generic track selection sheet for audio and subtitle tracks
/// ///
@@ -50,7 +53,7 @@ class TrackSelectionSheet<T> extends StatelessWidget {
VoidCallback? onOpen, VoidCallback? onOpen,
VoidCallback? onClose, VoidCallback? onClose,
}) { }) {
BaseVideoControlSheet.showSheet( VideoControlSheetLauncher.show(
context: context, context: context,
onOpen: onOpen, onOpen: onOpen,
onClose: onClose, onClose: onClose,
@@ -70,15 +73,6 @@ class TrackSelectionSheet<T> 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return StreamBuilder<Tracks>( return StreamBuilder<Tracks>(
@@ -86,26 +80,16 @@ class TrackSelectionSheet<T> extends StatelessWidget {
initialData: player.state.tracks, initialData: player.state.tracks,
builder: (context, snapshot) { builder: (context, snapshot) {
final tracks = snapshot.data; final tracks = snapshot.data;
final availableTracks = extractTracks(tracks).where((track) { final availableTracks = TrackFilterHelper.extractAndFilterTracks<T>(
// Filter out 'auto' and 'no' tracks from the list tracks,
if (track is AudioTrack) { extractTracks,
return track.id != 'auto' && track.id != 'no'; );
} else if (track is SubtitleTrack) {
return track.id != 'auto' && track.id != 'no';
}
return true;
}).toList();
return BaseVideoControlSheet( return BaseVideoControlSheet(
title: title, title: title,
icon: icon, icon: icon,
child: availableTracks.isEmpty child: availableTracks.isEmpty
? Center( ? TrackSelectionHelper.buildEmptyState<T>()
child: Text(
_getEmptyMessage(),
style: const TextStyle(color: Colors.white70),
),
)
: StreamBuilder<TrackSelection>( : StreamBuilder<TrackSelection>(
stream: player.streams.track, stream: player.streams.track,
initialData: player.state.track, initialData: player.state.track,
@@ -115,9 +99,10 @@ class TrackSelectionSheet<T> extends StatelessWidget {
final selectedTrack = getCurrentTrack(currentTrack); final selectedTrack = getCurrentTrack(currentTrack);
// Determine if "Off" is selected (null or explicit off) // Determine if "Off" is selected (null or explicit off)
final isOffSelected = final isOffSelected = TrackSelectionHelper.isOffSelected(
selectedTrack == null || selectedTrack,
(isOffTrack?.call(selectedTrack) ?? false); isOffTrack,
);
final itemCount = final itemCount =
availableTracks.length + (showOffOption ? 1 : 0); availableTracks.length + (showOffOption ? 1 : 0);
@@ -127,18 +112,8 @@ class TrackSelectionSheet<T> extends StatelessWidget {
itemBuilder: (context, index) { itemBuilder: (context, index) {
// First item is "Off" if enabled // First item is "Off" if enabled
if (showOffOption && index == 0) { if (showOffOption && index == 0) {
return ListTile( return TrackSelectionHelper.buildOffTile<T>(
title: Text( isSelected: isOffSelected,
'Off',
style: TextStyle(
color: isOffSelected
? Colors.blue
: Colors.white,
),
),
trailing: isOffSelected
? const Icon(Icons.check, color: Colors.blue)
: null,
onTap: () { onTap: () {
if (createOffTrack != null) { if (createOffTrack != null) {
final offTrack = createOffTrack!(); final offTrack = createOffTrack!();
@@ -155,39 +130,17 @@ class TrackSelectionSheet<T> extends StatelessWidget {
final track = availableTracks[trackIndex]; final track = availableTracks[trackIndex];
// Check if this track is selected // Check if this track is selected
String trackId; final trackId = TrackSelectionHelper.getTrackId(track);
if (track is AudioTrack) { final selectedId = selectedTrack == null
trackId = track.id; ? ''
} else if (track is SubtitleTrack) { : TrackSelectionHelper.getTrackId(selectedTrack);
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 isSelected = trackId == selectedId; final isSelected = trackId == selectedId;
final label = buildLabel(track, trackIndex); final label = buildLabel(track, trackIndex);
return ListTile( return TrackSelectionHelper.buildTrackTile<T>(
title: Text( label: label,
label, isSelected: isSelected,
style: TextStyle(
color: isSelected ? Colors.blue : Colors.white,
),
),
trailing: isSelected
? const Icon(Icons.check, color: Colors.blue)
: null,
onTap: () { onTap: () {
setTrack(track); setTrack(track);
onTrackChanged?.call(track); onTrackChanged?.call(track);
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../../models/plex_media_version.dart'; import '../../../models/plex_media_version.dart';
import 'base_video_control_sheet.dart'; import 'base_video_control_sheet.dart';
import 'video_control_sheet_launcher.dart';
/// Bottom sheet for selecting video version /// Bottom sheet for selecting video version
class VersionSheet extends StatelessWidget { class VersionSheet extends StatelessWidget {
@@ -23,7 +24,7 @@ class VersionSheet extends StatelessWidget {
VoidCallback? onOpen, VoidCallback? onOpen,
VoidCallback? onClose, VoidCallback? onClose,
}) { }) {
BaseVideoControlSheet.showSheet( VideoControlSheetLauncher.show(
context: context, context: context,
onOpen: onOpen, onOpen: onOpen,
onClose: onClose, onClose: onClose,
@@ -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<T?> show<T>({
required BuildContext context,
required WidgetBuilder builder,
VoidCallback? onOpen,
VoidCallback? onClose,
}) {
return BaseVideoControlSheet.showSheet<T>(
context: context,
onOpen: onOpen,
onClose: onClose,
builder: builder,
);
}
}
@@ -10,7 +10,6 @@ import '../widgets/sync_offset_control.dart';
import '../widgets/sleep_timer_content.dart'; import '../widgets/sleep_timer_content.dart';
import '../../../i18n/strings.g.dart'; import '../../../i18n/strings.g.dart';
import 'base_video_control_sheet.dart'; import 'base_video_control_sheet.dart';
import 'video_sheet_header.dart';
enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync, audioDevice } enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync, audioDevice }
@@ -80,20 +79,16 @@ class VideoSettingsSheet extends StatefulWidget {
VoidCallback? onOpen, VoidCallback? onOpen,
VoidCallback? onClose, VoidCallback? onClose,
}) { }) {
onOpen?.call(); return BaseVideoControlSheet.showSheet(
return showModalBottomSheet(
context: context, context: context,
backgroundColor: Colors.grey[900], onOpen: onOpen,
isScrollControlled: true, onClose: onClose,
constraints: BaseVideoControlSheet.getBottomSheetConstraints(context),
builder: (context) => VideoSettingsSheet( builder: (context) => VideoSettingsSheet(
player: player, player: player,
audioSyncOffset: audioSyncOffset, audioSyncOffset: audioSyncOffset,
subtitleSyncOffset: subtitleSyncOffset, subtitleSyncOffset: subtitleSyncOffset,
), ),
).whenComplete(() { );
onClose?.call();
});
} }
@override @override
@@ -204,22 +199,6 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
} }
} }
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() { Widget _buildMenuView() {
final sleepTimer = SleepTimerService(); final sleepTimer = SleepTimerService();
final isDesktop = PlatformDetector.isDesktop(context); final isDesktop = PlatformDetector.isDesktop(context);
@@ -443,34 +422,34 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( final sleepTimer = SleepTimerService();
child: SizedBox( final isIconActive =
height: MediaQuery.of(context).size.height * 0.75, _currentView == _SettingsView.menu &&
child: Column( (sleepTimer.isActive ||
children: [ _audioSyncOffset != 0 ||
_buildHeader(), _subtitleSyncOffset != 0);
const Divider(color: Colors.white24, height: 1),
Expanded( return BaseVideoControlSheet(
child: () { title: _getTitle(),
switch (_currentView) { icon: _getIcon(),
case _SettingsView.menu: iconColor: isIconActive ? Colors.amber : Colors.white,
return _buildMenuView(); onBack: _currentView != _SettingsView.menu ? _navigateBack : null,
case _SettingsView.speed: child: () {
return _buildSpeedView(); switch (_currentView) {
case _SettingsView.sleep: case _SettingsView.menu:
return _buildSleepView(); return _buildMenuView();
case _SettingsView.audioSync: case _SettingsView.speed:
return _buildAudioSyncView(); return _buildSpeedView();
case _SettingsView.subtitleSync: case _SettingsView.sleep:
return _buildSubtitleSyncView(); return _buildSleepView();
case _SettingsView.audioDevice: case _SettingsView.audioSync:
return _buildAudioDeviceView(); return _buildAudioSyncView();
} case _SettingsView.subtitleSync:
}(), return _buildSubtitleSyncView();
), case _SettingsView.audioDevice:
], return _buildAudioDeviceView();
), }
), }(),
); );
} }
} }
@@ -13,6 +13,7 @@ import '../sheets/chapter_sheet.dart';
import '../sheets/subtitle_track_sheet.dart'; import '../sheets/subtitle_track_sheet.dart';
import '../sheets/version_sheet.dart'; import '../sheets/version_sheet.dart';
import '../sheets/video_settings_sheet.dart'; import '../sheets/video_settings_sheet.dart';
import '../helpers/track_filter_helper.dart';
import '../video_control_button.dart'; import '../video_control_button.dart';
/// Row of track and chapter control buttons for the video player /// Row of track and chapter control buttons for the video player
@@ -191,18 +192,12 @@ class TrackChapterControls extends StatelessWidget {
bool _hasMultipleAudioTracks(Tracks? tracks) { bool _hasMultipleAudioTracks(Tracks? tracks) {
if (tracks == null) return false; if (tracks == null) return false;
final audioTracks = tracks.audio return TrackFilterHelper.hasMultipleTracks<AudioTrack>(tracks.audio);
.where((track) => track.id != 'auto' && track.id != 'no')
.toList();
return audioTracks.length > 1;
} }
bool _hasSubtitles(Tracks? tracks) { bool _hasSubtitles(Tracks? tracks) {
if (tracks == null) return false; if (tracks == null) return false;
final subtitles = tracks.subtitle return TrackFilterHelper.hasTracks<SubtitleTrack>(tracks.subtitle);
.where((track) => track.id != 'auto' && track.id != 'no')
.toList();
return subtitles.isNotEmpty;
} }
IconData _getBoxFitIcon(int mode) { IconData _getBoxFitIcon(int mode) {