@@ -6,22 +6,49 @@ import 'focus_theme.dart';
|
||||
import 'input_mode_tracker.dart';
|
||||
import 'key_event_utils.dart';
|
||||
|
||||
typedef FocusableActionBuilder = Widget Function(BuildContext context, FocusableActionBuildState state);
|
||||
|
||||
class FocusableActionBuildState {
|
||||
final FocusNode focusNode;
|
||||
final bool isFocused;
|
||||
final bool showFocus;
|
||||
final bool isKeyboardMode;
|
||||
final Duration animationDuration;
|
||||
|
||||
const FocusableActionBuildState({
|
||||
required this.focusNode,
|
||||
required this.isFocused,
|
||||
required this.showFocus,
|
||||
required this.isKeyboardMode,
|
||||
required this.animationDuration,
|
||||
});
|
||||
}
|
||||
|
||||
class FocusableAction {
|
||||
final IconData icon;
|
||||
final Color? iconColor;
|
||||
final double iconFill;
|
||||
|
||||
final String? debugLabel;
|
||||
final FocusNode? focusNode;
|
||||
final bool autofocus;
|
||||
|
||||
final String? tooltip;
|
||||
final VoidCallback? onPressed;
|
||||
final Widget? child;
|
||||
final FocusableActionBuilder? builder;
|
||||
|
||||
const FocusableAction({
|
||||
this.icon = Icons.circle,
|
||||
this.iconColor,
|
||||
this.iconFill = 1.0,
|
||||
this.debugLabel,
|
||||
this.focusNode,
|
||||
this.autofocus = false,
|
||||
this.tooltip,
|
||||
this.onPressed,
|
||||
this.child,
|
||||
this.builder,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -43,6 +70,12 @@ class FocusableActionBar extends StatefulWidget {
|
||||
/// Called when the user presses the back key while an action is focused.
|
||||
final VoidCallback? onBack;
|
||||
|
||||
/// Called when any action in the row gains or loses focus.
|
||||
final ValueChanged<bool>? onFocusChange;
|
||||
|
||||
final double spacing;
|
||||
final MainAxisSize mainAxisSize;
|
||||
|
||||
const FocusableActionBar({
|
||||
super.key,
|
||||
required this.actions,
|
||||
@@ -51,6 +84,9 @@ class FocusableActionBar extends StatefulWidget {
|
||||
this.onNavigateLeft,
|
||||
this.onNavigateRight,
|
||||
this.onBack,
|
||||
this.onFocusChange,
|
||||
this.spacing = 0,
|
||||
this.mainAxisSize = MainAxisSize.min,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -59,7 +95,10 @@ class FocusableActionBar extends StatefulWidget {
|
||||
|
||||
class FocusableActionBarState extends State<FocusableActionBar> {
|
||||
late List<FocusNode> _focusNodes;
|
||||
late List<bool> _ownsFocusNodes;
|
||||
late List<VoidCallback> _focusListeners;
|
||||
late List<bool> _focusStates;
|
||||
bool _hasAnyFocus = false;
|
||||
|
||||
FocusNode? getFocusNode(int index) => index >= 0 && index < _focusNodes.length ? _focusNodes[index] : null;
|
||||
|
||||
@@ -76,29 +115,58 @@ class FocusableActionBarState extends State<FocusableActionBar> {
|
||||
@override
|
||||
void didUpdateWidget(FocusableActionBar oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.actions.length != widget.actions.length) {
|
||||
if (_shouldRebuildFocusNodes(oldWidget)) {
|
||||
_disposeNodes();
|
||||
_initNodes();
|
||||
}
|
||||
}
|
||||
|
||||
bool _shouldRebuildFocusNodes(FocusableActionBar oldWidget) {
|
||||
if (oldWidget.actions.length != widget.actions.length) return true;
|
||||
for (var i = 0; i < widget.actions.length; i++) {
|
||||
if (oldWidget.actions[i].focusNode != widget.actions[i].focusNode) return true;
|
||||
if (oldWidget.actions[i].debugLabel != widget.actions[i].debugLabel) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _initNodes() {
|
||||
_focusNodes = List.generate(widget.actions.length, (i) => FocusNode(debugLabel: 'ActionBar[$i]'));
|
||||
_focusStates = List.filled(widget.actions.length, false);
|
||||
_focusNodes = List.generate(
|
||||
widget.actions.length,
|
||||
(i) => widget.actions[i].focusNode ?? FocusNode(debugLabel: widget.actions[i].debugLabel ?? 'ActionBar[$i]'),
|
||||
);
|
||||
_ownsFocusNodes = List.generate(widget.actions.length, (i) => widget.actions[i].focusNode == null);
|
||||
_focusListeners = [];
|
||||
_focusStates = List.generate(widget.actions.length, (i) => _focusNodes[i].hasFocus);
|
||||
_hasAnyFocus = _focusNodes.any((node) => node.hasFocus);
|
||||
for (var i = 0; i < _focusNodes.length; i++) {
|
||||
final idx = i;
|
||||
_focusNodes[i].addListener(() {
|
||||
void listener() {
|
||||
final hasFocus = _focusNodes[idx].hasFocus;
|
||||
if (_focusStates[idx] != hasFocus) {
|
||||
setState(() => _focusStates[idx] = hasFocus);
|
||||
}
|
||||
});
|
||||
_notifyRowFocusIfChanged();
|
||||
}
|
||||
|
||||
_focusListeners.add(listener);
|
||||
_focusNodes[i].addListener(listener);
|
||||
}
|
||||
}
|
||||
|
||||
void _notifyRowFocusIfChanged() {
|
||||
final hasAnyFocus = _focusNodes.any((node) => node.hasFocus);
|
||||
if (_hasAnyFocus == hasAnyFocus) return;
|
||||
_hasAnyFocus = hasAnyFocus;
|
||||
widget.onFocusChange?.call(hasAnyFocus);
|
||||
}
|
||||
|
||||
void _disposeNodes() {
|
||||
for (final node in _focusNodes) {
|
||||
node.dispose();
|
||||
for (var i = 0; i < _focusNodes.length; i++) {
|
||||
_focusNodes[i].removeListener(_focusListeners[i]);
|
||||
if (_ownsFocusNodes[i]) {
|
||||
_focusNodes[i].dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,8 +182,13 @@ class FocusableActionBarState extends State<FocusableActionBar> {
|
||||
final duration = FocusTheme.getAnimationDuration(context);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [for (var i = 0; i < widget.actions.length; i++) _buildButton(i, isKeyboard, duration)],
|
||||
mainAxisSize: widget.mainAxisSize,
|
||||
children: [
|
||||
for (var i = 0; i < widget.actions.length; i++) ...[
|
||||
if (i > 0 && widget.spacing > 0) SizedBox(width: widget.spacing),
|
||||
_buildButton(i, isKeyboard, duration),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -125,8 +198,19 @@ class FocusableActionBarState extends State<FocusableActionBar> {
|
||||
final showFocus = isFocused && isKeyboard;
|
||||
final opacity = isKeyboard && !isFocused ? 0.6 : 1.0;
|
||||
|
||||
final buildState = FocusableActionBuildState(
|
||||
focusNode: _focusNodes[index],
|
||||
isFocused: isFocused,
|
||||
showFocus: showFocus,
|
||||
isKeyboardMode: isKeyboard,
|
||||
animationDuration: duration,
|
||||
);
|
||||
final customChild = action.builder?.call(context, buildState);
|
||||
|
||||
return Focus(
|
||||
focusNode: _focusNodes[index],
|
||||
autofocus: action.autofocus,
|
||||
descendantsAreFocusable: false,
|
||||
onKeyEvent: (node, event) {
|
||||
if (widget.onBack != null) {
|
||||
final backResult = handleBackKeyAction(event, widget.onBack!);
|
||||
@@ -146,20 +230,22 @@ class FocusableActionBarState extends State<FocusableActionBar> {
|
||||
)(node, event);
|
||||
},
|
||||
child: ClickableCursor(
|
||||
enabled: action.onPressed != null || action.child != null,
|
||||
enabled: action.onPressed != null || action.child != null || customChild != null,
|
||||
child: AnimatedOpacity(
|
||||
opacity: showFocus ? 1.0 : opacity,
|
||||
duration: duration,
|
||||
child: Container(
|
||||
decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: 20),
|
||||
child:
|
||||
action.child ??
|
||||
IconButton(
|
||||
icon: AppIcon(action.icon, fill: action.iconFill, color: action.iconColor),
|
||||
tooltip: action.tooltip,
|
||||
onPressed: action.onPressed,
|
||||
),
|
||||
),
|
||||
child:
|
||||
customChild ??
|
||||
Container(
|
||||
decoration: FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: 20),
|
||||
child:
|
||||
action.child ??
|
||||
IconButton(
|
||||
icon: AppIcon(action.icon, fill: action.iconFill, color: action.iconColor),
|
||||
tooltip: action.tooltip,
|
||||
onPressed: action.onPressed,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -187,21 +187,6 @@ FocusOnKeyEventCallback dpadKeyHandler({
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether [container] has another focusable descendant beyond the currently
|
||||
/// focused one in [direction]. Lets a row's key handler move between interior
|
||||
/// items (true) but trap at the row's edge (false) so focus can't escape into
|
||||
/// an off-screen "black hole" (#1181). Horizontal only.
|
||||
///
|
||||
/// Relies on [FocusNode.traversalDescendants] being in reading (left→right)
|
||||
/// order, which holds for a flat [Row].
|
||||
bool hasHorizontalNeighbor(FocusNode container, TraversalDirection direction) {
|
||||
assert(direction == TraversalDirection.left || direction == TraversalDirection.right);
|
||||
final items = container.traversalDescendants.toList();
|
||||
final index = items.indexWhere((node) => node.hasPrimaryFocus);
|
||||
if (index < 0) return false;
|
||||
return direction == TraversalDirection.right ? index < items.length - 1 : index > 0;
|
||||
}
|
||||
|
||||
/// Navigator observer that automatically suppresses stray back KeyUp events
|
||||
/// after any route pop caused by a back key press.
|
||||
///
|
||||
|
||||
@@ -68,7 +68,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
return null; // default for other states
|
||||
});
|
||||
|
||||
ButtonStyle actionButtonStyle({Color? foregroundColor, EdgeInsetsGeometry? padding}) {
|
||||
ButtonStyle actionButtonStyle({Color? foregroundColor, EdgeInsetsGeometry? padding, bool showFocus = false}) {
|
||||
if (!isKeyboardMode && !isTv) {
|
||||
if (padding != null) {
|
||||
return FilledButton.styleFrom(padding: padding);
|
||||
@@ -79,6 +79,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
foregroundColor: foregroundColor,
|
||||
);
|
||||
}
|
||||
|
||||
return ButtonStyle(
|
||||
padding: padding != null ? WidgetStatePropertyAll(padding) : null,
|
||||
minimumSize: WidgetStatePropertyAll(padding == null ? Size.square(actionSize) : Size(0, actionSize)),
|
||||
@@ -87,14 +88,8 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
overlayColor: noOverlay,
|
||||
backgroundColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.focused)) return focusBg;
|
||||
return idleBg;
|
||||
}),
|
||||
foregroundColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.focused)) return focusFg;
|
||||
return foregroundColor ?? tonalFg;
|
||||
}),
|
||||
backgroundColor: WidgetStatePropertyAll(showFocus ? focusBg : idleBg),
|
||||
foregroundColor: WidgetStatePropertyAll(showFocus ? focusFg : foregroundColor ?? tonalFg),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,73 +101,122 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
|
||||
final gap = isTv ? 8.0 * tvScale : 12.0;
|
||||
|
||||
final playButton = SizedBox(
|
||||
height: actionSize,
|
||||
child: FilledButton(
|
||||
focusNode: _playButtonFocusNode,
|
||||
autofocus: isKeyboardMode,
|
||||
onPressed: onPlayPressed,
|
||||
style: actionButtonStyle(
|
||||
padding: EdgeInsets.symmetric(horizontal: isTv ? 17 * tvScale : 16, vertical: isTv ? 9 * tvScale : 0),
|
||||
Widget playButton(FocusableActionBuildState state) {
|
||||
return SizedBox(
|
||||
height: actionSize,
|
||||
child: FilledButton(
|
||||
onPressed: onPlayPressed,
|
||||
style: actionButtonStyle(
|
||||
showFocus: state.showFocus,
|
||||
padding: EdgeInsets.symmetric(horizontal: isTv ? 17 * tvScale : 16, vertical: isTv ? 9 * tvScale : 0),
|
||||
),
|
||||
child: playButtonLabel.isNotEmpty
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
playButtonIcon,
|
||||
SizedBox(width: isTv ? 7 * tvScale : 8),
|
||||
Text(playButtonLabel, style: playTextStyle),
|
||||
],
|
||||
)
|
||||
: playButtonIcon,
|
||||
),
|
||||
child: playButtonLabel.isNotEmpty
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
playButtonIcon,
|
||||
SizedBox(width: isTv ? 7 * tvScale : 8),
|
||||
Text(playButtonLabel, style: playTextStyle),
|
||||
],
|
||||
)
|
||||
: playButtonIcon,
|
||||
),
|
||||
);
|
||||
|
||||
final trailerButton = primaryTrailer == null
|
||||
? null
|
||||
: IconButton.filledTonal(
|
||||
onPressed: onPlayTrailer,
|
||||
icon: const AppIcon(Symbols.theaters_rounded, fill: 1),
|
||||
tooltip: t.tooltips.playTrailer,
|
||||
iconSize: isTv ? 21 * tvScale : 20,
|
||||
style: actionButtonStyle(),
|
||||
);
|
||||
|
||||
final shuffleButton = (metadata.isShow || metadata.isSeason)
|
||||
? IconButton.filledTonal(
|
||||
onPressed: () async {
|
||||
await _handleShufflePlayWithQueue(context, metadata);
|
||||
},
|
||||
icon: const AppIcon(Symbols.shuffle_rounded, fill: 1),
|
||||
tooltip: t.tooltips.shufflePlay,
|
||||
iconSize: isTv ? 21 * tvScale : 20,
|
||||
style: actionButtonStyle(),
|
||||
)
|
||||
: null;
|
||||
|
||||
final downloadButton = !widget.isOffline && !PlatformDetector.isAppleTV()
|
||||
? _buildDownloadButton(metadata, actionButtonStyle, tvScale)
|
||||
: null;
|
||||
final watchedButton = _buildWatchedToggleButton(metadata, actionButtonStyle, tvScale);
|
||||
final moreActionsButton = widget.isOffline
|
||||
? null
|
||||
: _buildMoreActionsButton(metadata, actionButtonStyle, tvScale, onPlayTrailer: onPlayTrailer);
|
||||
|
||||
Row actionRow(List<Widget> buttons) {
|
||||
return Row(
|
||||
children: [
|
||||
for (var i = 0; i < buttons.length; i++) ...[if (i > 0) SizedBox(width: gap), buttons[i]],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final allButtons = <Widget>[
|
||||
playButton,
|
||||
if (trailerButton != null) trailerButton,
|
||||
if (shuffleButton != null) shuffleButton,
|
||||
if (downloadButton != null) downloadButton,
|
||||
watchedButton,
|
||||
if (moreActionsButton != null) moreActionsButton,
|
||||
Widget iconActionButton(
|
||||
FocusableActionBuildState state, {
|
||||
required Widget icon,
|
||||
required VoidCallback? onPressed,
|
||||
String? tooltip,
|
||||
Color? foregroundColor,
|
||||
}) {
|
||||
return IconButton.filledTonal(
|
||||
onPressed: onPressed,
|
||||
icon: icon,
|
||||
tooltip: tooltip,
|
||||
iconSize: isTv ? 21 * tvScale : 20,
|
||||
style: actionButtonStyle(foregroundColor: foregroundColor, showFocus: state.showFocus),
|
||||
);
|
||||
}
|
||||
|
||||
final playAction = FocusableAction(
|
||||
debugLabel: 'detail_play',
|
||||
focusNode: _playButtonFocusNode,
|
||||
autofocus: isKeyboardMode,
|
||||
onPressed: onPlayPressed,
|
||||
builder: (context, state) => playButton(state),
|
||||
);
|
||||
|
||||
final trailerAction = primaryTrailer == null
|
||||
? null
|
||||
: FocusableAction(
|
||||
debugLabel: 'detail_trailer',
|
||||
onPressed: onPlayTrailer,
|
||||
builder: (context, state) => iconActionButton(
|
||||
state,
|
||||
onPressed: onPlayTrailer,
|
||||
icon: const AppIcon(Symbols.theaters_rounded, fill: 1),
|
||||
tooltip: t.tooltips.playTrailer,
|
||||
),
|
||||
);
|
||||
|
||||
final shuffleAction = (metadata.isShow || metadata.isSeason)
|
||||
? FocusableAction(
|
||||
debugLabel: 'detail_shuffle',
|
||||
onPressed: () async {
|
||||
await _handleShufflePlayWithQueue(context, metadata);
|
||||
},
|
||||
builder: (context, state) => iconActionButton(
|
||||
state,
|
||||
onPressed: () async {
|
||||
await _handleShufflePlayWithQueue(context, metadata);
|
||||
},
|
||||
icon: const AppIcon(Symbols.shuffle_rounded, fill: 1),
|
||||
tooltip: t.tooltips.shufflePlay,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
final downloadAction = !widget.isOffline && !PlatformDetector.isAppleTV()
|
||||
? FocusableAction(
|
||||
debugLabel: 'detail_download',
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
|
||||
builder: (context, state) =>
|
||||
_buildDownloadButton(metadata, actionButtonStyle, tvScale, showFocus: state.showFocus),
|
||||
)
|
||||
: null;
|
||||
|
||||
final watchedAction = FocusableAction(
|
||||
debugLabel: 'detail_watched',
|
||||
onPressed: () => unawaited(_handleWatchedTogglePressed(metadata)),
|
||||
builder: (context, state) =>
|
||||
_buildWatchedToggleButton(metadata, actionButtonStyle, tvScale, showFocus: state.showFocus),
|
||||
);
|
||||
|
||||
void showMoreActions() => _contextMenuKey.currentState?.showContextMenu(context);
|
||||
|
||||
final moreActionsAction = widget.isOffline
|
||||
? null
|
||||
: FocusableAction(
|
||||
debugLabel: 'detail_more',
|
||||
onPressed: showMoreActions,
|
||||
builder: (context, state) => _buildMoreActionsButton(
|
||||
metadata,
|
||||
actionButtonStyle,
|
||||
tvScale,
|
||||
onPlayTrailer: onPlayTrailer,
|
||||
showFocus: state.showFocus,
|
||||
),
|
||||
);
|
||||
|
||||
final allActions = <FocusableAction>[
|
||||
playAction,
|
||||
if (trailerAction != null) trailerAction,
|
||||
if (shuffleAction != null) shuffleAction,
|
||||
if (downloadAction != null) downloadAction,
|
||||
watchedAction,
|
||||
if (moreActionsAction != null) moreActionsAction,
|
||||
];
|
||||
|
||||
double playButtonWidthEstimate() {
|
||||
@@ -190,115 +234,121 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
}
|
||||
|
||||
final estimatedPlayWidth = playButtonWidthEstimate();
|
||||
double estimatedRowWidth(List<Widget> buttons) {
|
||||
if (buttons.isEmpty) return 0;
|
||||
return estimatedPlayWidth + (buttons.length - 1) * actionSize + (buttons.length - 1) * gap;
|
||||
double estimatedRowWidth(List<FocusableAction> actions) {
|
||||
if (actions.isEmpty) return 0;
|
||||
return estimatedPlayWidth + (actions.length - 1) * actionSize + (actions.length - 1) * gap;
|
||||
}
|
||||
|
||||
List<Widget> compactButtonsFor(double maxWidth) {
|
||||
List<FocusableAction> compactActionsFor(double maxWidth) {
|
||||
if (widget.isOffline) {
|
||||
final compact = <Widget>[playButton, watchedButton];
|
||||
if (maxWidth.isFinite && estimatedRowWidth(compact) > maxWidth) return [playButton];
|
||||
final compact = <FocusableAction>[playAction, watchedAction];
|
||||
if (maxWidth.isFinite && estimatedRowWidth(compact) > maxWidth) return [playAction];
|
||||
return compact;
|
||||
}
|
||||
|
||||
final medium = <Widget>[
|
||||
playButton,
|
||||
if (downloadButton != null) downloadButton,
|
||||
watchedButton,
|
||||
if (moreActionsButton != null) moreActionsButton,
|
||||
final medium = <FocusableAction>[
|
||||
playAction,
|
||||
if (downloadAction != null) downloadAction,
|
||||
watchedAction,
|
||||
if (moreActionsAction != null) moreActionsAction,
|
||||
];
|
||||
if (!maxWidth.isFinite || estimatedRowWidth(medium) <= maxWidth) return medium;
|
||||
|
||||
final compact = <Widget>[playButton, watchedButton, if (moreActionsButton != null) moreActionsButton];
|
||||
final compact = <FocusableAction>[playAction, watchedAction, if (moreActionsAction != null) moreActionsAction];
|
||||
if (estimatedRowWidth(compact) <= maxWidth) return compact;
|
||||
|
||||
return [playButton, if (moreActionsButton != null) moreActionsButton];
|
||||
return [playAction, if (moreActionsAction != null) moreActionsAction];
|
||||
}
|
||||
|
||||
return Focus(
|
||||
skipTraversal: true,
|
||||
onFocusChange: (hasFocus) {
|
||||
if (isTv) _setTvDetailActionRowFocus(hasFocus);
|
||||
Widget actionBar(List<FocusableAction> actions) {
|
||||
return FocusableActionBar(
|
||||
actions: actions,
|
||||
spacing: gap,
|
||||
onFocusChange: isTv ? _setTvDetailActionRowFocus : null,
|
||||
onNavigateUp: _focusAboveActionRow,
|
||||
onNavigateDown: _focusBelowActionRow,
|
||||
);
|
||||
}
|
||||
|
||||
// TV screens are wide and D-pad focus should see every direct action.
|
||||
// On smaller online screens, hidden actions remain available from ⋮.
|
||||
if (isTv) return actionBar(allActions);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth;
|
||||
if (!maxWidth.isFinite || estimatedRowWidth(allActions) <= maxWidth) {
|
||||
return actionBar(allActions);
|
||||
}
|
||||
return actionBar(compactActionsFor(maxWidth));
|
||||
},
|
||||
onKeyEvent: _handlePlayButtonKeyEvent,
|
||||
// TV screens are wide and D-pad focus should see every direct action.
|
||||
// On smaller online screens, hidden actions remain available from ⋮.
|
||||
child: isTv
|
||||
? actionRow(allButtons)
|
||||
: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth;
|
||||
if (!maxWidth.isFinite || estimatedRowWidth(allButtons) <= maxWidth) {
|
||||
return actionRow(allButtons);
|
||||
}
|
||||
return actionRow(compactButtonsFor(maxWidth));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleWatchedTogglePressed(MediaItem metadata) async {
|
||||
try {
|
||||
final isWatched = metadata.isWatched;
|
||||
if (widget.isOffline) {
|
||||
// Offline mode: queue action for later sync
|
||||
final offlineWatch = context.read<OfflineWatchProvider>();
|
||||
if (isWatched) {
|
||||
await offlineWatch.markAsUnwatched(serverId: metadata.serverId!, itemId: metadata.id);
|
||||
} else {
|
||||
await offlineWatch.markAsWatched(serverId: metadata.serverId!, itemId: metadata.id);
|
||||
}
|
||||
if (mounted) {
|
||||
showAppSnackBar(context, isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline);
|
||||
}
|
||||
} else {
|
||||
// Online mode: dispatch via the right backend's neutral method so
|
||||
// Jellyfin items hit /UserPlayedItems and Plex items hit /:/scrobble.
|
||||
final serverId = metadata.serverId;
|
||||
if (serverId == null) return;
|
||||
final client = context.tryGetMediaClientForServer(serverId);
|
||||
if (client == null) return;
|
||||
|
||||
if (isWatched) {
|
||||
await client.markUnwatched(metadata);
|
||||
unawaited(TrackerCoordinator.instance.markUnwatched(metadata, client));
|
||||
} else {
|
||||
await client.markWatched(metadata);
|
||||
unawaited(TrackerCoordinator.instance.markWatched(metadata, client));
|
||||
}
|
||||
if (mounted) {
|
||||
_watchStateChanged = true;
|
||||
showSuccessSnackBar(context, isWatched ? t.messages.markedAsUnwatched : t.messages.markedAsWatched);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildWatchedToggleButton(
|
||||
MediaItem metadata,
|
||||
ButtonStyle Function({Color? foregroundColor, EdgeInsetsGeometry? padding}) actionButtonStyle,
|
||||
double tvScale,
|
||||
) {
|
||||
ButtonStyle Function({Color? foregroundColor, EdgeInsetsGeometry? padding, required bool showFocus})
|
||||
actionButtonStyle,
|
||||
double tvScale, {
|
||||
bool showFocus = false,
|
||||
}) {
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () async {
|
||||
try {
|
||||
final isWatched = metadata.isWatched;
|
||||
if (widget.isOffline) {
|
||||
// Offline mode: queue action for later sync
|
||||
final offlineWatch = context.read<OfflineWatchProvider>();
|
||||
if (isWatched) {
|
||||
await offlineWatch.markAsUnwatched(serverId: metadata.serverId!, itemId: metadata.id);
|
||||
} else {
|
||||
await offlineWatch.markAsWatched(serverId: metadata.serverId!, itemId: metadata.id);
|
||||
}
|
||||
if (mounted) {
|
||||
showAppSnackBar(
|
||||
context,
|
||||
isWatched ? t.messages.markedAsUnwatchedOffline : t.messages.markedAsWatchedOffline,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Online mode: dispatch via the right backend's neutral method so
|
||||
// Jellyfin items hit /UserPlayedItems and Plex items hit /:/scrobble.
|
||||
final serverId = metadata.serverId;
|
||||
if (serverId == null) return;
|
||||
final client = context.tryGetMediaClientForServer(serverId);
|
||||
if (client == null) return;
|
||||
|
||||
if (isWatched) {
|
||||
await client.markUnwatched(metadata);
|
||||
unawaited(TrackerCoordinator.instance.markUnwatched(metadata, client));
|
||||
} else {
|
||||
await client.markWatched(metadata);
|
||||
unawaited(TrackerCoordinator.instance.markWatched(metadata, client));
|
||||
}
|
||||
if (mounted) {
|
||||
_watchStateChanged = true;
|
||||
showSuccessSnackBar(context, isWatched ? t.messages.markedAsUnwatched : t.messages.markedAsWatched);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
||||
}
|
||||
}
|
||||
},
|
||||
onPressed: () => unawaited(_handleWatchedTogglePressed(metadata)),
|
||||
icon: AppIcon(metadata.isWatched ? Symbols.remove_done_rounded : Symbols.check_rounded, fill: 1),
|
||||
tooltip: metadata.isWatched ? t.tooltips.markAsUnwatched : t.tooltips.markAsWatched,
|
||||
iconSize: PlatformDetector.isTV() ? 21 * tvScale : 20,
|
||||
style: actionButtonStyle(),
|
||||
style: actionButtonStyle(showFocus: showFocus),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMoreActionsButton(
|
||||
MediaItem metadata,
|
||||
ButtonStyle Function({Color? foregroundColor, EdgeInsetsGeometry? padding}) actionButtonStyle,
|
||||
ButtonStyle Function({Color? foregroundColor, EdgeInsetsGeometry? padding, required bool showFocus})
|
||||
actionButtonStyle,
|
||||
double tvScale, {
|
||||
VoidCallback? onPlayTrailer,
|
||||
bool showFocus = false,
|
||||
}) {
|
||||
return MediaContextMenu(
|
||||
key: _contextMenuKey,
|
||||
@@ -316,17 +366,166 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
},
|
||||
icon: const AppIcon(Symbols.more_vert_rounded, fill: 1),
|
||||
iconSize: PlatformDetector.isTV() ? 21 * tvScale : 20,
|
||||
style: actionButtonStyle(),
|
||||
style: actionButtonStyle(showFocus: showFocus),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleDownloadButtonPressed(MediaItem metadata) async {
|
||||
final downloadProvider = context.read<DownloadProvider>();
|
||||
final globalKey = metadata.globalKey;
|
||||
final ruleKey = _syncRuleKeyForMetadata(context, downloadProvider, metadata);
|
||||
final progress = downloadProvider.getProgress(globalKey);
|
||||
|
||||
if (downloadProvider.isQueueing(globalKey) ||
|
||||
progress?.status == DownloadStatus.queued ||
|
||||
progress?.status == DownloadStatus.downloading) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress?.status == DownloadStatus.paused) {
|
||||
final client = _getMediaClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
await downloadProvider.resumeDownload(globalKey, client);
|
||||
if (mounted) showAppSnackBar(context, 'Download resumed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress?.status == DownloadStatus.failed) {
|
||||
final client = _getMediaClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
final versionConfig = await _resolveDownloadVersion(context, metadata, client);
|
||||
if (versionConfig == null || !mounted) return;
|
||||
|
||||
await downloadProvider.deleteDownload(globalKey);
|
||||
try {
|
||||
await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig);
|
||||
if (mounted) showSuccessSnackBar(context, t.downloads.downloadQueued);
|
||||
} on CellularDownloadBlockedException {
|
||||
if (mounted) showErrorSnackBar(context, t.settings.cellularDownloadBlocked);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress?.status == DownloadStatus.cancelled) {
|
||||
final retry = await showConfirmDialog(
|
||||
context,
|
||||
title: 'Cancelled Download',
|
||||
message: 'This download was cancelled. What would you like to do?',
|
||||
cancelText: t.common.delete,
|
||||
confirmText: 'Retry',
|
||||
);
|
||||
|
||||
if (!retry && mounted) {
|
||||
await downloadProvider.deleteDownload(globalKey);
|
||||
if (mounted) showSuccessSnackBar(context, t.downloads.downloadDeleted);
|
||||
} else if (retry && mounted) {
|
||||
final client = _getMediaClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
final versionConfig = await _resolveDownloadVersion(context, metadata, client);
|
||||
if (versionConfig == null || !mounted) return;
|
||||
|
||||
await downloadProvider.deleteDownload(globalKey);
|
||||
try {
|
||||
await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig);
|
||||
if (mounted) showSuccessSnackBar(context, t.downloads.downloadQueued);
|
||||
} on CellularDownloadBlockedException {
|
||||
if (mounted) showErrorSnackBar(context, t.settings.cellularDownloadBlocked);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress?.status == DownloadStatus.partial) {
|
||||
if (downloadProvider.hasSyncRule(ruleKey)) {
|
||||
await _showSyncRuleActions(context, downloadProvider, metadata, ruleKey: ruleKey, downloadGlobalKey: globalKey);
|
||||
return;
|
||||
}
|
||||
|
||||
final client = _getMediaClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
final versionConfig = await _resolveDownloadVersion(context, metadata, client);
|
||||
if (versionConfig == null || !mounted) return;
|
||||
|
||||
final count = await downloadProvider.queueMissingEpisodes(metadata, client, versionConfig: versionConfig);
|
||||
if (mounted) {
|
||||
final message = count > 0 ? t.downloads.episodesQueued(count: count) : 'All episodes already downloaded';
|
||||
showAppSnackBar(context, message);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (downloadProvider.isDownloaded(globalKey)) {
|
||||
if (downloadProvider.hasSyncRule(ruleKey)) {
|
||||
await _showSyncRuleActions(context, downloadProvider, metadata, ruleKey: ruleKey, downloadGlobalKey: globalKey);
|
||||
return;
|
||||
}
|
||||
|
||||
final canDownloadMore = metadata.isShow || metadata.isSeason;
|
||||
Future<void> confirmAndDelete() async {
|
||||
final confirmed = await showDeleteConfirmation(
|
||||
context,
|
||||
title: t.downloads.deleteDownload,
|
||||
message: t.downloads.deleteConfirm(title: metadata.displayTitle),
|
||||
);
|
||||
if (confirmed && mounted) {
|
||||
await downloadProvider.deleteDownload(globalKey);
|
||||
if (mounted) showSuccessSnackBar(context, t.downloads.downloadDeleted);
|
||||
}
|
||||
}
|
||||
|
||||
if (!canDownloadMore) {
|
||||
await confirmAndDelete();
|
||||
return;
|
||||
}
|
||||
|
||||
final client = _getMediaClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
try {
|
||||
final result = await showDownloadOptionsAndQueue(
|
||||
context,
|
||||
metadata: metadata,
|
||||
client: client,
|
||||
downloadProvider: downloadProvider,
|
||||
onDelete: confirmAndDelete,
|
||||
);
|
||||
if (result == null || !mounted) return;
|
||||
showSuccessSnackBar(context, result.toSnackBarMessage());
|
||||
} on CellularDownloadBlockedException {
|
||||
if (mounted) showErrorSnackBar(context, t.settings.cellularDownloadBlocked);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final client = _getMediaClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
try {
|
||||
final result = await showDownloadOptionsAndQueue(
|
||||
context,
|
||||
metadata: metadata,
|
||||
client: client,
|
||||
downloadProvider: downloadProvider,
|
||||
);
|
||||
if (result == null || !mounted) return;
|
||||
|
||||
showSuccessSnackBar(context, result.toSnackBarMessage());
|
||||
} on CellularDownloadBlockedException {
|
||||
if (mounted) showErrorSnackBar(context, t.settings.cellularDownloadBlocked);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildDownloadButton(
|
||||
MediaItem metadata,
|
||||
ButtonStyle Function({Color? foregroundColor, EdgeInsetsGeometry? padding}) actionButtonStyle,
|
||||
double tvScale,
|
||||
) {
|
||||
ButtonStyle Function({Color? foregroundColor, EdgeInsetsGeometry? padding, required bool showFocus})
|
||||
actionButtonStyle,
|
||||
double tvScale, {
|
||||
bool showFocus = false,
|
||||
}) {
|
||||
return Consumer<DownloadProvider>(
|
||||
builder: (context, downloadProvider, _) {
|
||||
final iconSize = PlatformDetector.isTV() ? 21.0 * tvScale : 20.0;
|
||||
@@ -346,7 +545,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
onPressed: null,
|
||||
icon: LoadingIndicatorBox(size: iconSize),
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(),
|
||||
style: actionButtonStyle(showFocus: showFocus),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -362,7 +561,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
tooltip: tooltip,
|
||||
icon: const AppIcon(Symbols.schedule_rounded, fill: 1),
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(),
|
||||
style: actionButtonStyle(showFocus: showFocus),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -379,100 +578,40 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
tooltip: tooltip,
|
||||
icon: _buildRadialProgress(progress?.progressPercent),
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(),
|
||||
style: actionButtonStyle(showFocus: showFocus),
|
||||
);
|
||||
}
|
||||
|
||||
// State 4: Paused (can resume)
|
||||
if (progress?.status == DownloadStatus.paused) {
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () async {
|
||||
final client = _getMediaClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
await downloadProvider.resumeDownload(globalKey, client);
|
||||
if (context.mounted) {
|
||||
showAppSnackBar(context, 'Download resumed');
|
||||
}
|
||||
},
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
|
||||
icon: const AppIcon(Symbols.pause_circle_outline_rounded, fill: 1),
|
||||
tooltip: 'Resume download',
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(foregroundColor: Colors.amber),
|
||||
style: actionButtonStyle(foregroundColor: Colors.amber, showFocus: showFocus),
|
||||
);
|
||||
}
|
||||
|
||||
// State 5: Failed (can retry)
|
||||
if (progress?.status == DownloadStatus.failed) {
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () async {
|
||||
final client = _getMediaClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
final versionConfig = await _resolveDownloadVersion(context, metadata, client);
|
||||
if (versionConfig == null || !context.mounted) return;
|
||||
|
||||
await downloadProvider.deleteDownload(globalKey);
|
||||
try {
|
||||
await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig);
|
||||
|
||||
if (context.mounted) {
|
||||
showSuccessSnackBar(context, t.downloads.downloadQueued);
|
||||
}
|
||||
} on CellularDownloadBlockedException {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, t.settings.cellularDownloadBlocked);
|
||||
}
|
||||
}
|
||||
},
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
|
||||
icon: const AppIcon(Symbols.error_outline_rounded, fill: 1),
|
||||
tooltip: 'Retry download',
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(foregroundColor: Colors.red),
|
||||
style: actionButtonStyle(foregroundColor: Colors.red, showFocus: showFocus),
|
||||
);
|
||||
}
|
||||
|
||||
// State 6: Cancelled (can delete or retry)
|
||||
if (progress?.status == DownloadStatus.cancelled) {
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () async {
|
||||
// Show options: Delete or Retry
|
||||
final retry = await showConfirmDialog(
|
||||
context,
|
||||
title: 'Cancelled Download',
|
||||
message: 'This download was cancelled. What would you like to do?',
|
||||
cancelText: t.common.delete,
|
||||
confirmText: 'Retry',
|
||||
);
|
||||
|
||||
if (!retry && context.mounted) {
|
||||
await downloadProvider.deleteDownload(globalKey);
|
||||
if (context.mounted) {
|
||||
showSuccessSnackBar(context, t.downloads.downloadDeleted);
|
||||
}
|
||||
} else if (retry && context.mounted) {
|
||||
final client = _getMediaClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
final versionConfig = await _resolveDownloadVersion(context, metadata, client);
|
||||
if (versionConfig == null || !context.mounted) return;
|
||||
|
||||
await downloadProvider.deleteDownload(globalKey);
|
||||
try {
|
||||
await downloadProvider.queueDownload(metadata, client, versionConfig: versionConfig);
|
||||
if (context.mounted) {
|
||||
showSuccessSnackBar(context, t.downloads.downloadQueued);
|
||||
}
|
||||
} on CellularDownloadBlockedException {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, t.settings.cellularDownloadBlocked);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
|
||||
icon: const AppIcon(Symbols.cancel_rounded, fill: 1),
|
||||
tooltip: 'Cancelled download',
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(foregroundColor: Colors.grey),
|
||||
style: actionButtonStyle(foregroundColor: Colors.grey, showFocus: showFocus),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -490,17 +629,11 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
: t.downloads.keepSynced;
|
||||
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () => _showSyncRuleActions(
|
||||
context,
|
||||
downloadProvider,
|
||||
metadata,
|
||||
ruleKey: ruleKey,
|
||||
downloadGlobalKey: globalKey,
|
||||
),
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
|
||||
tooltip: tooltip,
|
||||
icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1),
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(foregroundColor: isEnabled ? Colors.teal : Colors.grey),
|
||||
style: actionButtonStyle(foregroundColor: isEnabled ? Colors.teal : Colors.grey, showFocus: showFocus),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -509,26 +642,11 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
: 'Partially downloaded - Click to complete';
|
||||
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () async {
|
||||
final client = _getMediaClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
final versionConfig = await _resolveDownloadVersion(context, metadata, client);
|
||||
if (versionConfig == null || !context.mounted) return;
|
||||
|
||||
final count = await downloadProvider.queueMissingEpisodes(metadata, client, versionConfig: versionConfig);
|
||||
|
||||
if (context.mounted) {
|
||||
final message = count > 0
|
||||
? t.downloads.episodesQueued(count: count)
|
||||
: 'All episodes already downloaded';
|
||||
showAppSnackBar(context, message);
|
||||
}
|
||||
},
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
|
||||
tooltip: tooltip,
|
||||
icon: const AppIcon(Symbols.downloading_rounded, fill: 1),
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(foregroundColor: Colors.orange),
|
||||
style: actionButtonStyle(foregroundColor: Colors.orange, showFocus: showFocus),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -541,97 +659,33 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
final syncRule = downloadProvider.getSyncRule(ruleKey);
|
||||
final isEnabled = syncRule?.enabled ?? true;
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () => _showSyncRuleActions(
|
||||
context,
|
||||
downloadProvider,
|
||||
metadata,
|
||||
ruleKey: ruleKey,
|
||||
downloadGlobalKey: globalKey,
|
||||
),
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
|
||||
icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1),
|
||||
tooltip: t.downloads.keepNUnwatched(count: syncRule?.episodeCount.toString() ?? '?'),
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(foregroundColor: isEnabled ? Colors.teal : Colors.grey),
|
||||
style: actionButtonStyle(foregroundColor: isEnabled ? Colors.teal : Colors.grey, showFocus: showFocus),
|
||||
);
|
||||
}
|
||||
|
||||
// Shows/seasons may have more episodes to fetch; movies/episodes don't.
|
||||
final canDownloadMore = metadata.isShow || metadata.isSeason;
|
||||
|
||||
Future<void> confirmAndDelete() async {
|
||||
final confirmed = await showDeleteConfirmation(
|
||||
context,
|
||||
title: t.downloads.deleteDownload,
|
||||
message: t.downloads.deleteConfirm(title: metadata.displayTitle),
|
||||
);
|
||||
if (confirmed && context.mounted) {
|
||||
await downloadProvider.deleteDownload(globalKey);
|
||||
if (context.mounted) {
|
||||
showSuccessSnackBar(context, t.downloads.downloadDeleted);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () async {
|
||||
// Movies/episodes: nothing more to download, so delete directly.
|
||||
if (!canDownloadMore) {
|
||||
await confirmAndDelete();
|
||||
return;
|
||||
}
|
||||
// Shows/seasons: reopen the download options menu so the user can
|
||||
// grab more episodes (or switch to sync), with delete as a row.
|
||||
final client = _getMediaClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
try {
|
||||
final result = await showDownloadOptionsAndQueue(
|
||||
context,
|
||||
metadata: metadata,
|
||||
client: client,
|
||||
downloadProvider: downloadProvider,
|
||||
onDelete: confirmAndDelete,
|
||||
);
|
||||
if (result == null || !context.mounted) return;
|
||||
showSuccessSnackBar(context, result.toSnackBarMessage());
|
||||
} on CellularDownloadBlockedException {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, t.settings.cellularDownloadBlocked);
|
||||
}
|
||||
}
|
||||
},
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
|
||||
icon: const AppIcon(Symbols.download_rounded, fill: 1),
|
||||
tooltip: canDownloadMore ? t.downloads.manage : t.downloads.deleteDownload,
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(foregroundColor: Colors.orange),
|
||||
style: actionButtonStyle(foregroundColor: Colors.orange, showFocus: showFocus),
|
||||
);
|
||||
}
|
||||
|
||||
// State 9: Not downloaded (default - can download)
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () async {
|
||||
final client = _getMediaClientForMetadata(context);
|
||||
if (client == null) return;
|
||||
|
||||
try {
|
||||
final result = await showDownloadOptionsAndQueue(
|
||||
context,
|
||||
metadata: metadata,
|
||||
client: client,
|
||||
downloadProvider: downloadProvider,
|
||||
);
|
||||
if (result == null || !context.mounted) return;
|
||||
|
||||
showSuccessSnackBar(context, result.toSnackBarMessage());
|
||||
} on CellularDownloadBlockedException {
|
||||
if (context.mounted) {
|
||||
showErrorSnackBar(context, t.settings.cellularDownloadBlocked);
|
||||
}
|
||||
}
|
||||
},
|
||||
onPressed: () => unawaited(_handleDownloadButtonPressed(metadata)),
|
||||
icon: const AppIcon(Symbols.download_rounded, fill: 1),
|
||||
tooltip: t.downloads.downloadNow,
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(),
|
||||
style: actionButtonStyle(showFocus: showFocus),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import '../widgets/collapsible_text.dart';
|
||||
import '../widgets/rating_bottom_sheet.dart';
|
||||
|
||||
import '../focus/dpad_navigator.dart';
|
||||
import '../focus/focusable_action_bar.dart';
|
||||
import '../focus/focusable_wrapper.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
@@ -1996,79 +1997,57 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
scrollContextToCenter(key.currentContext);
|
||||
}
|
||||
|
||||
/// Intercept DOWN from the play button row to focus the first available section
|
||||
KeyEventResult _handlePlayButtonKeyEvent(FocusNode node, KeyEvent event) {
|
||||
final key = event.logicalKey;
|
||||
if (!event.isActionable) return KeyEventResult.ignored;
|
||||
final isTv = PlatformDetector.isTV();
|
||||
|
||||
if (isTv && key.isUpKey) {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// UP: focus the rating chip if available
|
||||
if (key.isUpKey) {
|
||||
if (!widget.isOffline) {
|
||||
_ratingChipFocusNode.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// LEFT/RIGHT: let the framework move between buttons in the row, but trap
|
||||
// at the row's edges so focus can't fall off into a black hole (#1181).
|
||||
if (key.isLeftKey || key.isRightKey) {
|
||||
final dir = key.isRightKey ? TraversalDirection.right : TraversalDirection.left;
|
||||
return hasHorizontalNeighbor(node, dir) ? KeyEventResult.ignored : KeyEventResult.handled;
|
||||
}
|
||||
|
||||
if (!key.isDownKey) return KeyEventResult.ignored;
|
||||
/// Focus the first available section above the primary action row.
|
||||
void _focusAboveActionRow() {
|
||||
if (PlatformDetector.isTV()) return;
|
||||
if (!widget.isOffline) _ratingChipFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
/// Focus the first available section below the primary action row.
|
||||
void _focusBelowActionRow() {
|
||||
final metadata = _fullMetadata ?? _metadata;
|
||||
|
||||
if (isTv) {
|
||||
if (PlatformDetector.isTV()) {
|
||||
_tvDetailRailKey.currentState?.requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
return;
|
||||
}
|
||||
|
||||
// DOWN order: overview → seasons → cast → extras
|
||||
if (!PlatformDetector.isTV() && metadata.summary != null && metadata.summary!.isNotEmpty) {
|
||||
_overviewFocusNode.requestFocus();
|
||||
_scrollSectionIntoView(_overviewSectionKey);
|
||||
return KeyEventResult.handled;
|
||||
return;
|
||||
}
|
||||
|
||||
if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) {
|
||||
// Focus the selected season tab chip
|
||||
_seasonTabFocusNodes[_selectedSeasonIndex].requestFocus();
|
||||
_scrollSectionIntoView(_seasonsSectionKey);
|
||||
return KeyEventResult.handled;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_episodes.isNotEmpty) {
|
||||
_firstEpisodeFocusNode.requestFocus();
|
||||
_scrollSectionIntoView(_seasonsSectionKey);
|
||||
return KeyEventResult.handled;
|
||||
return;
|
||||
}
|
||||
|
||||
if (metadata.roles != null && metadata.roles!.isNotEmpty) {
|
||||
_castFocusNode.requestFocus();
|
||||
_scrollSectionIntoView(_castSectionKey);
|
||||
return KeyEventResult.handled;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_extras != null && _extras!.isNotEmpty) {
|
||||
_extrasFocusNode.requestFocus();
|
||||
_scrollSectionIntoView(_extrasSectionKey);
|
||||
return KeyEventResult.handled;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_relatedHubs.isNotEmpty) {
|
||||
_relatedHubKeys.first.currentState?.requestFocusFromMemory();
|
||||
return KeyEventResult.handled;
|
||||
return;
|
||||
}
|
||||
|
||||
return KeyEventResult.handled; // consume to prevent unwanted traversal
|
||||
}
|
||||
|
||||
/// Get the responsive card width used by seasons/extras/cast rows.
|
||||
|
||||
@@ -43,114 +43,6 @@ void main() {
|
||||
expect(backs, 1);
|
||||
});
|
||||
|
||||
group('hasHorizontalNeighbor', () {
|
||||
testWidgets('reports interior vs. edge neighbors for a flat button row', (tester) async {
|
||||
final container = FocusNode(debugLabel: 'row', skipTraversal: true);
|
||||
final play = FocusNode(debugLabel: 'play');
|
||||
final download = FocusNode(debugLabel: 'download');
|
||||
final more = FocusNode(debugLabel: 'more');
|
||||
for (final node in [container, play, download, more]) {
|
||||
addTearDown(node.dispose);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Focus(
|
||||
focusNode: container,
|
||||
skipTraversal: true,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FilledButton(focusNode: play, onPressed: () {}, child: const Text('Play')),
|
||||
IconButton(focusNode: download, onPressed: () {}, icon: const Icon(Icons.download)),
|
||||
IconButton(focusNode: more, onPressed: () {}, icon: const Icon(Icons.more_vert)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// The ordinal helper assumes one traversal node per button, in reading
|
||||
// order. Assert it so a framework change that breaks the assumption fails
|
||||
// loudly here rather than silently re-opening the black hole.
|
||||
expect(container.traversalDescendants.length, 3);
|
||||
|
||||
play.requestFocus();
|
||||
await tester.pump();
|
||||
expect(hasHorizontalNeighbor(container, TraversalDirection.left), isFalse);
|
||||
expect(hasHorizontalNeighbor(container, TraversalDirection.right), isTrue);
|
||||
|
||||
download.requestFocus();
|
||||
await tester.pump();
|
||||
expect(hasHorizontalNeighbor(container, TraversalDirection.left), isTrue);
|
||||
expect(hasHorizontalNeighbor(container, TraversalDirection.right), isTrue);
|
||||
|
||||
more.requestFocus();
|
||||
await tester.pump();
|
||||
expect(hasHorizontalNeighbor(container, TraversalDirection.right), isFalse);
|
||||
expect(hasHorizontalNeighbor(container, TraversalDirection.left), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('single-item row has no horizontal neighbor either way (rating-chip case)', (tester) async {
|
||||
final container = FocusNode(debugLabel: 'row', skipTraversal: true);
|
||||
final only = FocusNode(debugLabel: 'chip');
|
||||
addTearDown(container.dispose);
|
||||
addTearDown(only.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Focus(
|
||||
focusNode: container,
|
||||
skipTraversal: true,
|
||||
child: Focus(focusNode: only, child: const SizedBox(width: 50, height: 50)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
only.requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
expect(hasHorizontalNeighbor(container, TraversalDirection.left), isFalse);
|
||||
expect(hasHorizontalNeighbor(container, TraversalDirection.right), isFalse);
|
||||
});
|
||||
|
||||
testWidgets('returns false when focus is outside the container', (tester) async {
|
||||
final container = FocusNode(debugLabel: 'row');
|
||||
final inside = FocusNode(debugLabel: 'inside');
|
||||
final outside = FocusNode(debugLabel: 'outside');
|
||||
for (final node in [container, inside, outside]) {
|
||||
addTearDown(node.dispose);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
Focus(
|
||||
focusNode: container,
|
||||
child: Focus(focusNode: inside, child: const SizedBox(width: 40, height: 40)),
|
||||
),
|
||||
Focus(focusNode: outside, child: const SizedBox(width: 40, height: 40)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
outside.requestFocus();
|
||||
await tester.pump();
|
||||
|
||||
expect(hasHorizontalNeighbor(container, TraversalDirection.left), isFalse);
|
||||
expect(hasHorizontalNeighbor(container, TraversalDirection.right), isFalse);
|
||||
});
|
||||
});
|
||||
|
||||
group('dpadKeyHandler trapHorizontalEdges', () {
|
||||
testWidgets('consumes edge LEFT/RIGHT so focus cannot escape the group', (tester) async {
|
||||
final trapped = FocusNode(debugLabel: 'trapped');
|
||||
@@ -301,5 +193,68 @@ void main() {
|
||||
expect(navigatedLeft, isTrue);
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'left-target');
|
||||
});
|
||||
|
||||
testWidgets('moves through detail actions when trailer is inserted before shuffle', (tester) async {
|
||||
final play = FocusNode(debugLabel: 'detail_play');
|
||||
final outside = FocusNode(debugLabel: 'outside');
|
||||
addTearDown(play.dispose);
|
||||
addTearDown(outside.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
FocusableActionBar(
|
||||
actions: [
|
||||
FocusableAction(
|
||||
debugLabel: 'unused_play_label',
|
||||
focusNode: play,
|
||||
icon: Icons.play_arrow,
|
||||
onPressed: () {},
|
||||
),
|
||||
FocusableAction(debugLabel: 'detail_trailer', icon: Icons.theaters, onPressed: () {}),
|
||||
FocusableAction(debugLabel: 'detail_shuffle', icon: Icons.shuffle, onPressed: () {}),
|
||||
FocusableAction(debugLabel: 'detail_download', icon: Icons.download, onPressed: () {}),
|
||||
FocusableAction(debugLabel: 'detail_watched', icon: Icons.check, onPressed: () {}),
|
||||
FocusableAction(debugLabel: 'detail_more', icon: Icons.more_vert, onPressed: () {}),
|
||||
],
|
||||
),
|
||||
Focus(focusNode: outside, child: const SizedBox(width: 50, height: 50)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
play.requestFocus();
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'detail_play');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'detail_trailer');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'detail_shuffle');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'detail_download');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'detail_watched');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'detail_more');
|
||||
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'detail_more');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user