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