feat: inline season tabs and d-pad focus improvements
This commit is contained in:
@@ -1,13 +1,19 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'dpad_navigator.dart';
|
||||
import 'key_event_utils.dart';
|
||||
|
||||
/// Callbacks for chip key event handling.
|
||||
class ChipKeyCallbacks {
|
||||
/// Called when SELECT key is pressed.
|
||||
/// Called when SELECT key is pressed (short press when [onLongPress] is set).
|
||||
final VoidCallback? onSelect;
|
||||
|
||||
/// Called when SELECT key is held for 500ms.
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
/// Called when DOWN arrow is pressed.
|
||||
final VoidCallback? onNavigateDown;
|
||||
|
||||
@@ -25,6 +31,7 @@ class ChipKeyCallbacks {
|
||||
|
||||
const ChipKeyCallbacks({
|
||||
this.onSelect,
|
||||
this.onLongPress,
|
||||
this.onNavigateDown,
|
||||
this.onNavigateUp,
|
||||
this.onNavigateLeft,
|
||||
@@ -53,6 +60,8 @@ class ChipKeyCallbacks {
|
||||
mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
||||
FocusNode? _internalFocusNode;
|
||||
bool _isFocused = false;
|
||||
Timer? _longPressTimer;
|
||||
bool _isSelectKeyDown = false;
|
||||
|
||||
/// Override to return the widget's optional external focus node.
|
||||
FocusNode? get widgetFocusNode;
|
||||
@@ -85,6 +94,7 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
||||
void disposeFocusNode() {
|
||||
focusNode.removeListener(_onFocusChange);
|
||||
_internalFocusNode?.dispose();
|
||||
_longPressTimer?.cancel();
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
@@ -96,7 +106,7 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
||||
/// Shared key event handler for chip widgets.
|
||||
///
|
||||
/// Handles common key patterns:
|
||||
/// - SELECT key -> onSelect
|
||||
/// - SELECT key -> onSelect (short press) / onLongPress (hold 500ms)
|
||||
/// - Arrow keys -> navigation callbacks
|
||||
/// - BACK key -> onBack
|
||||
///
|
||||
@@ -112,16 +122,53 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
||||
}
|
||||
}
|
||||
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
if (SelectKeyUpSuppressor.consumeIfSuppressed(event)) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// SELECT key activates the chip
|
||||
if (key.isSelectKey && callbacks.onSelect != null) {
|
||||
callbacks.onSelect!();
|
||||
// SELECT key with long press support
|
||||
if (key.isSelectKey) {
|
||||
if (callbacks.onLongPress != null) {
|
||||
if (event is KeyDownEvent) {
|
||||
if (!_isSelectKeyDown) {
|
||||
_isSelectKeyDown = true;
|
||||
_longPressTimer?.cancel();
|
||||
_longPressTimer = Timer(const Duration(milliseconds: 500), () {
|
||||
if (mounted) {
|
||||
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
|
||||
callbacks.onLongPress?.call();
|
||||
}
|
||||
});
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
} else if (event is KeyRepeatEvent) {
|
||||
return KeyEventResult.handled;
|
||||
} else if (event is KeyUpEvent) {
|
||||
final timerWasActive = _longPressTimer?.isActive ?? false;
|
||||
_longPressTimer?.cancel();
|
||||
if (timerWasActive && _isSelectKeyDown) {
|
||||
callbacks.onSelect?.call();
|
||||
}
|
||||
_isSelectKeyDown = false;
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
} else if (event.isActionable && callbacks.onSelect != null) {
|
||||
callbacks.onSelect!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
}
|
||||
|
||||
// Context menu key triggers long press directly
|
||||
if (event.isActionable && key.isContextMenuKey && callbacks.onLongPress != null) {
|
||||
SelectKeyUpSuppressor.suppressSelectUntilKeyUp();
|
||||
callbacks.onLongPress!();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
// LEFT arrow - call callback if provided, otherwise propagate to parent
|
||||
if (key.isLeftKey) {
|
||||
if (callbacks.onNavigateLeft != null) {
|
||||
|
||||
@@ -240,7 +240,15 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
final renderObject = context.findRenderObject();
|
||||
if (renderObject == null) return;
|
||||
|
||||
final scrollable = Scrollable.maybeOf(context);
|
||||
// Find the nearest scrollable that actually has scroll range.
|
||||
// Skip inner scrollables with no extent (e.g. shrinkWrap ListView
|
||||
// with NeverScrollableScrollPhysics inside an outer scroll view).
|
||||
var scrollable = Scrollable.maybeOf(context);
|
||||
while (scrollable != null) {
|
||||
final pos = scrollable.position;
|
||||
if (pos.maxScrollExtent > pos.minScrollExtent) break;
|
||||
scrollable = Scrollable.maybeOf(scrollable.context);
|
||||
}
|
||||
if (scrollable == null) return;
|
||||
|
||||
final viewport = scrollable.context.findRenderObject() as RenderBox?;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -95,6 +95,33 @@ Future<MediaNavigationResult> navigateToMediaItem(
|
||||
continue defaultCase;
|
||||
|
||||
case PlexMediaType.season:
|
||||
// Navigate to the parent show with the season tab pre-selected
|
||||
if (metadata.parentRatingKey != null) {
|
||||
final showStub = PlexMetadata(
|
||||
ratingKey: metadata.parentRatingKey!,
|
||||
key: '/library/metadata/${metadata.parentRatingKey}',
|
||||
type: 'show',
|
||||
title: metadata.grandparentTitle ?? metadata.parentTitle ?? metadata.displayTitle,
|
||||
thumb: metadata.grandparentThumb ?? metadata.parentThumb,
|
||||
art: metadata.grandparentArt,
|
||||
serverId: metadata.serverId,
|
||||
serverName: metadata.serverName,
|
||||
);
|
||||
final result = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => MediaDetailScreen(
|
||||
metadata: showStub,
|
||||
isOffline: isOffline,
|
||||
initialSeasonIndex: metadata.index,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (result == true) {
|
||||
onRefresh?.call(metadata.ratingKey);
|
||||
}
|
||||
return MediaNavigationResult.navigated;
|
||||
}
|
||||
continue defaultCase;
|
||||
|
||||
defaultCase:
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Scroll the nearest scrollable ancestor so [context] is centered.
|
||||
///
|
||||
/// Uses [Scrollable.ensureVisible] with alignment 0.5 (center).
|
||||
/// Runs in a post-frame callback to ensure layout is complete.
|
||||
void scrollContextToCenter(BuildContext? context) {
|
||||
if (context == null) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!context.mounted) return;
|
||||
Scrollable.ensureVisible(context, alignment: 0.5, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
|
||||
});
|
||||
}
|
||||
|
||||
/// Scroll a horizontal list to center the item at the given index.
|
||||
///
|
||||
/// Assumes items are laid out with [leadingPadding] before the first item,
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../focus/focus_theme.dart';
|
||||
import '../focus/focusable_wrapper.dart';
|
||||
import '../models/download_models.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
@@ -30,6 +31,8 @@ class EpisodeCard extends StatefulWidget {
|
||||
final bool autofocus;
|
||||
final bool isOffline;
|
||||
final String? localPosterPath;
|
||||
final FocusNode? focusNode;
|
||||
final VoidCallback? onNavigateUp;
|
||||
|
||||
const EpisodeCard({
|
||||
super.key,
|
||||
@@ -41,6 +44,8 @@ class EpisodeCard extends StatefulWidget {
|
||||
this.autofocus = false,
|
||||
this.isOffline = false,
|
||||
this.localPosterPath,
|
||||
this.focusNode,
|
||||
this.onNavigateUp,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -106,33 +111,37 @@ class _EpisodeCardState extends State<EpisodeCard> {
|
||||
|
||||
final hasActiveProgress = hasProgress && widget.episode.viewOffset! < widget.episode.duration!;
|
||||
|
||||
return FocusableWrapper(
|
||||
autofocus: widget.autofocus,
|
||||
enableLongPress: true,
|
||||
onSelect: widget.onTap,
|
||||
onLongPress: _showContextMenu,
|
||||
borderRadius: 0, // Episode cards have no border radius
|
||||
useBackgroundFocus: true, // Use background color instead of outline
|
||||
disableScale: true, // No scale animation for list items
|
||||
child: MediaContextMenu(
|
||||
key: _contextMenuKey,
|
||||
item: widget.episode,
|
||||
onRefresh: widget.onRefresh,
|
||||
onListRefresh: widget.onListRefresh,
|
||||
onTap: widget.onTap,
|
||||
child: InkWell(
|
||||
key: Key(widget.episode.ratingKey),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: FocusableWrapper(
|
||||
focusNode: widget.focusNode,
|
||||
autofocus: widget.autofocus,
|
||||
enableLongPress: true,
|
||||
onNavigateUp: widget.onNavigateUp,
|
||||
onSelect: widget.onTap,
|
||||
onLongPress: _showContextMenu,
|
||||
disableScale: true,
|
||||
child: MediaContextMenu(
|
||||
key: _contextMenuKey,
|
||||
item: widget.episode,
|
||||
onRefresh: widget.onRefresh,
|
||||
onListRefresh: widget.onListRefresh,
|
||||
onTap: widget.onTap,
|
||||
onTapDown: _storeTapPosition,
|
||||
onLongPress: _showContextMenu,
|
||||
onSecondaryTapDown: _storeTapPosition,
|
||||
onSecondaryTap: _showContextMenu,
|
||||
hoverColor: Theme.of(context).colorScheme.surface.withValues(alpha: 0.05),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: tokens(context).outline, width: 0.5)),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: InkWell(
|
||||
key: Key(widget.episode.ratingKey),
|
||||
borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius),
|
||||
onTap: widget.onTap,
|
||||
onTapDown: _storeTapPosition,
|
||||
onLongPress: _showContextMenu,
|
||||
onSecondaryTapDown: _storeTapPosition,
|
||||
onSecondaryTap: _showContextMenu,
|
||||
hoverColor: Theme.of(context).colorScheme.surface.withValues(alpha: 0.05),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(FocusTheme.defaultBorderRadius),
|
||||
),
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -393,6 +402,7 @@ class _EpisodeCardState extends State<EpisodeCard> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,9 @@ class FocusableTabChip extends StatefulWidget {
|
||||
/// Called when the user presses BACK from this chip.
|
||||
final VoidCallback? onBack;
|
||||
|
||||
/// Called when SELECT key is held (D-pad long press).
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
const FocusableTabChip({
|
||||
super.key,
|
||||
required this.label,
|
||||
@@ -43,6 +46,7 @@ class FocusableTabChip extends StatefulWidget {
|
||||
this.onNavigateRight,
|
||||
this.onNavigateDown,
|
||||
this.onBack,
|
||||
this.onLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -80,6 +84,7 @@ class _FocusableTabChipState extends State<FocusableTabChip> with FocusableChipS
|
||||
event,
|
||||
ChipKeyCallbacks(
|
||||
onSelect: widget.onSelect,
|
||||
onLongPress: widget.onLongPress,
|
||||
onNavigateLeft: widget.onNavigateLeft,
|
||||
onNavigateRight: widget.onNavigateRight,
|
||||
onNavigateDown: widget.onNavigateDown,
|
||||
|
||||
+47
-16
@@ -934,28 +934,53 @@ bool _hasClickableTitle(PlexMetadata metadata) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Navigate to season detail from episode metadata
|
||||
/// Navigate to a show with the season tab pre-selected from episode metadata
|
||||
void _navigateToSeason(BuildContext context, PlexMetadata episode, {bool isOffline = false}) {
|
||||
if (episode.parentRatingKey == null) return;
|
||||
final seasonStub = PlexMetadata(
|
||||
ratingKey: episode.parentRatingKey!,
|
||||
key: '/library/metadata/${episode.parentRatingKey}',
|
||||
type: 'season',
|
||||
title: episode.parentTitle ?? 'Season ${episode.parentIndex ?? ''}',
|
||||
index: episode.parentIndex,
|
||||
parentRatingKey: episode.grandparentRatingKey,
|
||||
thumb: episode.parentThumb,
|
||||
serverId: episode.serverId,
|
||||
serverName: episode.serverName,
|
||||
);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => MediaDetailScreen(metadata: seasonStub, isOffline: isOffline)));
|
||||
if (episode.grandparentRatingKey != null) {
|
||||
// Navigate to the show with the season pre-selected
|
||||
final showStub = PlexMetadata(
|
||||
ratingKey: episode.grandparentRatingKey!,
|
||||
key: '/library/metadata/${episode.grandparentRatingKey}',
|
||||
type: 'show',
|
||||
title: episode.grandparentTitle ?? episode.displayTitle,
|
||||
thumb: episode.grandparentThumb,
|
||||
art: episode.grandparentArt,
|
||||
serverId: episode.serverId,
|
||||
serverName: episode.serverName,
|
||||
);
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MediaDetailScreen(
|
||||
metadata: showStub,
|
||||
isOffline: isOffline,
|
||||
initialSeasonIndex: episode.parentIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (episode.parentRatingKey != null) {
|
||||
// Fallback: navigate to season directly if no grandparent
|
||||
final seasonStub = PlexMetadata(
|
||||
ratingKey: episode.parentRatingKey!,
|
||||
key: '/library/metadata/${episode.parentRatingKey}',
|
||||
type: 'season',
|
||||
title: episode.parentTitle ?? 'Season ${episode.parentIndex ?? ''}',
|
||||
index: episode.parentIndex,
|
||||
parentRatingKey: episode.grandparentRatingKey,
|
||||
thumb: episode.parentThumb,
|
||||
serverId: episode.serverId,
|
||||
serverName: episode.serverName,
|
||||
);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => MediaDetailScreen(metadata: seasonStub, isOffline: isOffline)));
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to the detail screen for a metadata item.
|
||||
/// For episodes/seasons: navigates to the parent show.
|
||||
/// For episodes/seasons: navigates to the parent show with season pre-selected.
|
||||
/// For movies and other types: navigates to the item's own detail page.
|
||||
void _navigateToDetail(BuildContext context, PlexMetadata metadata, {bool isOffline = false}) {
|
||||
PlexMetadata target = metadata;
|
||||
int? initialSeasonIndex;
|
||||
|
||||
if (metadata.isEpisode && metadata.grandparentRatingKey != null) {
|
||||
target = PlexMetadata(
|
||||
@@ -969,6 +994,7 @@ void _navigateToDetail(BuildContext context, PlexMetadata metadata, {bool isOffl
|
||||
serverName: metadata.serverName,
|
||||
);
|
||||
} else if (metadata.isSeason && metadata.parentRatingKey != null) {
|
||||
initialSeasonIndex = metadata.index;
|
||||
target = PlexMetadata(
|
||||
ratingKey: metadata.parentRatingKey!,
|
||||
key: '/library/metadata/${metadata.parentRatingKey}',
|
||||
@@ -981,7 +1007,12 @@ void _navigateToDetail(BuildContext context, PlexMetadata metadata, {bool isOffl
|
||||
);
|
||||
}
|
||||
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => MediaDetailScreen(metadata: target, isOffline: isOffline)));
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => MediaDetailScreen(metadata: target, isOffline: isOffline, initialSeasonIndex: initialSeasonIndex),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Text widget that shows hover underline + pointer cursor only in pointer mode.
|
||||
|
||||
@@ -462,10 +462,15 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
|
||||
case 'season':
|
||||
didNavigate = true;
|
||||
// Navigate to the show with the season tab pre-selected
|
||||
final seasonParentKey = metadata!.mediaType == PlexMediaType.episode
|
||||
? metadata.grandparentRatingKey
|
||||
: metadata.parentRatingKey;
|
||||
final seasonIndex = metadata.parentIndex;
|
||||
await _navigateToRelated(
|
||||
context,
|
||||
metadata!.parentRatingKey,
|
||||
(metadata) => MediaDetailScreen(metadata: metadata),
|
||||
seasonParentKey,
|
||||
(show) => MediaDetailScreen(metadata: show, initialSeasonIndex: seasonIndex),
|
||||
t.messages.errorLoadingSeason,
|
||||
);
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user