refactor: deduplicate
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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,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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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),
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
});
|
||||||
});
|
}(),
|
||||||
}(),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user