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:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user