165 lines
6.1 KiB
Dart
165 lines
6.1 KiB
Dart
import 'package:flutter/material.dart';
|
|
|
|
import '../focus/focus_theme.dart';
|
|
import '../focus/focusable_wrapper.dart';
|
|
import '../media/media_item.dart';
|
|
import '../utils/platform_detector.dart';
|
|
import 'media_card.dart';
|
|
|
|
/// A focusable wrapper for MediaCard that handles D-pad navigation.
|
|
///
|
|
/// Wraps MediaCard with focus handling for TV/desktop navigation:
|
|
/// - Shows scale + border decoration when focused
|
|
/// - Handles SELECT key for activation with long-press detection
|
|
/// - Accepts optional external focusNode for programmatic focus control
|
|
class FocusableMediaCard extends StatefulWidget {
|
|
/// Either a [MediaItem] or a [MediaPlaylist]. Typed as [Object] because
|
|
/// Dart has no nominal union type. Forwarded as-is to the inner [MediaCard].
|
|
final Object item;
|
|
|
|
/// Optional row/column position announced with this card.
|
|
final String? semanticValue;
|
|
final double? width;
|
|
final double? height;
|
|
final void Function(MediaItem source)? onRefresh;
|
|
final VoidCallback? onRemoveFromContinueWatching;
|
|
final VoidCallback? onListRefresh;
|
|
final bool forceGridMode;
|
|
final bool forceListMode;
|
|
final bool isInContinueWatching;
|
|
final bool usesContinueWatchingAction;
|
|
final String? collectionId;
|
|
|
|
/// True for downloaded content without server access
|
|
final bool isOffline;
|
|
|
|
/// True when in a hub with mixed content (movies + episodes)
|
|
final bool mixedHubContext;
|
|
|
|
/// Render grid cards as image-only full-bleed cards.
|
|
final bool fullBleedImage;
|
|
|
|
/// Overrides the silhouette inferred from [item]. Used for container types
|
|
/// such as music collections and playlists.
|
|
final CardShape? cardShapeOverride;
|
|
|
|
/// Show server name in list view (multi-server)
|
|
final bool showServerName;
|
|
|
|
/// Whether to disable the scale animation on focus (e.g. in list view).
|
|
final bool disableScale;
|
|
|
|
/// Optional external focus node for programmatic focus control.
|
|
/// If not provided, an internal focus node is created.
|
|
final FocusNode? focusNode;
|
|
|
|
/// Called when the user presses UP and there's no focusable item above.
|
|
/// Used to navigate from the top row to filter chips.
|
|
final VoidCallback? onNavigateUp;
|
|
|
|
/// Called when the user presses DOWN and there's no focusable item below.
|
|
/// When the grid wires explicit row navigation, this points at the item in
|
|
/// the next row (or null on the last row).
|
|
final VoidCallback? onNavigateDown;
|
|
|
|
/// Called when the user presses LEFT and there's no focusable item to the left.
|
|
/// Used to navigate from the first column to the sidebar.
|
|
final VoidCallback? onNavigateLeft;
|
|
|
|
/// Called when the user presses RIGHT and there's no focusable item to the right.
|
|
/// Used to navigate from the last column to the alpha jump bar.
|
|
final VoidCallback? onNavigateRight;
|
|
|
|
/// Called when the user presses BACK.
|
|
/// Used to navigate from tab content to tab bar.
|
|
final VoidCallback? onBack;
|
|
|
|
/// Called when focus changes.
|
|
/// Used to track which grid item was last focused.
|
|
final ValueChanged<bool>? onFocusChange;
|
|
|
|
const FocusableMediaCard({
|
|
super.key,
|
|
required this.item,
|
|
this.semanticValue,
|
|
this.width,
|
|
this.height,
|
|
this.onRefresh,
|
|
this.onRemoveFromContinueWatching,
|
|
this.onListRefresh,
|
|
this.forceGridMode = false,
|
|
this.forceListMode = false,
|
|
this.isInContinueWatching = false,
|
|
bool? usesContinueWatchingAction,
|
|
this.collectionId,
|
|
this.isOffline = false,
|
|
this.mixedHubContext = false,
|
|
this.fullBleedImage = false,
|
|
this.cardShapeOverride,
|
|
this.showServerName = false,
|
|
this.disableScale = false,
|
|
this.focusNode,
|
|
this.onNavigateUp,
|
|
this.onNavigateDown,
|
|
this.onNavigateLeft,
|
|
this.onNavigateRight,
|
|
this.onBack,
|
|
this.onFocusChange,
|
|
}) : usesContinueWatchingAction = usesContinueWatchingAction ?? isInContinueWatching;
|
|
|
|
@override
|
|
State<FocusableMediaCard> createState() => _FocusableMediaCardState();
|
|
}
|
|
|
|
class _FocusableMediaCardState extends State<FocusableMediaCard> {
|
|
final GlobalKey<MediaCardState> _mediaCardKey = GlobalKey();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return FocusableWrapper(
|
|
focusNode: widget.focusNode,
|
|
// The MediaCard already exposes the complete static button semantics.
|
|
// Avoid invalidating a dense TV grid's semantics tree on every D-pad
|
|
// focus change unless an accessibility service needs focused state.
|
|
includeFocusSemantics: !PlatformDetector.isTV() || MediaQuery.accessibleNavigationOf(context),
|
|
onSelect: () => _mediaCardKey.currentState?.handleTap(),
|
|
onLongPress: () => _mediaCardKey.currentState?.showContextMenu(),
|
|
onNavigateUp: widget.onNavigateUp,
|
|
onNavigateDown: widget.onNavigateDown,
|
|
onNavigateLeft: widget.onNavigateLeft,
|
|
onNavigateRight: widget.onNavigateRight,
|
|
onBack: widget.onBack,
|
|
onFocusChange: widget.onFocusChange,
|
|
enableLongPress: true,
|
|
disableScale: widget.disableScale,
|
|
focusScale: widget.fullBleedImage ? FocusTheme.fullCardFocusScale : FocusTheme.focusScale,
|
|
useFocusGlow: widget.fullBleedImage,
|
|
// MediaCard draws the focus border itself, on the rect its layout
|
|
// highlights (poster for standard grid cards, whole card otherwise).
|
|
delegateFocusBorder: true,
|
|
useComfortableZone: !PlatformDetector.isTV(), // Always center on TV
|
|
scrollAlignment: 0.5,
|
|
child: MediaCard(
|
|
key: _mediaCardKey,
|
|
item: widget.item,
|
|
semanticValue: widget.semanticValue,
|
|
width: widget.width,
|
|
height: widget.height,
|
|
onRefresh: widget.onRefresh,
|
|
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
|
|
onListRefresh: widget.onListRefresh,
|
|
forceGridMode: widget.forceGridMode,
|
|
forceListMode: widget.forceListMode,
|
|
isInContinueWatching: widget.isInContinueWatching,
|
|
usesContinueWatchingAction: widget.usesContinueWatchingAction,
|
|
collectionId: widget.collectionId,
|
|
isOffline: widget.isOffline,
|
|
mixedHubContext: widget.mixedHubContext,
|
|
fullBleedImage: widget.fullBleedImage,
|
|
cardShapeOverride: widget.cardShapeOverride,
|
|
showServerName: widget.showServerName,
|
|
),
|
|
);
|
|
}
|
|
}
|