feat(detail): preview and choose the audio and subtitle tracks before Play

The detail screen gave no hint of what pressing Play would do with the
file: which audio track and which subtitles the player would start with,
or the picture format. Changing tracks meant starting playback first.

The action row now ends with a status line — picture labels, then the
audio and subtitle rows the player will select — computed by the player's
own selection ladder (TrackSelectionService) over the server's stream
rows, so it agrees with what the player then does. Select opens a
pre-play chooser with the track sheet's two columns; Play and rail
activation pass the pick to the player as its navigation-tier preference,
and a pick carries across episodes by semantics, forced-ness included.
Plex listings carry no stream rows, so a focused episode is probed once,
debounced, and cached for the screen.

Along the way: MediaStream keeps the server's `selected` apart from the
container `default` flag (they were OR'd, so the account's pick was
indistinguishable from the default track); the overlay sheet host no
longer overrides a row that claimed focus itself.
This commit is contained in:
edde746
2026-09-03 13:03:13 +02:00
parent eafacff3be
commit f967b9b2f2
15 changed files with 1214 additions and 36 deletions
+9
View File
@@ -12,8 +12,16 @@ class MediaStream {
final String? languageCode;
final String? title;
final String? displayTitle;
/// The server's current pick for this user (Plex `selected`, Jellyfin's
/// default-stream index). Distinct from [isDefault]: Plex marks the
/// container's default track separately, and the player's selection ladder
/// ranks the two differently.
final bool selected;
/// The container's own default flag (Plex `default`, Jellyfin `IsDefault`).
final bool isDefault;
// Audio
final int? channels;
@@ -41,6 +49,7 @@ class MediaStream {
this.title,
this.displayTitle,
this.selected = false,
this.isDefault = false,
this.channels,
this.frameRate,
this.hdr = false,
+27 -26
View File
@@ -48,12 +48,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
final episode = _showPlayEpisode();
if (episode != null) {
appLogger.d('Playing episode: ${episode.title}');
await navigateToVideoPlayerWithRefresh(
context,
metadata: episode,
isOffline: widget.isOffline,
onRefresh: _refreshWatchState,
);
await _navigateToPlayerWithTrackChoice(episode);
} else {
// No on deck episode, fetch first episode of first season
await _playFirstEpisode();
@@ -61,24 +56,14 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
} else if (metadata.isSeason) {
// For seasons, play the first episode
if (_episodes.isNotEmpty) {
await navigateToVideoPlayerWithRefresh(
context,
metadata: _episodes.first,
isOffline: widget.isOffline,
onRefresh: _refreshWatchState,
);
await _navigateToPlayerWithTrackChoice(_episodes.first);
} else {
await _playFirstEpisode();
}
} else {
appLogger.d('Playing: ${metadata.title}');
// For movies or episodes, play directly
await navigateToVideoPlayerWithRefresh(
context,
metadata: metadata,
isOffline: widget.isOffline,
onRefresh: _refreshWatchState,
);
await _navigateToPlayerWithTrackChoice(metadata);
}
}
@@ -395,17 +380,33 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
);
}
// 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);
// The trailing track status shares the row's leftover width; it is the
// first thing to go, so the buttons never compact because of it. Built
// from the row's own context: the State's context sits above the screen's
// OverlaySheetHost, and the chooser must open hosted, not as a modal.
FocusableAction? tracksActionFor(BuildContext rowContext, List<FocusableAction> actions, double maxWidth) {
if (!maxWidth.isFinite) return null;
final remaining = maxWidth - estimatedRowWidth(actions) - gap;
if (remaining < (isTv ? 180 * tvScale : 160)) return null;
return _buildPlaybackTracksAction(
rowContext,
metadata,
isTv: isTv,
tvScale: tvScale,
actionSize: actionSize,
maxWidth: remaining,
);
}
return LayoutBuilder(
builder: (context, constraints) {
builder: (rowContext, constraints) {
final maxWidth = constraints.maxWidth;
if (!maxWidth.isFinite || estimatedRowWidth(allActions) <= maxWidth) {
return actionBar(allActions);
}
return actionBar(compactActionsFor(maxWidth));
// TV screens are wide and D-pad focus should see every direct action.
// On smaller online screens, hidden actions remain available from ⋮.
final actions = isTv || !maxWidth.isFinite || estimatedRowWidth(allActions) <= maxWidth
? allActions
: compactActionsFor(maxWidth);
return actionBar([...actions, ?tracksActionFor(rowContext, actions, maxWidth)]);
},
);
}
@@ -0,0 +1,243 @@
part of '../media_detail_screen.dart';
/// The action row's trailing status: what Play will do with this item's
/// picture, audio, and subtitles, computed by the player's own selection
/// ladder ([previewPlaybackTracks]). Activating it opens the pre-play chooser.
extension _MediaDetailPlaybackTracksAction on _MediaDetailScreenState {
/// The item Play would start: the hero's episode for a show, the first
/// episode for a season, the item itself otherwise. Null while a show has
/// not resolved any episode yet.
MediaItem? _playbackTargetItem(MediaItem metadata) {
final MediaItem? target;
if (metadata.isShow) {
target = _showPlayEpisode();
} else if (metadata.isSeason) {
target = _episodes.isEmpty ? null : _episodes.first;
} else {
target = metadata;
}
if (target == null) return null;
return _probedPlaybackItems[target.id] ?? target;
}
PlaybackTrackPreview? _playbackTrackPreview(BuildContext context, MediaItem target) {
// Profile-scoped in the app; nullable so a bare detail screen (widget
// tests) still previews with the ladder's non-profile tiers.
return previewPlaybackTracks(
target,
profile: context.read<AccountPreferencesController?>()?.activePreferences,
choice: _playbackTrackChoice,
);
}
/// Plex listings describe a file only by its container summary, so an
/// episode reached through the rail has no stream rows until its own item
/// is fetched. One request per item, debounced past D-pad scrubbing, cached
/// for the screen's lifetime; the result reaches the hero through the
/// focused-episode notifier so the rail does not rebuild.
void _scheduleTargetProbe(BuildContext context, MediaItem target) {
if (widget.isOffline || _probedPlaybackItems.containsKey(target.id)) return;
final client = _getMediaClientForMetadata(context);
if (client == null) return;
_playbackProbeTimer?.cancel();
_playbackProbeTimer = Timer(const Duration(milliseconds: 350), () {
if (!mounted || _probedPlaybackItems.containsKey(target.id)) return;
unawaited(_probeTarget(client, target));
});
}
Future<void> _probeTarget(MediaServerClient client, MediaItem target) async {
final MediaItem? fetched;
try {
fetched = await client.fetchItem(target.id);
} catch (e, stackTrace) {
appLogger.d('Playback track probe failed for ${target.id}', error: e, stackTrace: stackTrace);
return;
}
if (!mounted || fetched == null) return;
final probed = fetched.copyWith(
serverId: target.serverId ?? fetched.serverId,
serverName: target.serverName ?? fetched.serverName,
);
// A summary-only answer is not worth caching: the chooser retries on demand.
if (previewPlaybackTracks(probed) == null) return;
_probedPlaybackItems[target.id] = probed;
if (_tvDetailFocusedEpisode.value?.id == target.id) {
_tvDetailFocusedEpisode.value = probed;
} else {
setStateIfMounted(() {});
}
}
/// Null when there is nothing to say: no version at all, or a container
/// summary without a resolution.
FocusableAction? _buildPlaybackTracksAction(
BuildContext context,
MediaItem metadata, {
required bool isTv,
required double tvScale,
required double actionSize,
required double maxWidth,
}) {
final target = _playbackTargetItem(metadata);
if (target == null) return null;
final preview = _playbackTrackPreview(context, target);
final videoLabels = buildMediaVideoLabels(target);
if (preview == null && videoLabels.isEmpty) return null;
if (preview == null) _scheduleTargetProbe(context, target);
final audioLabel = preview?.audio?.label;
final subtitleLabel = preview?.subtitle?.label;
final parts = <MetadataLinePart>[
// Shed order on a tight row: the long codec details first (subtitle's,
// then audio's — the sheet has them in full), then the short picture
// labels, then the audio track; the subtitle decision always stays.
for (final label in videoLabels) MetadataLineText(label, dropPriority: 2),
if (audioLabel != null)
MetadataLineIconText(
Symbols.volume_up_rounded,
audioLabel.primary,
detail: audioLabel.secondary,
dropPriority: 1,
detailDropPriority: 3,
),
if (preview != null)
MetadataLineIconText(
Symbols.subtitles_rounded,
subtitleLabel?.primary ?? t.common.off,
detail: subtitleLabel?.secondary,
dropPriority: 0,
detailDropPriority: 3,
),
];
final semanticsLabel =
'${t.videoControls.tracksButton}: ${[for (final part in parts) switch (part) {
MetadataLineText(:final text) => text,
MetadataLineIconText(:final text, :final detail) => detail == null ? text : '$text${MetadataLineIconText.detailSeparator}$detail',
MetadataLineRatings() => '',
}].join(', ')}';
final canChoose =
preview == null || preview.source.audioTracks.length > 1 || preview.source.subtitleTracks.isNotEmpty;
final VoidCallback? onPressed = canChoose && !widget.isOffline
? () => unawaited(_openPlaybackTrackChooser(context, target))
: null;
final colorScheme = Theme.of(context).colorScheme;
final textStyle = TextStyle(
fontSize: isTv ? 14 * tvScale : 12.5,
fontWeight: .w600,
letterSpacing: 0.1,
height: 1.2,
);
final horizontalPadding = isTv ? 12 * tvScale : 10.0;
return FocusableAction(
debugLabel: 'detail_playback_tracks',
onPressed: onPressed,
builder: (context, state) {
final foreground = state.showFocus
? colorScheme.onInverseSurface
: colorScheme.onSurface.withValues(alpha: onPressed == null ? 0.6 : 0.78);
return Semantics(
label: semanticsLabel,
button: onPressed != null,
onTap: onPressed,
excludeSemantics: true,
child: SizedBox(
height: actionSize,
child: TextButton(
onPressed: onPressed,
style: ButtonStyle(
padding: WidgetStatePropertyAll(EdgeInsets.symmetric(horizontal: horizontalPadding)),
minimumSize: WidgetStatePropertyAll(Size(0, actionSize)),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
backgroundColor: WidgetStatePropertyAll(
state.showFocus ? colorScheme.inverseSurface : Colors.transparent,
),
foregroundColor: WidgetStatePropertyAll(foreground),
overlayColor: WidgetStatePropertyAll(state.showFocus ? Colors.transparent : null),
shape: const WidgetStatePropertyAll(StadiumBorder()),
),
child: ConstrainedBox(
constraints: BoxConstraints(maxWidth: maxWidth - horizontalPadding * 2),
child: FittedMetadataLine(
textStyle: textStyle.copyWith(color: foreground),
parts: parts,
ratingIconSize: textStyle.fontSize! * 1.15,
),
),
),
),
);
},
);
}
/// The screen's choice expressed in [target]'s own rows: made on this item
/// it is passed through; made elsewhere it is the ladder's resolution of
/// its semantics against this item (see [PlaybackTrackChoice.itemId]).
/// Members the viewer never chose stay null so the ladder keeps deciding.
PlaybackTrackChoice _choiceFor(BuildContext context, MediaItem target) {
final choice = _playbackTrackChoice;
if (choice.isEmpty || choice.itemId == target.id) return choice;
final preview = _playbackTrackPreview(context, _probedPlaybackItems[target.id] ?? target);
if (preview == null) return choice;
return PlaybackTrackChoice(
itemId: target.id,
audio: choice.audio == null ? null : preview.audioTrack,
subtitle: choice.subtitle == null ? null : preview.subtitleTrack,
);
}
/// Every Play path from this screen goes through here so the chooser's
/// pick reaches the player as its navigation-tier preference.
Future<void> _navigateToPlayerWithTrackChoice(MediaItem item) {
final choice = _choiceFor(context, item);
return navigateToVideoPlayerWithRefresh(
context,
metadata: item,
isOffline: widget.isOffline,
onRefresh: _refreshWatchState,
preferredAudioTrack: choice.audio,
preferredSubtitleTrack: choice.subtitle,
);
}
Future<void> _openPlaybackTrackChooser(BuildContext context, MediaItem target) async {
var preview = _playbackTrackPreview(context, target);
if (preview == null) {
// The listing only carried the container summary; the full item has the
// stream rows. One request, on an explicit user action.
final client = _getMediaClientForMetadata(context);
final fetched = client == null ? null : await client.fetchItem(target.id);
if (!context.mounted) return;
if (fetched != null) {
preview = _playbackTrackPreview(context, fetched);
if (preview != null) setStateIfMounted(() => _probedPlaybackItems[target.id] = fetched);
}
}
if (preview == null) {
showErrorSnackBar(context, t.messages.fileInfoNotAvailable);
return;
}
final resolved = preview;
final source = resolved.source;
if (source.audioTracks.length <= 1 && source.subtitleTracks.isEmpty) return;
await OverlaySheetController.showAdaptive<void>(
context,
isScrollControlled: true,
builder: (_) => PlaybackTrackChooserSheet(
source: source,
effectiveAudio: resolved.audio,
effectiveSubtitle: resolved.subtitle,
choice: _choiceFor(context, target).copyWith(itemId: target.id),
onChanged: (choice) => setStateIfMounted(() => _playbackTrackChoice = choice),
),
);
}
}
+19 -1
View File
@@ -98,8 +98,12 @@ import '../widgets/loading_indicator_box.dart';
import '../widgets/rasterized_gradient.dart';
import '../widgets/tv_browse_rail.dart';
import '../widgets/tv_spotlight_background.dart';
import '../providers/account_preferences_controller.dart';
import '../services/playback_track_preview.dart';
import '../widgets/playback_track_chooser_sheet.dart';
part 'media_detail/action_buttons.dart';
part 'media_detail/playback_tracks_action.dart';
/// Ceiling for the detail hero's backdrop box, as a fraction of the window
/// height. Roughly the natural height of a 16:9 backdrop on a 16:10 desktop
@@ -317,6 +321,16 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final ValueNotifier<MediaItem?> _tvDetailFocusedEpisode = ValueNotifier(null);
bool _tvDetailActionRowHasFocus = false;
// Pre-play audio/subtitle choice from the action row's track chooser. One
// choice per screen: it is a `source:` descriptor the player resolves by
// intent, so it carries across the episodes browsed here the same way a
// manual pick carries into autoplay-next.
PlaybackTrackChoice _playbackTrackChoice = const PlaybackTrackChoice();
// Full items fetched for the chooser when a listing gave only the container
// summary; keyed by item id so the status line upgrades after the fetch.
final Map<String, MediaItem> _probedPlaybackItems = {};
Timer? _playbackProbeTimer;
// Watchlist action (external catalog sources: Trakt, MAL). External ids
// resolve once via the owning server, then per capable source; membership
// comes from each source's session snapshot, so opening details never
@@ -855,6 +869,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
_scrollController.dispose();
_scrollOffset.dispose();
_tvDetailFocusedEpisode.dispose();
_playbackProbeTimer?.cancel();
_extrasScrollController.dispose();
_extrasFocusNode.removeListener(_handleExtrasFocusChange);
_extrasFocusNode.dispose();
@@ -3793,7 +3808,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
for (final part in _tvDetailMetadataParts(metadata)) {
add(switch (part) {
MetadataLineText(:final text) => text,
MetadataLineText(:final text) || MetadataLineIconText(:final text) => text,
MetadataLineRatings(:final ratings) => ratingsSemanticLabel(ratings),
});
}
@@ -4098,11 +4113,14 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
Future<bool> _handleTvDetailRailItemActivated(MediaHub hub, MediaItem item) async {
if (_isTvDetailEpisodeHub(hub) && item.isEpisode) {
final choice = _choiceFor(context, item);
await navigateToVideoPlayerWithRefresh(
context,
metadata: item,
isOffline: widget.isOffline,
onRefresh: () => unawaited(_refreshItemInPlace(item)),
preferredAudioTrack: choice.audio,
preferredSubtitleTrack: choice.subtitle,
);
return true;
}
+1
View File
@@ -574,6 +574,7 @@ class JellyfinMappers {
defaultAudioStreamIndex: defaultAudioStreamIndex,
defaultSubtitleStreamIndex: defaultSubtitleStreamIndex,
),
isDefault: f.isDefault,
channels: f.channels,
frameRate: f.frameRate,
hdr: isVideo && jellyfinVideoStreamIsHdr(source ?? const <String, dynamic>{}, s),
+230
View File
@@ -0,0 +1,230 @@
import '../media/media_item.dart';
import '../media/media_server_user_profile.dart';
import '../media/media_source_info.dart';
import '../media/media_stream.dart';
import '../media/media_version.dart';
import '../mpv/mpv.dart';
import 'playback_subtitle_resolver.dart';
import 'subtitle_preference.dart';
import 'track_selection_service.dart';
/// The viewer's explicit pre-play track choice, made on a detail screen before
/// playback starts. Both members are the stable `source:` descriptors the
/// player already accepts as its highest-priority preference
/// ([PlaybackSubtitleResolver.audioTrackForSource] and
/// [PlaybackSubtitleResolver.subtitleTrackForSource]); [subtitle] may also be
/// [SubtitleTrack.off]. A null member means "let the ladder decide".
///
/// [itemId] names the item the rows belong to. Applied to any other item the
/// choice is only semantics — language, title, forced-ness — exactly the way a
/// manual pick carries into autoplay-next ([SubtitlePreference.demoteToIntent]).
class PlaybackTrackChoice {
final String? itemId;
final AudioTrack? audio;
final SubtitleTrack? subtitle;
const PlaybackTrackChoice({this.itemId, this.audio, this.subtitle});
PlaybackTrackChoice copyWith({String? itemId, AudioTrack? audio, SubtitleTrack? subtitle}) {
return PlaybackTrackChoice(
itemId: itemId ?? this.itemId,
audio: audio ?? this.audio,
subtitle: subtitle ?? this.subtitle,
);
}
bool get isEmpty => audio == null && subtitle == null;
}
/// What the player will do with an item's tracks, decided before playback by
/// the same selection ladder the player runs ([TrackSelectionService]) over
/// the server's own stream rows. Two ladder inputs are genuinely unknowable
/// here — a manual choice carried from a previous episode, and a Plex
/// transcode decision — everything else is the real thing.
class PlaybackTrackPreview {
final MediaVersion version;
/// The version's audio and subtitle rows, as the player will see them.
final MediaSourceInfo source;
/// The audio row the ladder picks; null when the file carries no audio rows.
final MediaAudioTrack? audio;
/// The subtitle row the ladder picks; null means subtitles start off.
final MediaSubtitleTrack? subtitle;
/// [audio] and [subtitle] as the player's own descriptors — what Play hands
/// over as its navigation-tier preference once the viewer has chosen, so a
/// choice carried from another episode reaches the player already resolved
/// to this item's rows. [subtitleTrack] is [SubtitleTrack.off] for "off".
final AudioTrack? audioTrack;
final SubtitleTrack subtitleTrack;
const PlaybackTrackPreview({
required this.version,
required this.source,
required this.audio,
required this.subtitle,
required this.audioTrack,
required this.subtitleTrack,
});
}
/// Whether [version] carries per-stream rows. Plex listings sometimes describe
/// a file only by its container summary (`Media.audioCodec`); those synthetic
/// rows have no stream index and no per-track identity, so a preview built on
/// them would name tracks the file does not have.
bool mediaVersionHasProbedStreams(MediaVersion version) {
for (final part in version.parts) {
for (final stream in part.streams) {
if ((stream.kind == MediaStreamKind.audio || stream.kind == MediaStreamKind.subtitle) && stream.index != null) {
return true;
}
}
}
return false;
}
/// The audio and subtitle rows of [version] in the shape the playback pipeline
/// uses, plus the player-side descriptors the ladder ranks. Null when the
/// version has no probed streams (see [mediaVersionHasProbedStreams]).
///
/// The descriptors' `isDefault` is the container's own flag, which is what
/// the player sees on its native tracks; the server's `selected` rides on the
/// rows, where the ladder's server-selected tier reads it.
_VersionTracks? _versionTracks(MediaVersion version) {
if (!mediaVersionHasProbedStreams(version)) return null;
final audioRows = <MediaAudioTrack>[];
final subtitleRows = <MediaSubtitleTrack>[];
final audioTracks = <AudioTrack>[];
final subtitleTracks = <SubtitleTrack>[];
for (final part in version.parts) {
for (final stream in part.streams) {
final id = int.tryParse(stream.id) ?? stream.index;
if (id == null) continue;
switch (stream.kind) {
case MediaStreamKind.audio:
final row = MediaAudioTrack(
id: id,
index: stream.index,
codec: stream.codec,
language: stream.language,
languageCode: stream.languageCode,
title: stream.title,
displayTitle: stream.displayTitle,
channels: stream.channels,
selected: stream.selected,
);
audioRows.add(row);
audioTracks.add(PlaybackSubtitleResolver.audioTrackForSource(row).copyWith(isDefault: stream.isDefault));
case MediaStreamKind.subtitle:
final row = MediaSubtitleTrack(
id: id,
index: stream.index,
codec: stream.codec,
language: stream.language,
languageCode: stream.languageCode,
title: stream.title,
displayTitle: stream.displayTitle,
selected: stream.selected,
forced: stream.forced,
key: stream.sidecarPath,
external: stream.isExternal,
);
subtitleRows.add(row);
subtitleTracks.add(
PlaybackSubtitleResolver.subtitleTrackForSource(row).copyWith(isDefault: stream.isDefault),
);
case MediaStreamKind.video:
case MediaStreamKind.image:
case MediaStreamKind.data:
case MediaStreamKind.lyric:
case MediaStreamKind.unknown:
break;
}
}
}
return _VersionTracks(
source: MediaSourceInfo(videoUrl: '', audioTracks: audioRows, subtitleTracks: subtitleRows, chapters: const []),
audioTracks: audioTracks,
subtitleTracks: subtitleTracks,
);
}
class _VersionTracks {
final MediaSourceInfo source;
final List<AudioTrack> audioTracks;
final List<SubtitleTrack> subtitleTracks;
const _VersionTracks({required this.source, required this.audioTracks, required this.subtitleTracks});
}
/// Run the player's selection ladder for [item] ahead of playback.
///
/// Returns null when [item] has no version at [versionIndex] or the version
/// has no probed streams — the caller shows nothing rather than a guess.
PlaybackTrackPreview? previewPlaybackTracks(
MediaItem item, {
int versionIndex = 0,
MediaServerUserProfile? profile,
PlaybackTrackChoice? choice,
}) {
final versions = item.mediaVersions;
if (versions == null || versions.isEmpty) return null;
final version = versionIndex >= 0 && versionIndex < versions.length ? versions[versionIndex] : versions.first;
final tracks = _versionTracks(version);
if (tracks == null) return null;
final source = tracks.source;
final audioRows = tracks.audioTracks;
final subtitleRows = tracks.subtitleTracks;
final service = TrackSelectionService(profileSettings: profile, metadata: item, plexMediaInfo: source);
// Within the item the choice is an identity; anywhere else only its
// semantics apply. Audio descriptors already carry their semantics
// (findNativeAudioTrackForIntent); subtitles need the explicit demotion so
// forced-ness survives the boundary.
final sameItem = choice?.itemId == item.id;
final subtitlePreference = SubtitlePreference.trackOrNull(choice?.subtitle);
final audioResult = service.selectAudioTrack(audioRows, choice?.audio);
final audio = audioResult == null ? null : _sourceAudioRow(source, audioResult.track);
final subtitleResult = service.selectSubtitleTrack(
subtitleRows,
sameItem ? subtitlePreference : SubtitlePreference.demoteToIntent(subtitlePreference),
audioResult?.track,
waitForPendingSource: false,
);
final subtitle = subtitleResult == null ? null : _sourceSubtitleRow(source, subtitleResult.track);
return PlaybackTrackPreview(
version: version,
source: source,
audio: audio,
subtitle: subtitle,
audioTrack: audioResult?.track,
subtitleTrack: subtitle == null ? SubtitleTrack.off : subtitleResult!.track,
);
}
int? _sourceIdOf(String trackId) {
const prefix = 'source:';
return trackId.startsWith(prefix) ? int.tryParse(trackId.substring(prefix.length)) : null;
}
MediaAudioTrack? _sourceAudioRow(MediaSourceInfo source, AudioTrack track) {
final id = _sourceIdOf(track.id);
if (id == null) return null;
for (final row in source.audioTracks) {
if (row.id == id) return row;
}
return null;
}
MediaSubtitleTrack? _sourceSubtitleRow(MediaSourceInfo source, SubtitleTrack track) {
final id = _sourceIdOf(track.id);
if (id == null) return null;
for (final row in source.subtitleTracks) {
if (row.id == id) return row;
}
return null;
}
+2 -1
View File
@@ -126,7 +126,8 @@ List<MediaStream> _mediaStreamsFromPlexPart(Object? raw, {Map<String, dynamic>?
languageCode: stream['languageCode']?.toString(),
title: stream['title']?.toString(),
displayTitle: stream['displayTitle']?.toString() ?? stream['extendedDisplayTitle']?.toString(),
selected: flexibleBool(stream['selected']) || flexibleBool(stream['default']),
selected: flexibleBool(stream['selected']),
isDefault: flexibleBool(stream['default']),
channels: flexibleInt(stream['channels']),
frameRate: flexibleDouble(stream['frameRate']),
hdr: isHdr,
+26 -1
View File
@@ -27,6 +27,29 @@ List<String> buildMediaQualityLabels(MediaItem item, {int versionIndex = 0}) {
return labels;
}
/// Resolution, video codec, and dynamic range — the picture half of
/// [buildMediaQualityLabels], for a line that names the audio track separately.
List<String> buildMediaVideoLabels(MediaItem item, {int versionIndex = 0}) {
final version = _selectedVersion(item.mediaVersions, versionIndex);
if (version == null) return const [];
final labels = <String>[];
final resolution = _formatResolution(version);
if (resolution != null) labels.add(resolution);
final video = _firstStreamOfKind(version, MediaStreamKind.video);
final codec = (video?.codec ?? version.videoCodec)?.trim();
if (codec != null && codec.isNotEmpty) labels.add(CodecUtils.formatVideoCodec(codec));
if (video?.dolbyVision == true) {
labels.add(_formatDolbyVision(video!));
} else if (video?.hdr == true) {
labels.add('HDR');
}
return labels;
}
String? buildMediaSizeLabel(MediaItem item, {int versionIndex = 0}) {
final version = _selectedVersion(item.mediaVersions, versionIndex);
if (version == null || version.parts.isEmpty) return null;
@@ -73,14 +96,16 @@ MediaStream? _firstStreamOfKind(MediaVersion version, MediaStreamKind kind) {
MediaStream? _selectedAudioStream(MediaVersion version) {
MediaStream? first;
MediaStream? containerDefault;
for (final part in version.parts) {
for (final stream in part.streams) {
if (stream.kind != MediaStreamKind.audio) continue;
first ??= stream;
if (stream.selected) return stream;
if (stream.isDefault) containerDefault ??= stream;
}
}
return first;
return containerDefault ?? first;
}
String? _formatAudio(MediaStream? stream) {
+52 -3
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../media/media_rating.dart';
import '../utils/text_measure_cache.dart';
import 'app_icon.dart';
import 'media_rating_badge.dart';
/// One slot on a [FittedMetadataLine].
@@ -22,6 +23,26 @@ class MetadataLineText extends MetadataLinePart {
final String text;
}
/// A text field led by a glyph instead of a word: the pre-play track summary
/// on the detail action row uses one per track kind so the line stays short
/// enough to share the row with the buttons.
///
/// [detail] is a second, separately droppable unit ("AAC · Stereo" after
/// "Japanese") with its own [detailDropPriority], so a tight line keeps the
/// language and sheds the codec before it sheds the whole track.
class MetadataLineIconText extends MetadataLinePart {
const MetadataLineIconText(this.icon, this.text, {required super.dropPriority, this.detail, int? detailDropPriority})
: detailDropPriority = detailDropPriority ?? dropPriority;
final IconData icon;
final String text;
final String? detail;
final int detailDropPriority;
/// Separator between [text] and [detail]; the track labels' own joiner.
static const String detailSeparator = ' · ';
}
/// The attributed-score slot. Every score shares this one slot so the bullet
/// separators don't multiply with the number of sources.
class MetadataLineRatings extends MetadataLinePart {
@@ -64,6 +85,9 @@ class FittedMetadataLine extends StatelessWidget {
/// Also for callers that append their own trailing widget to the line.
static const String separator = ' • ';
/// Gap between a [MetadataLineIconText] glyph and its text.
static const double iconTextGap = 4;
@override
Widget build(BuildContext context) {
if (parts.isEmpty) return const SizedBox.shrink();
@@ -86,11 +110,16 @@ class FittedMetadataLine extends StatelessWidget {
).width;
// Units are the droppable atoms: a text part is one unit, a ratings
// slot is one unit per badge.
// slot is one unit per badge, an icon part is its text plus an
// optional detail.
final unitWidths = <List<double>>[
for (final part in parts)
switch (part) {
MetadataLineText(:final text) => <double>[textWidth(text)],
MetadataLineIconText(:final text, :final detail) => <double>[
iconSize + iconTextGap + textWidth(text),
if (detail != null) textWidth('${MetadataLineIconText.detailSeparator}$detail'),
],
MetadataLineRatings(:final ratings) => <double>[
for (final rating in ratings)
inlineRatingBadgeWidth(
@@ -113,10 +142,18 @@ class FittedMetadataLine extends StatelessWidget {
for (var unit = 0; unit < kept; unit++) {
width += unitWidths[index][unit];
}
if (kept > 1) width += entryGap * (kept - 1);
// Badges sit apart; an icon part's detail is glued to its text.
if (kept > 1 && parts[index] is MetadataLineRatings) width += entryGap * (kept - 1);
return width;
}
// Priority of the unit that would go next if [index] is chosen.
int nextUnitDropPriority(int index) {
final part = parts[index];
if (part is MetadataLineIconText && keptUnits[index] == 2) return part.detailDropPriority;
return part.dropPriority;
}
double totalWidth() {
var total = 0.0;
var keptParts = 0;
@@ -134,7 +171,7 @@ class FittedMetadataLine extends StatelessWidget {
var dropIndex = -1;
for (var index = 0; index < parts.length; index++) {
if (keptUnits[index] == 0) continue;
if (dropIndex == -1 || parts[index].dropPriority >= parts[dropIndex].dropPriority) dropIndex = index;
if (dropIndex == -1 || nextUnitDropPriority(index) >= nextUnitDropPriority(dropIndex)) dropIndex = index;
}
if (dropIndex == -1) break;
keptUnits[dropIndex]--;
@@ -148,6 +185,18 @@ class FittedMetadataLine extends StatelessWidget {
if (children.isNotEmpty) children.add(Text(separator, maxLines: 1, style: textStyle));
children.add(switch (parts[index]) {
MetadataLineText(:final text) => Text(text, maxLines: 1, style: textStyle),
MetadataLineIconText(:final icon, :final text, :final detail) => Row(
mainAxisSize: MainAxisSize.min,
children: [
AppIcon(icon, size: iconSize, color: textStyle.color),
const SizedBox(width: iconTextGap),
Text(
kept == 2 && detail != null ? '$text${MetadataLineIconText.detailSeparator}$detail' : text,
maxLines: 1,
style: textStyle,
),
],
),
MetadataLineRatings(:final ratings) => InlineRatingBadges(
ratings: kept == ratings.length ? ratings : ratings.sublist(0, kept),
textStyle: textStyle,
+5 -2
View File
@@ -490,12 +490,15 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_isOpen) return;
// If the current top entry has an initialFocusNode that is attached,
// focus that instead of the first descendant.
// focus that instead of the first descendant. A row that claimed focus
// itself while building (`autofocus`, e.g. the selected row of a track
// list) is an equally deliberate target and is left alone.
final topEntry = _pageStack.isNotEmpty ? _pageStack.last : null;
final initialNode = topEntry?.initialFocusNode;
final focusedDescendant = _sheetFocusScopeNode.focusedChild;
if (initialNode != null && initialNode.context != null) {
initialNode.requestFocus();
} else {
} else if (focusedDescendant == null || !focusedDescendant.hasPrimaryFocus) {
_focusFirstDescendant();
}
@@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../i18n/strings.g.dart';
import '../media/media_source_info.dart';
import '../mpv/mpv.dart';
import '../services/playback_subtitle_resolver.dart';
import '../services/playback_track_preview.dart';
import 'video_controls/helpers/track_selection_helper.dart';
import 'video_controls/sheets/base_video_control_sheet.dart';
import 'video_controls/sheets/sheet_selection_column.dart';
import 'video_controls/sheets/sheet_split_columns.dart';
/// Pre-play audio and subtitle picker for a detail screen: the same two
/// columns the player's track sheet shows, over the server's stream rows
/// instead of the live player's. Picking a row does not close the sheet —
/// both columns are usually visited — it reports the new choice through
/// [onChanged]; the caller keeps it and hands it to Play as the player's
/// highest-priority preference.
class PlaybackTrackChooserSheet extends StatefulWidget {
final MediaSourceInfo source;
/// The rows the ladder currently lands on, so the sheet opens with the
/// effective selection marked even before the viewer has chosen anything.
final MediaAudioTrack? effectiveAudio;
final MediaSubtitleTrack? effectiveSubtitle;
final PlaybackTrackChoice choice;
final ValueChanged<PlaybackTrackChoice> onChanged;
const PlaybackTrackChooserSheet({
super.key,
required this.source,
required this.effectiveAudio,
required this.effectiveSubtitle,
required this.choice,
required this.onChanged,
});
@override
State<PlaybackTrackChooserSheet> createState() => _PlaybackTrackChooserSheetState();
}
class _PlaybackTrackChooserSheetState extends State<PlaybackTrackChooserSheet> {
late int? _audioId = widget.effectiveAudio?.id;
/// Null while subtitles start off.
late int? _subtitleId = widget.effectiveSubtitle?.id;
late PlaybackTrackChoice _choice = widget.choice;
void _pickAudio(MediaAudioTrack row) {
setState(() {
_audioId = row.id;
_choice = _choice.copyWith(audio: PlaybackSubtitleResolver.audioTrackForSource(row));
});
widget.onChanged(_choice);
}
void _pickSubtitle(MediaSubtitleTrack? row) {
setState(() {
_subtitleId = row?.id;
_choice = _choice.copyWith(
subtitle: row == null ? SubtitleTrack.off : PlaybackSubtitleResolver.subtitleTrackForSource(row),
);
});
widget.onChanged(_choice);
}
@override
Widget build(BuildContext context) {
final audioRows = widget.source.audioTracks;
final subtitleRows = widget.source.subtitleTracks;
final showAudio = audioRows.length > 1;
final showSubtitles = subtitleRows.isNotEmpty;
// Focus opens on the row the ladder chose — the effective subtitle row
// when subtitles are offered, else the effective audio row — not on the
// first row, which the initial scroll may have pushed out of view. Bound
// once, at open: the autofocus must not hop rows as the pick changes.
final initialAudioId = widget.effectiveAudio?.id;
final initialSubtitleId = widget.effectiveSubtitle?.id;
final audioColumn = SheetSelectionColumn(
headerLabel: showSubtitles ? t.videoControls.audioLabel : null,
itemCount: audioRows.length,
initialIndex: audioRows.indexWhere((row) => row.id == _audioId),
itemBuilder: (context, index, scope) {
final row = audioRows[index];
return TrackSelectionHelper.buildTrackTile(
context: context,
key: scope.keyFor(index),
autofocus: !showSubtitles && row.id == initialAudioId,
label: row.label,
isSelected: row.id == _audioId,
onTap: () => _pickAudio(row),
);
},
);
// Row 0 is Off; source rows follow in server order.
final subtitleColumn = SheetSelectionColumn(
headerLabel: showAudio ? t.videoControls.subtitlesLabel : null,
itemCount: subtitleRows.length + 1,
initialIndex: _subtitleId == null ? 0 : subtitleRows.indexWhere((row) => row.id == _subtitleId) + 1,
itemBuilder: (context, index, scope) {
if (index == 0) {
return TrackSelectionHelper.buildOffTile(
context: context,
key: scope.keyFor(index),
autofocus: initialSubtitleId == null,
isSelected: _subtitleId == null,
onTap: () => _pickSubtitle(null),
);
}
final row = subtitleRows[index - 1];
return TrackSelectionHelper.buildTrackTile(
context: context,
key: scope.keyFor(index),
autofocus: row.id == initialSubtitleId,
label: row.labelForIndex(index - 1),
isSelected: row.id == _subtitleId,
onTap: () => _pickSubtitle(row),
);
},
);
final Widget body;
if (showAudio && showSubtitles) {
body = SheetSplitColumns(
start: FocusTraversalGroup(child: audioColumn),
end: FocusTraversalGroup(child: subtitleColumn),
);
} else if (showAudio) {
body = audioColumn;
} else {
body = subtitleColumn;
}
return BaseVideoControlSheet(title: t.videoControls.tracksButton, icon: Symbols.subtitles_rounded, child: body);
}
}
@@ -13,6 +13,7 @@ class TrackSelectionHelper {
required VoidCallback onTap,
Key? key,
FocusNode? focusNode,
bool autofocus = false,
VoidCallback? onLongPress,
VoidCallback? onSecondaryTap,
Widget? badge,
@@ -24,6 +25,7 @@ class TrackSelectionHelper {
isSelected: isSelected,
onTap: onTap,
focusNode: focusNode,
autofocus: autofocus,
onLongPress: onLongPress,
onSecondaryTap: onSecondaryTap,
badge: badge,
@@ -37,6 +39,7 @@ class TrackSelectionHelper {
required VoidCallback onTap,
Key? key,
FocusNode? focusNode,
bool autofocus = false,
VoidCallback? onLongPress,
VoidCallback? onSecondaryTap,
Widget? badge,
@@ -49,6 +52,7 @@ class TrackSelectionHelper {
isSelected: isSelected,
onTap: onTap,
focusNode: focusNode,
autofocus: autofocus,
onLongPress: onLongPress,
onSecondaryTap: onSecondaryTap,
badge: badge,
@@ -70,6 +74,9 @@ class TrackSelectionHelper {
);
}
/// [autofocus] is for sheets whose rows are not built until the sheet has
/// grown: the host's initial-focus pass runs before that, so the intended
/// row claims focus itself the moment it exists.
static Widget _buildSelectableTile({
required BuildContext context,
required String label,
@@ -78,6 +85,7 @@ class TrackSelectionHelper {
String? secondaryLabel,
Key? key,
FocusNode? focusNode,
bool autofocus = false,
VoidCallback? onLongPress,
VoidCallback? onSecondaryTap,
Widget? badge,
@@ -93,6 +101,7 @@ class TrackSelectionHelper {
Widget tile = FocusableListTile(
key: key,
focusNode: focusNode,
autofocus: autofocus,
selected: isSelected,
title: Text(
label,
+149 -2
View File
@@ -18,6 +18,9 @@ import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_rating.dart';
import 'package:plezy/media/media_version.dart';
import 'package:plezy/media/media_part.dart';
import 'package:plezy/media/media_stream.dart';
import 'package:plezy/widgets/playback_track_chooser_sheet.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/server_capabilities.dart';
import 'package:plezy/providers/download_provider.dart';
@@ -371,8 +374,10 @@ void main() {
expect(find.text('Movie'), findsNothing);
expect(find.text('2017'), findsOneWidget);
// Stream quality describes the file, not the title: off the hero line,
// reachable in the details sheet (#2217).
expect(find.text('1080p'), findsNothing);
// on the action row's playback status instead (#2217).
final information = find.byKey(const ValueKey('tv_detail_information_semantics'));
expect(find.descendant(of: information, matching: find.text('1080p')), findsNothing);
expect(find.descendant(of: find.byType(FocusableActionBar), matching: find.text('1080p')), findsOneWidget);
// Desktop chip order: year, certification, runtime.
final fieldXs = [
@@ -1130,6 +1135,148 @@ void main() {
semantics.dispose();
});
testWidgets('TV detail action row previews the tracks Play will use and lets the viewer change them', (tester) async {
await SettingsService.getInstance();
tester.view.physicalSize = const Size(1920, 1080);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const streams = [
MediaStream(id: '1', kind: MediaStreamKind.video, index: 0, codec: 'hevc'),
MediaStream(
id: '101',
kind: MediaStreamKind.audio,
index: 1,
codec: 'truehd',
languageCode: 'eng',
channels: 8,
selected: true,
),
MediaStream(id: '102', kind: MediaStreamKind.audio, index: 2, codec: 'aac', languageCode: 'jpn', channels: 2),
MediaStream(id: '201', kind: MediaStreamKind.subtitle, index: 3, codec: 'srt', languageCode: 'eng'),
];
const version = MediaVersion(
id: 'v1',
videoResolution: '1080',
videoCodec: 'hevc',
parts: [MediaPart(id: 'p1', streams: streams)],
);
final show = testMediaItem(
id: 'show_1',
backend: MediaBackend.plex,
kind: MediaKind.show,
title: 'The Show',
serverId: 'server_1',
serverName: 'Server',
);
final season = testMediaItem(
id: 'season_1',
backend: MediaBackend.plex,
kind: MediaKind.season,
title: 'Season 1',
index: 1,
parentId: show.id,
serverId: show.serverId,
serverName: show.serverName,
);
final episode = testMediaItem(
id: 'episode_1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Pilot',
index: 1,
parentId: season.id,
parentIndex: season.index,
grandparentId: show.id,
grandparentTitle: show.title,
serverId: show.serverId,
serverName: show.serverName,
mediaVersions: const [version],
);
final client = _FakeMediaServerClient(
show: show,
childrenByParent: {
show.id: [season],
season.id: [episode],
},
);
final provider = testMultiServer(clients: [client]).provider;
await tester.pumpWidget(
TranslationProvider(
child: ChangeNotifierProvider<MultiServerProvider>.value(
value: provider,
child: MaterialApp(
theme: monoTheme(dark: true),
home: withProfileNavigationScope(
child: SizedBox(width: 1920, height: 1080, child: MediaDetailScreen(metadata: show)),
),
),
),
),
);
await tester.pump();
await tester.pump();
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
// The row's trailing status is the player's own ladder run over the
// server rows: Plex-selected English audio, subtitles off (no row selected).
final status = find.byWidgetPredicate(
(widget) => widget is Semantics && (widget.properties.label ?? '').startsWith('Audio & Subtitles:'),
);
expect(status, findsOneWidget);
String statusLabel() => tester.widget<Semantics>(status).properties.label!;
expect(statusLabel(), 'Audio & Subtitles: 1080p, HEVC, English · TrueHD · 7.1, Off');
expect(
find.descendant(of: find.byType(FocusableActionBar), matching: find.text('English · TrueHD · 7.1')),
findsOneWidget,
);
// It is the last stop on the action row; Select opens the chooser.
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
for (var i = 0; i < 8; i++) {
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
}
expect(FocusManager.instance.primaryFocus?.debugLabel, 'detail_playback_tracks');
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
final sheet = find.byType(PlaybackTrackChooserSheet);
expect(sheet, findsOneWidget);
// Focus opens on the row the ladder chose (Off: no subtitle row is
// selected), not on whatever row happens to be first.
final focusedTile = find.ancestor(
of: find.descendant(of: sheet, matching: find.text('Off')),
matching: find.byType(ListTile),
);
expect(tester.widget<ListTile>(focusedTile).focusNode?.hasFocus, isTrue);
expect(find.descendant(of: sheet, matching: find.text('Japanese')), findsOneWidget);
expect(find.descendant(of: sheet, matching: find.text('Off')), findsOneWidget);
await tester.tap(find.descendant(of: sheet, matching: find.text('Japanese')));
await tester.pumpAndSettle();
// Picking does not close the sheet — the subtitle column is next.
expect(sheet, findsOneWidget);
// Both columns have an English row; the subtitle column is the later one.
await tester.tap(find.descendant(of: sheet, matching: find.text('English')).last);
await tester.pumpAndSettle();
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
expect(sheet, findsNothing);
// The status now reports the viewer's choice, resolved by the same ladder.
expect(statusLabel(), 'Audio & Subtitles: 1080p, HEVC, Japanese · AAC · Stereo, English · SRT');
});
testWidgets('TV detail episode activation bypasses the open-details preference', (tester) async {
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.episodeAction, EpisodeAction.details);
@@ -0,0 +1,259 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_part.dart';
import 'package:plezy/media/media_server_user_profile.dart';
import 'package:plezy/media/media_stream.dart';
import 'package:plezy/media/media_version.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/services/playback_track_preview.dart';
class _JapaneseAudioProfile implements MediaServerUserProfile {
@override
bool get autoSelectAudio => true;
@override
String? get defaultAudioLanguage => 'jpn';
@override
String? get defaultSubtitleLanguage => null;
@override
SubtitlePlaybackMode? get subtitleMode => null;
}
void main() {
setUp(() {
LocaleSettings.setLocaleSync(AppLocale.en);
});
const english = MediaStream(
id: '101',
kind: MediaStreamKind.audio,
index: 1,
codec: 'truehd',
language: 'English',
languageCode: 'eng',
channels: 8,
selected: true,
);
const japanese = MediaStream(
id: '102',
kind: MediaStreamKind.audio,
index: 2,
codec: 'aac',
language: 'Japanese',
languageCode: 'jpn',
channels: 2,
);
const englishForced = MediaStream(
id: '201',
kind: MediaStreamKind.subtitle,
index: 3,
codec: 'srt',
language: 'English',
languageCode: 'eng',
forced: true,
);
const englishFull = MediaStream(
id: '202',
kind: MediaStreamKind.subtitle,
index: 4,
codec: 'ass',
language: 'English',
languageCode: 'eng',
title: 'Full',
);
MediaItem plexEpisode(List<MediaStream> streams) => MediaItem.plex(
id: 'episode_1',
kind: MediaKind.episode,
title: 'Pilot',
mediaVersions: [
MediaVersion(
id: 'v1',
videoResolution: '1080',
videoCodec: 'hevc',
parts: [MediaPart(id: 'p1', streams: streams)],
),
],
);
test('follows the server-selected audio and keeps subtitles off when Plex selected none', () {
final preview = previewPlaybackTracks(plexEpisode([english, japanese, englishForced, englishFull]))!;
expect(preview.audio?.id, 101);
expect(preview.audio?.label.joined, 'English · TrueHD · 7.1');
expect(preview.subtitle, isNull);
expect(preview.source.subtitleTracks.map((row) => row.id), [201, 202]);
});
test('a Plex-selected subtitle row is the prediction', () {
const selectedFull = MediaStream(
id: '202',
kind: MediaStreamKind.subtitle,
index: 4,
codec: 'ass',
language: 'English',
languageCode: 'eng',
title: 'Full',
selected: true,
);
final preview = previewPlaybackTracks(plexEpisode([english, japanese, englishForced, selectedFull]))!;
expect(preview.subtitle?.id, 202);
});
test('the server pick outranks the container default, which outranks the first row', () {
const defaultJapanese = MediaStream(
id: '102',
kind: MediaStreamKind.audio,
index: 2,
codec: 'opus',
languageCode: 'jpn',
channels: 2,
isDefault: true,
);
const selectedEnglish = MediaStream(
id: '101',
kind: MediaStreamKind.audio,
index: 1,
codec: 'opus',
languageCode: 'eng',
channels: 6,
selected: true,
);
const plainEnglish = MediaStream(
id: '101',
kind: MediaStreamKind.audio,
index: 1,
codec: 'opus',
languageCode: 'eng',
channels: 6,
);
// Plex: the account's pick (English) wins although Japanese is the
// container default — what the player's server-selected tier does.
expect(previewPlaybackTracks(plexEpisode([selectedEnglish, defaultJapanese]))!.audio?.id, 101);
// No pick: the container default wins over row order.
expect(previewPlaybackTracks(plexEpisode([plainEnglish, defaultJapanese]))!.audio?.id, 102);
});
test('an explicit choice outranks the server selection and comes back as the picked rows', () {
final item = plexEpisode([english, japanese, englishForced, englishFull]);
final baseline = previewPlaybackTracks(item)!;
final chooseJapanese = PlaybackTrackChoice(
itemId: item.id,
audio: const AudioTrack(id: 'source:102', language: 'jpn', codec: 'aac', channels: 2),
subtitle: const SubtitleTrack(id: 'source:202', language: 'eng', codec: 'ass', title: 'Full'),
);
final preview = previewPlaybackTracks(item, choice: chooseJapanese)!;
expect(baseline.audio?.id, 101);
expect(preview.audio?.id, 102);
expect(preview.subtitle?.id, 202);
// The descriptors Play hands over point at exactly those rows.
expect(preview.audioTrack?.id, 'source:102');
expect(preview.subtitleTrack.id, 'source:202');
// Off is a choice too, and wins over a server-selected row.
final offPreview = previewPlaybackTracks(
item,
choice: PlaybackTrackChoice(itemId: item.id, subtitle: SubtitleTrack.off),
)!;
expect(offPreview.subtitle, isNull);
expect(offPreview.subtitleTrack, SubtitleTrack.off);
});
test('a choice made on another episode carries its semantics, forced-ness included', () {
// Same show, next episode: different stream ids, same track layout.
const nextEnglish = MediaStream(
id: '301',
kind: MediaStreamKind.audio,
index: 1,
codec: 'truehd',
languageCode: 'eng',
channels: 8,
selected: true,
);
const nextJapanese = MediaStream(
id: '302',
kind: MediaStreamKind.audio,
index: 2,
codec: 'aac',
languageCode: 'jpn',
channels: 2,
);
const nextForced = MediaStream(
id: '401',
kind: MediaStreamKind.subtitle,
index: 3,
codec: 'srt',
languageCode: 'eng',
forced: true,
);
const nextFull = MediaStream(
id: '402',
kind: MediaStreamKind.subtitle,
index: 4,
codec: 'ass',
languageCode: 'eng',
);
// What the chooser stored on episode 1: Japanese audio, the forced track.
final carried = PlaybackTrackChoice(
itemId: 'episode_1',
audio: const AudioTrack(id: 'source:102', language: 'jpn', codec: 'aac', channels: 2),
subtitle: const SubtitleTrack(id: 'source:201', language: 'eng', codec: 'srt', isForced: true),
);
final nextEpisode = MediaItem.plex(
id: 'episode_2',
kind: MediaKind.episode,
title: 'Next',
mediaVersions: const [
MediaVersion(
id: 'v2',
parts: [
MediaPart(id: 'p2', streams: [nextEnglish, nextJapanese, nextForced, nextFull]),
],
),
],
);
final preview = previewPlaybackTracks(nextEpisode, choice: carried)!;
expect(preview.audio?.id, 302);
// Not the first English row (402): the forced class is a hard requirement.
expect(preview.subtitle?.id, 401);
expect(preview.subtitleTrack.id, 'source:401');
});
test('profile language preference applies when the server selected nothing', () {
const unselectedEnglish = MediaStream(
id: '101',
kind: MediaStreamKind.audio,
index: 1,
codec: 'truehd',
languageCode: 'eng',
channels: 8,
);
final preview = previewPlaybackTracks(
plexEpisode([unselectedEnglish, japanese]),
profile: _JapaneseAudioProfile(),
)!;
expect(preview.audio?.id, 102);
});
test('a container summary without stream rows yields no preview', () {
// What a Plex listing carries when it only knows `Media.audioCodec`.
const synthetic = MediaStream(
id: '55:audio',
kind: MediaStreamKind.audio,
codec: 'aac',
channels: 2,
selected: true,
);
expect(previewPlaybackTracks(plexEpisode([synthetic])), isNull);
expect(previewPlaybackTracks(const MediaItem.plex(id: 'bare', kind: MediaKind.movie, title: 'Bare')), isNull);
});
}
@@ -142,6 +142,50 @@ void main() {
expect(find.text('6.4'), findsOneWidget);
});
testWidgets('an icon part sheds its detail before the whole part, by its own priority', (tester) async {
// The action-row track status: the long codec details go first
// (rightmost first), then the short picture labels, then the audio
// track; the subtitle decision stays. Widths at 1 em/char: 1080p 50,
// HEVC 40, audio 14+80 (+150 detail), subtitle 14+70 (+60 detail),
// 3 bullets × 50 → 628 px.
const parts = [
MetadataLineText('1080p', dropPriority: 2),
MetadataLineText('HEVC', dropPriority: 2),
MetadataLineIconText(Icons.volume_up, 'Japanese', detail: 'AAC · Stereo', dropPriority: 1, detailDropPriority: 3),
MetadataLineIconText(Icons.subtitles, 'English', detail: 'SRT', dropPriority: 0, detailDropPriority: 3),
];
await _pumpLine(tester, width: 700, parts: parts);
expect(find.text('Japanese · AAC · Stereo'), findsOneWidget);
expect(find.text('English · SRT'), findsOneWidget);
expect(find.text('HEVC'), findsOneWidget);
await _pumpLine(tester, width: 600, parts: parts);
expect(find.text('English'), findsOneWidget);
expect(find.text('Japanese · AAC · Stereo'), findsOneWidget);
expect(find.text('HEVC'), findsOneWidget);
await _pumpLine(tester, width: 450, parts: parts);
expect(find.text('Japanese'), findsOneWidget);
expect(find.text('English'), findsOneWidget);
expect(find.text('1080p'), findsOneWidget);
expect(find.text('HEVC'), findsOneWidget);
await _pumpLine(tester, width: 350, parts: parts);
expect(find.text('HEVC'), findsNothing);
expect(find.text('1080p'), findsOneWidget);
await _pumpLine(tester, width: 250, parts: parts);
expect(find.text('1080p'), findsNothing);
expect(find.text('Japanese'), findsOneWidget);
expect(find.text('English'), findsOneWidget);
await _pumpLine(tester, width: 150, parts: parts);
expect(find.text('Japanese'), findsNothing);
expect(find.text('English'), findsOneWidget);
expect(find.byType(AppIcon), findsOneWidget);
});
testWidgets('announces only the badges it kept', (tester) async {
final semantics = tester.ensureSemantics();
await _pumpLine(tester, width: 560);