diff --git a/lib/media/media_stream.dart b/lib/media/media_stream.dart index a8839ba51..63bb7ed29 100644 --- a/lib/media/media_stream.dart +++ b/lib/media/media_stream.dart @@ -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, diff --git a/lib/screens/media_detail/action_buttons.dart b/lib/screens/media_detail/action_buttons.dart index a2683957c..51223ab2f 100644 --- a/lib/screens/media_detail/action_buttons.dart +++ b/lib/screens/media_detail/action_buttons.dart @@ -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 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)]); }, ); } diff --git a/lib/screens/media_detail/playback_tracks_action.dart b/lib/screens/media_detail/playback_tracks_action.dart new file mode 100644 index 000000000..dd30d4d49 --- /dev/null +++ b/lib/screens/media_detail/playback_tracks_action.dart @@ -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()?.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 _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 = [ + // 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 _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 _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( + 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), + ), + ); + } +} diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 4608ec378..9455f6af5 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -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 final ValueNotifier _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 _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 _scrollController.dispose(); _scrollOffset.dispose(); _tvDetailFocusedEpisode.dispose(); + _playbackProbeTimer?.cancel(); _extrasScrollController.dispose(); _extrasFocusNode.removeListener(_handleExtrasFocusChange); _extrasFocusNode.dispose(); @@ -3793,7 +3808,7 @@ class _MediaDetailScreenState extends State 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 Future _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; } diff --git a/lib/services/jellyfin_mappers.dart b/lib/services/jellyfin_mappers.dart index cddee9d66..9bceac0ea 100644 --- a/lib/services/jellyfin_mappers.dart +++ b/lib/services/jellyfin_mappers.dart @@ -574,6 +574,7 @@ class JellyfinMappers { defaultAudioStreamIndex: defaultAudioStreamIndex, defaultSubtitleStreamIndex: defaultSubtitleStreamIndex, ), + isDefault: f.isDefault, channels: f.channels, frameRate: f.frameRate, hdr: isVideo && jellyfinVideoStreamIsHdr(source ?? const {}, s), diff --git a/lib/services/playback_track_preview.dart b/lib/services/playback_track_preview.dart new file mode 100644 index 000000000..8809f5d52 --- /dev/null +++ b/lib/services/playback_track_preview.dart @@ -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 = []; + final subtitleRows = []; + final audioTracks = []; + final subtitleTracks = []; + 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 audioTracks; + final List 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; +} diff --git a/lib/services/plex_mappers.dart b/lib/services/plex_mappers.dart index 479a92509..5c71b6448 100644 --- a/lib/services/plex_mappers.dart +++ b/lib/services/plex_mappers.dart @@ -126,7 +126,8 @@ List _mediaStreamsFromPlexPart(Object? raw, {Map? 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, diff --git a/lib/utils/media_quality_labels.dart b/lib/utils/media_quality_labels.dart index 94acdf908..810981721 100644 --- a/lib/utils/media_quality_labels.dart +++ b/lib/utils/media_quality_labels.dart @@ -27,6 +27,29 @@ List 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 buildMediaVideoLabels(MediaItem item, {int versionIndex = 0}) { + final version = _selectedVersion(item.mediaVersions, versionIndex); + if (version == null) return const []; + + final labels = []; + 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) { diff --git a/lib/widgets/fitted_metadata_line.dart b/lib/widgets/fitted_metadata_line.dart index c26a3404e..7b6fbe285 100644 --- a/lib/widgets/fitted_metadata_line.dart +++ b/lib/widgets/fitted_metadata_line.dart @@ -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 = >[ for (final part in parts) switch (part) { MetadataLineText(:final text) => [textWidth(text)], + MetadataLineIconText(:final text, :final detail) => [ + iconSize + iconTextGap + textWidth(text), + if (detail != null) textWidth('${MetadataLineIconText.detailSeparator}$detail'), + ], MetadataLineRatings(:final ratings) => [ 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, diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index 0b93a2c19..9b5636918 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -490,12 +490,15 @@ class _OverlaySheetHostState extends State 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(); } diff --git a/lib/widgets/playback_track_chooser_sheet.dart b/lib/widgets/playback_track_chooser_sheet.dart new file mode 100644 index 000000000..81e175c48 --- /dev/null +++ b/lib/widgets/playback_track_chooser_sheet.dart @@ -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 onChanged; + + const PlaybackTrackChooserSheet({ + super.key, + required this.source, + required this.effectiveAudio, + required this.effectiveSubtitle, + required this.choice, + required this.onChanged, + }); + + @override + State createState() => _PlaybackTrackChooserSheetState(); +} + +class _PlaybackTrackChooserSheetState extends State { + 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); + } +} diff --git a/lib/widgets/video_controls/helpers/track_selection_helper.dart b/lib/widgets/video_controls/helpers/track_selection_helper.dart index 67ad23122..8fa59605e 100644 --- a/lib/widgets/video_controls/helpers/track_selection_helper.dart +++ b/lib/widgets/video_controls/helpers/track_selection_helper.dart @@ -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, diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index 45f4be2f0..ece794c02 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -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.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(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(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(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); diff --git a/test/services/playback_track_preview_test.dart b/test/services/playback_track_preview_test.dart new file mode 100644 index 000000000..734e39e3c --- /dev/null +++ b/test/services/playback_track_preview_test.dart @@ -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 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); + }); +} diff --git a/test/widgets/fitted_metadata_line_test.dart b/test/widgets/fitted_metadata_line_test.dart index fc18fca4c..edfcbf904 100644 --- a/test/widgets/fitted_metadata_line_test.dart +++ b/test/widgets/fitted_metadata_line_test.dart @@ -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);