From f5012b36ea7ecb7201fc885dff3753de2f08c226 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 30 May 2026 14:51:41 +0200 Subject: [PATCH] fix(media-detail): collapse action buttons that overflow small screens --- lib/screens/media_detail/action_buttons.dart | 204 ++++++++++++------- lib/widgets/media_context_menu.dart | 21 ++ 2 files changed, 157 insertions(+), 68 deletions(-) diff --git a/lib/screens/media_detail/action_buttons.dart b/lib/screens/media_detail/action_buttons.dart index 088b04eb..80ed0ce5 100644 --- a/lib/screens/media_detail/action_buttons.dart +++ b/lib/screens/media_detail/action_buttons.dart @@ -6,7 +6,9 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { final tvScale = TvLayoutConstants.scaleOf(context); final actionSize = isTv ? _tvDetailActionSize * tvScale : 48.0; final playButtonLabel = _getPlayButtonLabel(metadata); - final playButtonIcon = AppIcon(_getPlayButtonIcon(metadata), fill: 1, size: isTv ? 22 * tvScale : 20); + final playIconSize = isTv ? 22 * tvScale : 20.0; + final playTextStyle = TextStyle(fontSize: isTv ? 17 * tvScale : 16, fontWeight: FontWeight.w700); + final playButtonIcon = AppIcon(_getPlayButtonIcon(metadata), fill: 1, size: playIconSize); Future onPlayPressed() async { // For TV shows, play the OnDeck episode if available @@ -96,79 +98,143 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { ); } + // Plays the resolved trailer. Shared by the row's trailer button and the + // ⋮ menu item so the trailer stays reachable when the row hides its button. + final VoidCallback? onPlayTrailer = primaryTrailer == null + ? null + : () => unawaited(navigateToVideoPlayer(context, metadata: primaryTrailer)); + + 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), + ), + 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 buttons) { + return Row( + children: [ + for (var i = 0; i < buttons.length; i++) ...[if (i > 0) SizedBox(width: gap), buttons[i]], + ], + ); + } + + final allButtons = [ + playButton, + if (trailerButton != null) trailerButton, + if (shuffleButton != null) shuffleButton, + if (downloadButton != null) downloadButton, + watchedButton, + if (moreActionsButton != null) moreActionsButton, + ]; + + double playButtonWidthEstimate() { + if (playButtonLabel.isEmpty) return 64.0; + final textPainter = TextPainter( + text: TextSpan(text: playButtonLabel, style: playTextStyle), + maxLines: 1, + textDirection: Directionality.of(context), + )..layout(); + final textWidth = textPainter.width; + textPainter.dispose(); + final horizontalPadding = isTv ? 34.0 * tvScale : 32.0; + final iconGap = isTv ? 7.0 * tvScale : 8.0; + return (horizontalPadding + playIconSize + iconGap + textWidth).clamp(64.0, double.infinity).toDouble(); + } + + final estimatedPlayWidth = playButtonWidthEstimate(); + double estimatedRowWidth(List buttons) { + if (buttons.isEmpty) return 0; + return estimatedPlayWidth + (buttons.length - 1) * actionSize + (buttons.length - 1) * gap; + } + + List compactButtonsFor(double maxWidth) { + if (widget.isOffline) { + final compact = [playButton, watchedButton]; + if (maxWidth.isFinite && estimatedRowWidth(compact) > maxWidth) return [playButton]; + return compact; + } + + final medium = [ + playButton, + if (downloadButton != null) downloadButton, + watchedButton, + if (moreActionsButton != null) moreActionsButton, + ]; + if (!maxWidth.isFinite || estimatedRowWidth(medium) <= maxWidth) return medium; + + final compact = [playButton, watchedButton, if (moreActionsButton != null) moreActionsButton]; + if (estimatedRowWidth(compact) <= maxWidth) return compact; + + return [playButton, if (moreActionsButton != null) moreActionsButton]; + } + return Focus( skipTraversal: true, onFocusChange: (hasFocus) { if (isTv) _setTvDetailActionRowFocus(hasFocus); }, onKeyEvent: _handlePlayButtonKeyEvent, - child: Row( - children: [ - 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), - ), - child: playButtonLabel.isNotEmpty - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - playButtonIcon, - SizedBox(width: isTv ? 7 * tvScale : 8), - Text( - playButtonLabel, - style: TextStyle(fontSize: isTv ? 17 * tvScale : 16, fontWeight: FontWeight.w700), - ), - ], - ) - : playButtonIcon, - ), - ), - SizedBox(width: isTv ? 8 * tvScale : 12), - // Trailer button (only if trailer is available) - if (primaryTrailer != null) ...[ - IconButton.filledTonal( - onPressed: () async { - await navigateToVideoPlayer(context, metadata: primaryTrailer); + // 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)); }, - icon: const AppIcon(Symbols.theaters_rounded, fill: 1), - tooltip: t.tooltips.playTrailer, - iconSize: isTv ? 21 * tvScale : 20, - style: actionButtonStyle(), ), - SizedBox(width: isTv ? 8 * tvScale : 12), - ], - // Shuffle button (only for shows and seasons) - if (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(), - ), - SizedBox(width: isTv ? 8 * tvScale : 12), - ], - // Download button (hide in offline mode - already downloaded, - // and on Apple TV where there's no user file storage). - if (!widget.isOffline && !PlatformDetector.isAppleTV()) - _buildDownloadButton(metadata, actionButtonStyle, tvScale), - SizedBox(width: isTv ? 8 * tvScale : 12), - // Mark as watched/unwatched toggle (works offline too) - _buildWatchedToggleButton(metadata, actionButtonStyle, tvScale), - // Three-dots menu button (hidden in offline mode) - if (!widget.isOffline) ...[ - SizedBox(width: isTv ? 8 * tvScale : 12), - _buildMoreActionsButton(metadata, actionButtonStyle, tvScale), - ], - ], - ), ); } @@ -231,12 +297,14 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { Widget _buildMoreActionsButton( MediaItem metadata, ButtonStyle Function({Color? foregroundColor, EdgeInsetsGeometry? padding}) actionButtonStyle, - double tvScale, - ) { + double tvScale, { + VoidCallback? onPlayTrailer, + }) { return MediaContextMenu( key: _contextMenuKey, item: metadata, onRefresh: (itemId) => unawaited(_refreshItemInPlace(itemId)), + onPlayTrailer: onPlayTrailer, child: Builder( builder: (buttonContext) => IconButton.filledTonal( onPressed: () { diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index b3828063..45dbd089 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -94,6 +94,13 @@ class MediaContextMenu extends StatefulWidget { final VoidCallback? onRemoveFromContinueWatching; final VoidCallback? onListRefresh; // For refreshing list after deletion final VoidCallback? onTap; + + /// Plays the item's trailer. When non-null a "Play trailer" item is added to + /// the menu. Only the detail screen passes this (it resolves the trailer from + /// Plex extras), so the item never appears on card/browse context menus. This + /// keeps the trailer reachable even when the detail row hides its trailer + /// button to fit a small screen. + final VoidCallback? onPlayTrailer; final Widget child; final bool isInContinueWatching; final String? collectionId; // The collection ID if displaying within a collection @@ -105,6 +112,7 @@ class MediaContextMenu extends StatefulWidget { this.onRemoveFromContinueWatching, this.onListRefresh, this.onTap, + this.onPlayTrailer, required this.child, this.isInContinueWatching = false, this.collectionId, @@ -268,6 +276,14 @@ class MediaContextMenuState extends State { ); } + // Trailer playback. The detail row may hide its trailer button on small + // screens, so surface it here whenever the screen wires up onPlayTrailer. + if (widget.onPlayTrailer != null) { + menuActions.add( + _MenuAction(value: 'play_trailer', icon: Symbols.theaters_rounded, label: t.tooltips.playTrailer), + ); + } + if (!mediaItem!.isWatched || isPartiallyWatched || hasActiveProgress) { menuActions.add( _MenuAction(value: 'watch', icon: Symbols.check_circle_outline_rounded, label: t.mediaMenu.markAsWatched), @@ -548,6 +564,11 @@ class MediaContextMenuState extends State { } break; + case 'play_trailer': + didNavigate = true; + widget.onPlayTrailer?.call(); + break; + case 'watch': final isOffline = context.read().isOffline; if (isOffline && mediaItem?.serverId != null) {