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
This commit is contained in:
edde746
2026-08-18 07:41:54 +02:00
parent 93d3ec65e2
commit 8461e6ba5f
7 changed files with 307 additions and 79 deletions
+10 -1
View File
@@ -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<FocusableActionBar> {
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<FocusableActionBar> {
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;
+92 -6
View File
@@ -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<MultiServerProvider>().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<void> 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<void> 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 = <FocusableAction>[
playAction,
?versionAction,
?trailerAction,
?shuffleAction,
?downloadAction,
@@ -279,7 +360,12 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
final estimatedPlayWidth = playButtonWidthEstimate();
double estimatedRowWidth(List<FocusableAction> 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<FocusableAction> compactActionsFor(double maxWidth) {
@@ -289,13 +375,13 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
return compact;
}
final medium = <FocusableAction>[playAction, ?downloadAction, watchedAction, ?moreActionsAction];
final medium = <FocusableAction>[playAction, ?versionAction, ?downloadAction, watchedAction, ?moreActionsAction];
if (!maxWidth.isFinite || estimatedRowWidth(medium) <= maxWidth) return medium;
final compact = <FocusableAction>[playAction, watchedAction, ?moreActionsAction];
final compact = <FocusableAction>[playAction, ?versionAction, watchedAction, ?moreActionsAction];
if (estimatedRowWidth(compact) <= maxWidth) return compact;
return [playAction, ?moreActionsAction];
return [playAction, ?versionAction, ?moreActionsAction];
}
Widget actionBar(List<FocusableAction> actions) {
+1
View File
@@ -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';
+79
View File
@@ -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<bool?> 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<bool> promptAndPlayVersion(BuildContext context, MediaItem item) async {
final itemServerId = serverIdOrNull(item.serverId);
final client = context.tryGetMediaClientForServer(itemServerId);
final itemServerOnline =
itemServerId != null && context.read<MultiServerProvider>().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 <MediaVersion>[]
: 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
+1 -71
View File
@@ -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<MediaContextMenu> {
break;
case 'play_version':
didNavigate = await _handlePlayVersion(context);
didNavigate = await promptAndPlayVersion(context, _mediaItem!);
break;
case 'fileinfo':
@@ -1163,71 +1158,6 @@ class MediaContextMenuState extends State<MediaContextMenu> {
}
}
Future<bool> _handlePlayVersion(BuildContext context) async {
final item = _mediaItem!;
final itemServerId = serverIdOrNull(_itemServerId);
final client = context.tryGetMediaClientForServer(itemServerId);
final itemServerOnline =
itemServerId != null && context.read<MultiServerProvider>().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).
+44
View File
@@ -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<void> pumpBar(WidgetTester tester, List<FocusableAction> 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);
});
}
+80 -1
View File
@@ -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<MultiServerProvider>.value(value: multiServerProvider),
ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
ChangeNotifierProvider<WatchStateStore>.value(value: watchStateOverlay),
ChangeNotifierProvider<OfflineWatchSyncService>.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',