feat: inline season tabs and d-pad focus improvements

This commit is contained in:
edde746
2026-03-12 00:57:51 +01:00
parent 1a4516bdd1
commit c062dd944e
9 changed files with 629 additions and 568 deletions
+54 -7
View File
@@ -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) {
+9 -1
View File
@@ -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
+27
View File
@@ -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:
+12
View File
@@ -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,
+36 -26
View File
@@ -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> {
),
),
),
),
);
}
+5
View File
@@ -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
View File
@@ -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.
+7 -2
View File
@@ -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;