From 8461e6ba5f300cc26b07bab4dc0320de1e06a2c2 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Tue, 18 Aug 2026 07:41:54 +0200 Subject: [PATCH] feat(detail): split play button with version picker segment when multiple versions exist There was no way to tell from the detail screen that a movie or episode has multiple versions (theatrical/extended cuts, 1080p/4K encodes); the only entry point was the hidden Play Version item in the overflow menu. The Play button now becomes a Material 3 Expressive split button when the item carries more than one version and its server is reachable: the main segment keeps plain Play (which already resumes the remembered version), and a narrower chevron segment runs the existing Play Version flow (version picker, quality picker when the backend can transcode, preference save). The flow itself is extracted from the context menu into a shared promptAndPlayVersion helper, and FocusableActionBar gains per-action spacingBefore so the joined pair can sit tighter than the rest of the row. Transcode-only quality picking stays in the overflow menu so the chevron keeps signaling a real version choice. close #1881 --- lib/focus/focusable_action_bar.dart | 11 ++- lib/screens/media_detail/action_buttons.dart | 98 ++++++++++++++++++-- lib/screens/media_detail_screen.dart | 1 + lib/utils/video_player_navigation.dart | 79 ++++++++++++++++ lib/widgets/media_context_menu.dart | 72 +------------- test/focus/focusable_action_bar_test.dart | 44 +++++++++ test/screens/media_detail_screen_test.dart | 81 +++++++++++++++- 7 files changed, 307 insertions(+), 79 deletions(-) create mode 100644 test/focus/focusable_action_bar_test.dart diff --git a/lib/focus/focusable_action_bar.dart b/lib/focus/focusable_action_bar.dart index 993d20e2c..9692888e6 100644 --- a/lib/focus/focusable_action_bar.dart +++ b/lib/focus/focusable_action_bar.dart @@ -32,6 +32,12 @@ class FocusableAction { final Widget? child; final FocusableActionBuilder? builder; + /// Row gap between this action and the one before it, overriding the bar's + /// uniform [FocusableActionBar.spacing]. Lets visually joined pairs (the + /// detail screen's split Play button) sit tighter than the rest of the row. + /// Ignored on the first action. + final double? spacingBefore; + const FocusableAction({ this.icon = Symbols.circle_rounded, this.iconColor, @@ -44,6 +50,7 @@ class FocusableAction { this.onPressed, this.child, this.builder, + this.spacingBefore, }); } @@ -190,7 +197,7 @@ class FocusableActionBarState extends State { mainAxisSize: MainAxisSize.min, children: [ for (var i = 0; i < widget.actions.length; i++) ...[ - if (i > 0 && widget.spacing > 0) SizedBox(width: widget.spacing), + if (i > 0 && _gapBefore(i) > 0) SizedBox(width: _gapBefore(i)), _buildButton(i, isKeyboard, duration), ], ], @@ -204,6 +211,8 @@ class FocusableActionBarState extends State { return AnimatedOpacity(opacity: isKeyboard && !_hasAnyFocus ? 0.6 : 1.0, duration: duration, child: row); } + double _gapBefore(int index) => widget.actions[index].spacingBefore ?? widget.spacing; + Widget _buildButton(int index, bool isKeyboard, Duration duration) { final action = widget.actions[index]; final enabled = action.onPressed != null; diff --git a/lib/screens/media_detail/action_buttons.dart b/lib/screens/media_detail/action_buttons.dart index d715edb14..a1828c3cc 100644 --- a/lib/screens/media_detail/action_buttons.dart +++ b/lib/screens/media_detail/action_buttons.dart @@ -13,6 +13,34 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { final playTextStyle = TextStyle(fontSize: isTv ? 17 * tvScale : 16, fontWeight: .w700); final playButtonIcon = AppIcon(playIcon, fill: 1, size: playIconSize); + // Split "Play Version" segment (#1881): a visible second Play segment + // that surfaces multiple versions without opening the ⋮ menu. Only when + // the item itself carries a real choice — a movie/episode with more than + // one inline version — and its server is reachable (offline playback and + // unreachable servers have at most one playable version, so the picker + // would offer versions that can't play; see #1440). Transcode-only + // quality picking deliberately stays in the ⋮ menu: a chevron on every + // item would stop signaling "multiple versions exist". + final itemServerId = serverIdOrNull(metadata.serverId); + final showVersionSplit = + !widget.isOffline && + (metadata.isMovie || metadata.isEpisode) && + (metadata.mediaVersions?.length ?? 0) > 1 && + itemServerId != null && + context.read().serverManager.isClientOnline(itemServerId); + // M3E split-button geometry: stadium outer corners, small joined inner + // corners, a hairline gap between the two segments, and a trailing + // segment narrower than a full action so it reads as Play's appendix. + final splitGap = isTv ? 2.0 * tvScale : 2.0; + final versionSegmentWidth = actionSize * 0.75; + final splitInnerRadius = Radius.circular(isTv ? 8.0 * tvScale : 8.0); + final splitOuterRadius = Radius.circular(actionSize / 2); + final playShape = showVersionSplit + ? RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.horizontal(start: splitOuterRadius, end: splitInnerRadius), + ) + : null; + Future onPlayPressed() async { // For TV shows, play the OnDeck episode if available // Otherwise, play the first episode of the first season @@ -53,6 +81,13 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { } } + Future onPlayVersionPressed() async { + final didNavigate = await promptAndPlayVersion(context, metadata); + // Same post-playback refresh plain Play gets from + // navigateToVideoPlayerWithRefresh; the split segment is online-only. + if (didNavigate && mounted) unawaited(_loadFullMetadata()); + } + final primaryTrailer = _getPrimaryTrailer(); final isKeyboardMode = InputModeTracker.isKeyboardMode(context); @@ -71,10 +106,15 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { return null; // default for other states }); - ButtonStyle actionButtonStyle({Color? foregroundColor, EdgeInsetsGeometry? padding, bool showFocus = false}) { + ButtonStyle actionButtonStyle({ + Color? foregroundColor, + EdgeInsetsGeometry? padding, + bool showFocus = false, + OutlinedBorder? shape, + }) { if (!isKeyboardMode && !isTv) { if (padding != null) { - return FilledButton.styleFrom(padding: padding); + return FilledButton.styleFrom(padding: padding, shape: shape); } return IconButton.styleFrom( minimumSize: const Size(48, 48), @@ -93,6 +133,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { overlayColor: noOverlay, backgroundColor: WidgetStatePropertyAll(showFocus ? focusBg : idleBg), foregroundColor: WidgetStatePropertyAll(showFocus ? focusFg : foregroundColor ?? tonalFg), + shape: shape != null ? WidgetStatePropertyAll(shape) : null, ); } @@ -117,6 +158,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { style: actionButtonStyle( showFocus: state.showFocus, padding: .symmetric(horizontal: isTv ? 17 * tvScale : 16, vertical: isTv ? 9 * tvScale : 0), + shape: playShape, ), child: playButtonLabel.isNotEmpty ? Row( @@ -133,6 +175,35 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { ); } + // The trailing segment of the split Play button. A plain FilledButton + // (not IconButton) so both segments share color roles and focus styling. + Widget versionButton(FocusableActionBuildState state) { + return Semantics( + label: t.mediaMenu.playVersion, + button: true, + onTap: onPlayVersionPressed, + excludeSemantics: true, + child: Tooltip( + message: t.mediaMenu.playVersion, + child: SizedBox( + width: versionSegmentWidth, + height: actionSize, + child: FilledButton( + onPressed: onPlayVersionPressed, + style: actionButtonStyle( + showFocus: state.showFocus, + padding: .zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.horizontal(start: splitInnerRadius, end: splitOuterRadius), + ), + ), + child: AppIcon(Symbols.keyboard_arrow_down_rounded, fill: 1, size: playIconSize), + ), + ), + ), + ); + } + Widget iconActionButton( FocusableActionBuildState state, { required Widget icon, @@ -157,6 +228,15 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { builder: (context, state) => playButton(state), ); + final versionAction = showVersionSplit + ? FocusableAction( + debugLabel: 'detail_play_version', + spacingBefore: splitGap, + onPressed: onPlayVersionPressed, + builder: (context, state) => versionButton(state), + ) + : null; + final trailerAction = primaryTrailer == null ? null : FocusableAction( @@ -254,6 +334,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { final allActions = [ playAction, + ?versionAction, ?trailerAction, ?shuffleAction, ?downloadAction, @@ -279,7 +360,12 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { final estimatedPlayWidth = playButtonWidthEstimate(); double estimatedRowWidth(List actions) { if (actions.isEmpty) return 0; - return estimatedPlayWidth + (actions.length - 1) * actionSize + (actions.length - 1) * gap; + var width = estimatedPlayWidth; + for (var i = 1; i < actions.length; i++) { + final actionWidth = identical(actions[i], versionAction) ? versionSegmentWidth : actionSize; + width += (actions[i].spacingBefore ?? gap) + actionWidth; + } + return width; } List compactActionsFor(double maxWidth) { @@ -289,13 +375,13 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { return compact; } - final medium = [playAction, ?downloadAction, watchedAction, ?moreActionsAction]; + final medium = [playAction, ?versionAction, ?downloadAction, watchedAction, ?moreActionsAction]; if (!maxWidth.isFinite || estimatedRowWidth(medium) <= maxWidth) return medium; - final compact = [playAction, watchedAction, ?moreActionsAction]; + final compact = [playAction, ?versionAction, watchedAction, ?moreActionsAction]; if (estimatedRowWidth(compact) <= maxWidth) return compact; - return [playAction, ?moreActionsAction]; + return [playAction, ?versionAction, ?moreActionsAction]; } Widget actionBar(List actions) { diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index b35e854b5..da50f1622 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -61,6 +61,7 @@ import '../widgets/settings_builder.dart'; import '../utils/layout_constants.dart'; import '../providers/catalog_sources_provider.dart'; import '../providers/download_provider.dart'; +import '../providers/multi_server_provider.dart'; import '../providers/offline_watch_provider.dart'; import '../providers/watch_state_store.dart'; import '../services/catalog/catalog_source.dart'; diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart index 109705efc..41a44e441 100644 --- a/lib/utils/video_player_navigation.dart +++ b/lib/utils/video_player_navigation.dart @@ -22,6 +22,11 @@ import '../services/settings_service.dart'; import 'app_logger.dart'; import 'global_key_utils.dart'; import 'platform_detector.dart'; +import 'download_version_utils.dart'; +import 'media_version_resolver.dart'; +import 'provider_extensions.dart'; +import 'quality_preset_labels.dart'; +import '../i18n/strings.g.dart'; const String kVideoPlayerRouteName = '/video_player'; @@ -436,6 +441,80 @@ Future navigateToVideoPlayerWithRefresh( return result; } +/// Sum of [MediaPart.sizeBytes] across all parts of [version]. Returns +/// null when any part is missing a size (a partial sum would be misleading +/// for the "Original" row in the quality picker). +int? _versionSizeBytes(MediaVersion? version) { + if (version == null || version.parts.isEmpty) return null; + var total = 0; + for (final p in version.parts) { + final s = p.sizeBytes; + if (s == null || s <= 0) return null; + total += s; + } + return total > 0 ? total : null; +} + +/// The shared "Play Version..." flow behind the detail screen's split Play +/// segment and the context menu entry: pick a version when the item has a +/// choice, pick a transcode quality when the backend can transcode, persist +/// the pick, and launch playback. +/// +/// Returns true when playback navigation started; false when the user +/// dismissed a picker or the context went away. The returned future completes +/// after the player route pops, so callers can refresh on return. +Future promptAndPlayVersion(BuildContext context, MediaItem item) async { + final itemServerId = serverIdOrNull(item.serverId); + final client = context.tryGetMediaClientForServer(itemServerId); + final itemServerOnline = + itemServerId != null && context.read().serverManager.isClientOnline(itemServerId); + // Same flag the in-player Version & Quality sheet reads — keeps both + // surfaces honest about what the active backend can actually do. Also + // requires a reachable server: capabilities are static, and a server + // dropping between surface build and tap must not offer transcodes. + final canTranscode = itemServerOnline && (client?.capabilities.videoTranscoding ?? false); + final versions = client == null + ? item.mediaVersions ?? const [] + : await resolveMediaVersions(item, client); + if (!context.mounted) return false; + + int selectedVersionIndex = 0; + if (versions.length > 1) { + final picked = await showVersionPickerDialog(context, versions, t.mediaMenu.playVersion); + if (picked == null || !context.mounted) return false; + selectedVersionIndex = picked; + } + + final selectedVersion = selectedVersionIndex < versions.length ? versions[selectedVersionIndex] : null; + TranscodeQualityPreset selectedQuality = TranscodeQualityPreset.original; + if (canTranscode) { + final picked = await showQualityPickerDialog( + context, + sourceBitrateKbps: selectedVersion?.bitrate, + sourceDurationMs: item.durationMs, + sourceSizeBytes: _versionSizeBytes(selectedVersion), + ); + if (picked == null || !context.mounted) return false; + selectedQuality = picked; + } + + // Remember the pick so Continue Watching / plain Play resume this version + // (#1492) — same store the in-player version switch writes. + if (versions.length > 1) { + await saveMediaVersionPreferenceFor(item, index: selectedVersionIndex, versions: versions); + if (!context.mounted) return false; + } + + await navigateToVideoPlayer( + context, + metadata: item, + selectedMediaIndex: selectedVersionIndex, + selectedMediaSourceId: selectedVersion?.id, + selectedQualityPreset: selectedQuality, + ); + return true; +} + /// Resolves the current Watch Together media and opens the video player. /// /// Returns whether navigation was initiated. The fetch can outlive the diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index d9513ebbc..89d4481e0 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -13,7 +13,6 @@ import '../media/media_kind.dart'; import '../media/media_playlist.dart'; import '../media/media_server_client.dart'; import '../metadata_edit/metadata_edit_adapters.dart'; -import '../media/media_version.dart'; import '../services/plex_client.dart'; import '../services/media_list_playback_launcher.dart'; import '../services/music/music_playback_service.dart'; @@ -21,13 +20,9 @@ import '../services/offline_watch_sync_service.dart'; import '../services/playlist_items_loader.dart'; import '../services/watch_actions.dart'; import '../services/catalog/library_watchlist_candidates.dart'; -import '../models/transcode_quality_preset.dart'; import '../utils/content_utils.dart'; import '../utils/delete_impact.dart'; -import '../utils/download_version_utils.dart'; import '../utils/download_utils.dart'; -import '../utils/quality_preset_labels.dart'; -import '../utils/media_version_resolver.dart'; import '../utils/global_key_utils.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; @@ -852,7 +847,7 @@ class MediaContextMenuState extends State { break; case 'play_version': - didNavigate = await _handlePlayVersion(context); + didNavigate = await promptAndPlayVersion(context, _mediaItem!); break; case 'fileinfo': @@ -1163,71 +1158,6 @@ class MediaContextMenuState extends State { } } - Future _handlePlayVersion(BuildContext context) async { - final item = _mediaItem!; - final itemServerId = serverIdOrNull(_itemServerId); - final client = context.tryGetMediaClientForServer(itemServerId); - final itemServerOnline = - itemServerId != null && context.read().serverManager.isClientOnline(itemServerId); - // Same flag the in-player Version & Quality sheet reads — keeps both - // surfaces honest about what the active backend can actually do. Also - // requires a reachable server: capabilities are static, and a server - // dropping between menu open and tap must not offer transcodes. - final canTranscode = itemServerOnline && (client?.capabilities.videoTranscoding ?? false); - final versions = client == null ? item.mediaVersions ?? const [] : await resolveMediaVersions(item, client); - if (!context.mounted) return false; - - int selectedVersionIndex = 0; - if (versions.length > 1) { - final picked = await showVersionPickerDialog(context, versions, t.mediaMenu.playVersion); - if (picked == null || !context.mounted) return false; - selectedVersionIndex = picked; - } - - final selectedVersion = selectedVersionIndex < versions.length ? versions[selectedVersionIndex] : null; - TranscodeQualityPreset selectedQuality = TranscodeQualityPreset.original; - if (canTranscode) { - final picked = await showQualityPickerDialog( - context, - sourceBitrateKbps: selectedVersion?.bitrate, - sourceDurationMs: item.durationMs, - sourceSizeBytes: _versionSizeBytes(selectedVersion), - ); - if (picked == null || !context.mounted) return false; - selectedQuality = picked; - } - - // Remember the pick so Continue Watching / plain Play resume this version - // (#1492) — same store the in-player version switch writes. - if (versions.length > 1) { - await saveMediaVersionPreferenceFor(item, index: selectedVersionIndex, versions: versions); - if (!context.mounted) return false; - } - - await navigateToVideoPlayer( - context, - metadata: item, - selectedMediaIndex: selectedVersionIndex, - selectedMediaSourceId: selectedVersion?.id, - selectedQualityPreset: selectedQuality, - ); - return true; - } - - /// Sum of [MediaPart.sizeBytes] across all parts of [version]. Returns - /// null when any part is missing a size (a partial sum would be misleading - /// for the "Original" row in the quality picker). - int? _versionSizeBytes(MediaVersion? version) { - if (version == null || version.parts.isEmpty) return null; - var total = 0; - for (final p in version.parts) { - final s = p.sizeBytes; - if (s == null || s <= 0) return null; - total += s; - } - return total > 0 ? total : null; - } - /// The track list music playback should operate on for [item]: the item /// itself for a track, an album's tracks, or an artist's playable /// descendants (one server round-trip for the container kinds). diff --git a/test/focus/focusable_action_bar_test.dart b/test/focus/focusable_action_bar_test.dart new file mode 100644 index 000000000..4b8249bbf --- /dev/null +++ b/test/focus/focusable_action_bar_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:plezy/focus/focusable_action_bar.dart'; + +void main() { + Future pumpBar(WidgetTester tester, List actions, {double spacing = 12}) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: FocusableActionBar(actions: actions, spacing: spacing), + ), + ), + ), + ); + } + + testWidgets('spacingBefore tightens one gap while the rest keep the row spacing', (tester) async { + // The detail screen's split Play button relies on this: the chevron + // segment sits a hairline from the play segment while the remaining + // actions keep the uniform row gap. + await pumpBar(tester, [ + FocusableAction(icon: Symbols.play_arrow_rounded, debugLabel: 'play', onPressed: () {}), + FocusableAction( + icon: Symbols.keyboard_arrow_down_rounded, + debugLabel: 'version', + onPressed: () {}, + spacingBefore: 2, + ), + FocusableAction(icon: Symbols.shuffle_rounded, debugLabel: 'shuffle', onPressed: () {}), + ]); + + final buttons = find.byType(IconButton); + expect(buttons, findsNWidgets(3)); + + final play = tester.getRect(buttons.at(0)); + final version = tester.getRect(buttons.at(1)); + final shuffle = tester.getRect(buttons.at(2)); + + expect(version.left - play.right, 2); + expect(shuffle.left - version.right, 12); + }); +} diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index bbe582157..b5418cd87 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -966,6 +966,7 @@ void main() { String? initialSeasonId, int? initialSeasonIndex, String? initialEpisodeId, + NavigatorObserver? observer, }) async { TvDetectionService.debugSetAppleTVOverride(false); await SettingsService.getInstance(); @@ -989,11 +990,14 @@ void main() { // testMultiServer disposes the manager as well as its provider; // MultiServerProvider does not own the manager, and manager.dispose() is // what closes its status/progress controllers and the registered client. - final multiServerProvider = testMultiServer(clients: [client]).provider; + final multi = testMultiServer(clients: [client]); + final multiServerProvider = multi.provider; final watchStateOverlay = WatchStateStore(); + final offlineWatch = OfflineWatchSyncService(database: db, serverManager: multi.manager); addTearDown(() async { watchStateOverlay.dispose(); + offlineWatch.dispose(); downloadProvider.dispose(); downloadManager.dispose(); await db.close(); @@ -1006,8 +1010,10 @@ void main() { ChangeNotifierProvider.value(value: multiServerProvider), ChangeNotifierProvider.value(value: downloadProvider), ChangeNotifierProvider.value(value: watchStateOverlay), + ChangeNotifierProvider.value(value: offlineWatch), ], child: MaterialApp( + navigatorObservers: [?observer], theme: monoTheme(dark: true), home: withProfileNavigationScope( child: MediaDetailScreen( @@ -1144,6 +1150,79 @@ void main() { expect(find.text('Director'), findsNothing); }); + testWidgets('movie with multiple versions gets a split segment that plays the picked version', (tester) async { + // Issue #1881: the split Play chevron makes multiple versions visible + // on the detail screen and runs the existing Play Version flow. + final versions = [MediaVersion(id: 'v1', videoResolution: '1080'), MediaVersion(id: 'v2', videoResolution: '4k')]; + final movie = testMediaItem( + id: 'multi_version', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + title: 'Multi Version', + serverId: 'server_1', + serverName: 'Server', + mediaVersions: versions, + ); + final client = _FakeMediaServerClient(show: movie, childrenByParent: const {}); + final observer = _RecordingNavigatorObserver(popVideoPlayerImmediately: true); + + await pumpPhoneDetail(tester, client, movie, observer: observer); + + final chevron = find.descendant( + of: find.byType(FocusableActionBar), + matching: find.byIcon(Symbols.keyboard_arrow_down_rounded), + ); + expect(chevron, findsOneWidget); + + await tester.tap(chevron); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // The existing version picker, listing every version. Scoped to the + // dialog: the hero's quality chip also renders the resolution label. + Finder inDialog(String text) => find.descendant(of: find.byType(Dialog), matching: find.text(text)); + expect(inDialog(t.mediaMenu.playVersion), findsOneWidget); + expect(inDialog(versions[0].displayLabel), findsOneWidget); + expect(inDialog(versions[1].displayLabel), findsOneWidget); + + await tester.tap(inDialog(versions[1].displayLabel)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // Jellyfin can transcode, so the quality picker follows; keep Original. + await tester.tap(inDialog(t.videoControls.qualityOriginal)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + expect(observer.pushedRouteNames, contains(kVideoPlayerRouteName)); + // The pick is remembered so Continue Watching / plain Play resume it. + final saved = await savedMediaVersionPreferenceFor(movie); + expect(saved?.index, 1); + }); + + testWidgets('single-version movie keeps the plain play button', (tester) async { + final movie = testMediaItem( + id: 'single_version', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + title: 'Single Version', + serverId: 'server_1', + serverName: 'Server', + mediaVersions: [MediaVersion(id: 'v1', videoResolution: '1080')], + ); + final client = _FakeMediaServerClient(show: movie, childrenByParent: const {}); + + await pumpPhoneDetail(tester, client, movie); + + expect( + find.descendant( + of: find.byType(FocusableActionBar), + matching: find.byIcon(Symbols.keyboard_arrow_down_rounded), + ), + findsNothing, + ); + }); + testWidgets('portrait phone hero shows square art instead of the cropped backdrop', (tester) async { final movie = testMediaItem( id: 'square_hero',