fix(app): restore playback and state lifecycle contracts

This commit is contained in:
edde746
2026-07-25 16:16:04 +02:00
parent 73f4a3e055
commit 4af77f4696
58 changed files with 2448 additions and 363 deletions
+10 -2
View File
@@ -83,8 +83,9 @@ class AppDatabase extends _$AppDatabase {
static final Object _durabilityZoneKey = Object();
static final SerialFutureQueue _tvosRecoveryQueue = SerialFutureQueue();
/// Resolves and opens the production database, then reconciles tvOS
/// recovery before returning it to startup consumers.
/// Resolves and opens the production database, eagerly completing Drift
/// setup and migrations on non-tvOS before returning. tvOS recovery retains
/// ownership of database access ordering.
static Future<AppDatabaseBootstrap> open({
bool isTvos = const bool.fromEnvironment('TVOS_BUILD'),
File? databaseFile,
@@ -110,6 +111,13 @@ class AppDatabase extends _$AppDatabase {
final store = recoveryStore ?? TvosDatabaseRecoveryStore(prefs, isTvos: isTvos);
final database = AppDatabase._((executorFactory ?? _createNativeDatabase)(file), recoveryStore: store);
try {
if (!isTvos) {
// Drift executors open lazily. Force the connection through setup and
// migrations while failures are still covered by this close/rethrow
// boundary and the caller's startup download-recovery decision.
await database.customSelect('SELECT 1').get();
// It deliberately does not claim capacity for a later write.
}
final outcome = await _tvosRecoveryQueue.run(
() => store.reconcile(
databaseExisted: databaseExisted,
+10 -1
View File
@@ -145,6 +145,14 @@ class FocusableWrapper extends StatefulWidget {
/// Optional current value announced after [semanticLabel].
final String? semanticValue;
/// Whether this wrapper replaces semantics contributed by [child].
///
/// The default (`true`) replacement mode produces one operable control node for
/// labeled wrappers. Set this to `false` only to supplement non-interactive
/// child content; a child that already owns a role or actions would conflict
/// with this wrapper's button and activation semantics.
final bool excludeChildSemantics;
/// Optional checked state for toggle-style controls.
final bool? checked;
@@ -211,6 +219,7 @@ class FocusableWrapper extends StatefulWidget {
this.useComfortableZone = false,
this.semanticLabel,
this.semanticValue,
this.excludeChildSemantics = true,
this.checked,
this.canRequestFocus = true,
this.onKeyEvent,
@@ -594,7 +603,7 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
checked: widget.checked,
onTap: widget.onSelect,
onLongPress: widget.onLongPress,
excludeSemantics: true,
excludeSemantics: widget.excludeChildSemantics,
child: result,
);
}
+15 -3
View File
@@ -159,12 +159,17 @@ abstract class MediaServerClient {
/// children).
Future<void> refreshLibraryMetadata(String libraryId);
/// Fetch a single item by its backend-opaque id. Returns `null` when the
/// item no longer exists or the user can't see it.
/// Fetch a single item by its backend-opaque id. An online HTTP 404 returns
/// `null`; every other HTTP status remains an error. Implementations may use
/// cached metadata while explicitly offline or after a classified transient
/// transport failure, but must not turn other online failures into stale
/// success.
Future<MediaItem?> fetchItem(String id);
/// Fetch a single item *and* its on-deck episode (the next unwatched /
/// in-progress episode) in one round-trip when the backend supports it.
/// The item follows [fetchItem]'s error contract: an online HTTP 404 returns
/// both nullable fields as `null`, while every other HTTP status throws.
/// Plex bundles both via `/library/metadata/{id}?includeOnDeck=1`;
/// Jellyfin has no equivalent endpoint and returns `onDeckEpisode: null`,
/// leaving callers to fetch on-deck separately if they need it.
@@ -719,7 +724,12 @@ abstract interface class SeasonEpisodePagingClient {
/// shared base class isn't an option, but a `mixin on MediaServerClient` is.
mixin MediaServerCacheMixin implements MediaServerClient {
/// Fetch with cache fallback: offline → cached only; online → try network,
/// cache the result, fall back to cached on any error.
/// cache the result, then fall back to cached data on an accepted error.
///
/// When [shouldFallback] is omitted, every error remains eligible for cache
/// fallback. A supplied selector narrows that policy; rejected errors are
/// rethrown unchanged before the fallback cache is read. Response-parser
/// failures can therefore be propagated instead of hidden by stale data.
///
/// Returns `null` when offline mode is on and no cached row exists, or
/// when both network and cache come up empty.
@@ -728,6 +738,7 @@ mixin MediaServerCacheMixin implements MediaServerClient {
required Future<MediaServerResponse> Function() networkCall,
required T? Function(dynamic cachedData) parseCache,
required T? Function(MediaServerResponse response) parseResponse,
bool Function(Object error)? shouldFallback,
bool cacheResponse = true,
}) async {
final cacheScope = ServerId(cacheServerId);
@@ -744,6 +755,7 @@ mixin MediaServerCacheMixin implements MediaServerClient {
}
return parseResponse(response);
} catch (e) {
if (shouldFallback != null && !shouldFallback(e)) rethrow;
appLogger.w('Network request failed for $cacheKey, trying cache', error: e);
final cached = await cache.get(cacheScope, cacheKey);
if (cached != null) return parseCache(cached);
+8 -4
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:provider/provider.dart';
import '../connection/connection_registry.dart';
@@ -58,7 +59,8 @@ CatalogSourcesProvider _createCatalogSourcesProvider(BuildContext context) {
/// on the root navigator so they survive this subtree being replaced.
class ProfileSessionScreen extends StatefulWidget {
const ProfileSessionScreen({super.key, this.isOfflineMode = false, this.initialPromptHandled = false})
: profileShellBuilder = null;
: profileShellBuilder = null,
trackerHttpClientFactory = null;
@visibleForTesting
const ProfileSessionScreen.forTesting({
@@ -66,11 +68,13 @@ class ProfileSessionScreen extends StatefulWidget {
this.isOfflineMode = false,
this.initialPromptHandled = false,
required this.profileShellBuilder,
});
required http.Client Function() httpClientFactory,
}) : trackerHttpClientFactory = httpClientFactory;
final bool isOfflineMode;
final bool initialPromptHandled;
final WidgetBuilder? profileShellBuilder;
final http.Client Function()? trackerHttpClientFactory;
@override
State<ProfileSessionScreen> createState() => _ProfileSessionScreenState();
@@ -146,7 +150,7 @@ class _ProfileSessionScreenState extends State<ProfileSessionScreen> {
),
ChangeNotifierProvider(
create: (context) {
final provider = TraktAccountProvider();
final provider = TraktAccountProvider(httpClientFactory: widget.trackerHttpClientFactory);
unawaited(
provider.onActiveProfileChanged(activeId).catchError((Object e, StackTrace s) {
appLogger.w('Trakt profile hydrate failed', error: e, stackTrace: s);
@@ -157,7 +161,7 @@ class _ProfileSessionScreenState extends State<ProfileSessionScreen> {
),
ChangeNotifierProvider(
create: (context) {
final provider = TrackersProvider();
final provider = TrackersProvider(httpClientFactory: widget.trackerHttpClientFactory);
unawaited(
provider.onActiveProfileChanged(activeId).catchError((Object e, StackTrace s) {
appLogger.w('Trackers profile hydrate failed', error: e, stackTrace: s);
+23 -6
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../models/trackers/device_code.dart';
import '../services/trackers/anilist/anilist_auth_service.dart';
@@ -34,17 +35,33 @@ typedef TrackerSessionConnectPipeline =
/// Plex profile. Single rebind seam: [onActiveProfileChanged] loads all three
/// sessions from their stores and pushes them to their trackers.
class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
TrackersProvider() : this._(runConnectPipeline<TrackerSession>);
/// [httpClientFactory] must return a fresh client for each eager auth owner.
/// Every returned client is closed when this provider is disposed.
TrackersProvider({http.Client Function()? httpClientFactory})
: this._(runConnectPipeline<TrackerSession>, httpClientFactory);
@visibleForTesting
TrackersProvider.forTesting({required TrackerSessionConnectPipeline connectPipeline}) : this._(connectPipeline);
TrackersProvider.forTesting({
required TrackerSessionConnectPipeline connectPipeline,
http.Client Function()? httpClientFactory,
}) : this._(connectPipeline, httpClientFactory);
TrackersProvider._(this._connectPipeline);
TrackersProvider._(this._connectPipeline, http.Client Function()? httpClientFactory)
: _malAuth = httpClientFactory == null
? MalAuthService()
: MalAuthService(
proxy: OAuthProxyClient(httpClient: httpClientFactory()),
httpClient: httpClientFactory(),
),
_anilistAuth = httpClientFactory == null
? AnilistAuthService()
: AnilistAuthService(proxy: OAuthProxyClient(httpClient: httpClientFactory())),
_simklAuth = httpClientFactory == null ? SimklAuthService() : SimklAuthService(httpClient: httpClientFactory());
final TrackerSessionConnectPipeline _connectPipeline;
final MalAuthService _malAuth = MalAuthService();
final AnilistAuthService _anilistAuth = AnilistAuthService();
final SimklAuthService _simklAuth = SimklAuthService();
final MalAuthService _malAuth;
final AnilistAuthService _anilistAuth;
final SimklAuthService _simklAuth;
final TrackerAccountStore _malStore = trackerAccountStore(TrackerService.mal);
final TrackerAccountStore _anilistStore = trackerAccountStore(TrackerService.anilist);
final TrackerAccountStore _simklStore = trackerAccountStore(TrackerService.simkl);
+7 -1
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../mixins/disposable_change_notifier_mixin.dart';
import '../models/trackers/device_code.dart';
@@ -19,7 +20,12 @@ import '../services/trakt/trakt_sync_service.dart';
/// Single rebind seam: `onActiveProfileChanged` loads the new profile's
/// session and pushes it to both `TraktScrobbleService` and `TraktSyncService`.
class TraktAccountProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
final TraktAuthService _auth = TraktAuthService();
/// Each client returned by [httpClientFactory] is owned by this provider and
/// closed when the provider is disposed.
TraktAccountProvider({http.Client Function()? httpClientFactory})
: _auth = httpClientFactory == null ? TraktAuthService() : TraktAuthService(httpClient: httpClientFactory());
final TraktAuthService _auth;
final TrackerAccountStore _store = trackerAccountStore(TrackerService.trakt);
TrackerSession? _session;
+1
View File
@@ -188,6 +188,7 @@ class ExploreScreenState extends State<ExploreScreen>
menuKey: _sourceMenuKey,
tooltip: t.explore.selectSource,
semanticLabel: t.explore.selectSource,
semanticValue: active.displayName,
anchorAlignment: anchorAlignment,
onSelected: (id) => unawaited(sources.setActiveSource(id)),
itemBuilder: (context) => _sourceMenuEntries(sources, active),
@@ -131,6 +131,7 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
onNavigateRight: canEdit && hasShortcut ? _clearFocusNode.requestFocus : null,
onNavigateDown: (canSave ? _saveFocusNode : _cancelFocusNode).requestFocus,
semanticLabel: recordLabel,
semanticValue: _recordedHotKey == null ? null : formatHotKeyDisplay(_recordedHotKey!),
descendantsAreFocusable: false,
useBackgroundFocus: true,
child: GestureDetector(
@@ -1,4 +1,5 @@
import 'dart:math' as math;
import '../../providers/playback_state_provider.dart';
/// Position must be within this many ms of the best-known duration for a
/// player EOF signal to count as the real end of the media.
@@ -13,12 +14,17 @@ const int spuriousEofToleranceMs = 10000;
/// How a player EOF signal should be interpreted.
enum EofSignalClass { genuine, spurious, unknown }
/// End-of-media action after considering adjacent-episode discovery.
/// End-of-media action after considering queue adjacency discovery.
enum CompletionNavigationAction { presentNext, retryAdjacent, exit }
CompletionNavigationAction completionNavigationAction({required bool hasNext, required bool adjacentLoadFailed}) {
CompletionNavigationAction completionNavigationAction({
required bool hasNext,
required QueueNavigationStatus adjacentStatus,
}) {
if (hasNext) return CompletionNavigationAction.presentNext;
if (adjacentLoadFailed) return CompletionNavigationAction.retryAdjacent;
if (adjacentStatus == QueueNavigationStatus.failed) {
return CompletionNavigationAction.retryAdjacent;
}
return CompletionNavigationAction.exit;
}
+6 -10
View File
@@ -64,8 +64,11 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
return const PlaybackSourceSubtitleChoice.off();
}
List<PlaybackSubtitleSidecar> _sourceSubtitleSidecarsForControls() =>
_playbackSession?.context.result.subtitleSidecars ?? const <PlaybackSubtitleSidecar>[];
List<MediaSubtitleTrack> _sourceSubtitleTracksForControls() {
final sidecarSourceIds = _sourceSubtitleSidecarIdsForControls();
final sidecarSourceIds = {for (final sidecar in _sourceSubtitleSidecarsForControls()) ?sidecar.sourceStreamId};
return selectableSourceSubtitleTracks(
_currentMediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[],
isTranscoding: _isTranscoding,
@@ -74,13 +77,6 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
);
}
Set<int> _sourceSubtitleSidecarIdsForControls() {
return {
for (final sidecar in _playbackSession?.context.result.subtitleSidecars ?? const <PlaybackSubtitleSidecar>[])
?sidecar.sourceStreamId,
};
}
Widget _buildLoadingSpinner() {
return const Scaffold(
backgroundColor: Colors.black,
@@ -273,8 +269,8 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
}
final sourceAudioTracks = _currentMediaInfo?.audioTracks ?? const <MediaAudioTrack>[];
final sourceSubtitleSidecars = _sourceSubtitleSidecarsForControls();
final sourceSubtitleTracks = _sourceSubtitleTracksForControls();
final sourceSubtitleSidecarIds = _sourceSubtitleSidecarIdsForControls();
return Video(
player: player!,
@@ -296,7 +292,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
sourceSubtitleTracks: sourceSubtitleTracks,
selectedSubtitleChoice: _selectedSourceSubtitleChoiceForControls(sourceSubtitleTracks),
selectedSecondarySubtitleStreamId: _playbackSession?.subtitleSelection.secondarySourceStreamId,
sourceSubtitleSidecarIds: sourceSubtitleSidecarIds,
sourceSubtitleSidecars: sourceSubtitleSidecars,
sourcePartId: _currentMediaInfo?.partId,
onPlaybackSourceChanged: _switchPlaybackSource,
onTogglePIPMode: _togglePIPMode,
@@ -42,7 +42,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
var navigationAction = completionNavigationAction(
hasNext: _nextEpisode != null,
adjacentLoadFailed: _currentMetadata.isEpisode && _nextEpisodeStatus == QueueNavigationStatus.failed,
adjacentStatus: _nextEpisodeStatus,
);
if (navigationAction == CompletionNavigationAction.retryAdjacent) {
_isResolvingCompletionAdjacency = true;
@@ -52,10 +52,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
_isResolvingCompletionAdjacency = false;
}
if (!mounted) return;
navigationAction = completionNavigationAction(
hasNext: _nextEpisode != null,
adjacentLoadFailed: _currentMetadata.isEpisode && _nextEpisodeStatus == QueueNavigationStatus.failed,
);
navigationAction = completionNavigationAction(hasNext: _nextEpisode != null, adjacentStatus: _nextEpisodeStatus);
if (navigationAction == CompletionNavigationAction.retryAdjacent) {
_completionLatch.latch();
showGlobalErrorSnackBar(t.messages.errorLoadingSeries);
+2
View File
@@ -342,6 +342,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
late MediaItem _currentMetadata;
MediaItem? _nextEpisode;
MediaItem? _previousEpisode;
// Retryable sentinel until the fire-and-forget initial adjacency load
// commits found, boundary, or unavailable.
QueueNavigationStatus _nextEpisodeStatus = QueueNavigationStatus.failed;
bool _isResolvingCompletionAdjacency = false;
bool _isLoadingNext = false;
+25 -21
View File
@@ -41,17 +41,18 @@ class AdjacentEpisodes {
enum _EpisodeQueueAvailability { active, unavailable, failed }
/// Manages episode navigation for TV show playback.
/// Manages queue-based adjacency for video playback.
///
/// Handles:
/// - Loading next/previous episodes from play queues
/// - Loading next/previous items from verified play queues
/// - Building episode-series fallback queues when needed
/// - Navigating between episodes while preserving track selections
/// - Supporting both sequential and shuffle playback modes
///
/// Plex normally enters with its server-side `/playQueues` queue. If that
/// setup failed, the same client-side full-series path used by Jellyfin is
/// used as a fallback. Both paths publish into [PlaybackStateProvider] so
/// the player reads previous/next from one source.
/// setup failed for an episode, the same client-side full-series path used by
/// Jellyfin is used as a fallback. Playlist and collection queues may contain
/// movies; once membership is verified, they use the same adjacency path.
class EpisodeNavigationService {
/// Cached client-side episode lists, keyed by `seriesId`. Populated for
/// Jellyfin and when Plex's server-side queue is unavailable. Fetched once
@@ -69,12 +70,11 @@ class EpisodeNavigationService {
/// holding ~510 MB of metadata when the user wanders the library.
static const int _seriesCacheCapacity = 5;
/// Load the next and previous episodes for the current episode
/// Load the next and previous items for the current video.
///
/// Returns null for episodes if:
/// - Not applicable (e.g., movie content)
/// - Next episode doesn't exist (end of season/series)
/// - Previous episode doesn't exist (first episode)
/// Series queue construction is episode-only. Movies can still resolve
/// adjacency when they are proven members of the active playlist or
/// collection queue.
///
/// [playedPartId] is the backend part id actually being played, when
/// known — it lets the queue skip sibling entries of a Plex
@@ -131,19 +131,19 @@ class EpisodeNavigationService {
/// Ensure [PlaybackStateProvider] holds a queue covering the current item.
/// A queue the item already belongs to (launcher-seeded shuffle, playlist,
/// collection, or an earlier series build) is preserved. Otherwise the
/// backend's full series episode list is published, anchored at the current
/// episode. For Plex this is the fallback when `/playQueues` was
/// unavailable; for Jellyfin it is the normal queue path.
/// collection, or an earlier series build) is preserved for both movies and
/// episodes. Otherwise an episode's full backend series list is published,
/// anchored at the current episode. For Plex this series build is the
/// fallback when `/playQueues` was unavailable; for Jellyfin it is the
/// normal episode path.
Future<_EpisodeQueueAvailability> _ensureLocalEpisodeQueue(
MultiServerManager serverManager,
PlaybackStateProvider playbackState,
MediaItem metadata,
) async {
if (metadata.serverId == null || !metadata.isEpisode || metadata.grandparentId == null) {
return _EpisodeQueueAvailability.unavailable;
}
final seriesId = metadata.grandparentId!;
// Queue membership and current-cursor identity are media-kind agnostic.
// Prove them before applying the episode-only series-build prerequisites
// so playlist and collection movies retain their launcher-seeded queue.
// Preserve any queue this item already belongs to — a launcher-seeded
// shuffled show queue (contextKey == seriesId), a playlist/collection
// queue, or a series queue this method built earlier. setCurrentItem
@@ -154,14 +154,18 @@ class EpisodeNavigationService {
playbackState.setCurrentItem(metadata);
return _EpisodeQueueAvailability.active;
}
// Same-episode reload with a fresh object: a source/quality switch hands
// Same-item reload with a fresh object: a source/quality switch hands
// _reloadMediaInPlace a copyWith clone of the playing item, and MediaItem
// compares by identity, so the membership gate above misses. The cursor
// already points at this episode — the queue (and any shuffled order)
// must survive.
// already points at this item — the queue (and any shuffled order) must
// survive.
if (playbackState.isQueueActive && playbackState.currentQueueItem?.globalKey == metadata.globalKey) {
return _EpisodeQueueAvailability.active;
}
if (metadata.serverId == null || !metadata.isEpisode || metadata.grandparentId == null) {
return playbackState.isQueueActive ? _EpisodeQueueAvailability.failed : _EpisodeQueueAvailability.unavailable;
}
final seriesId = metadata.grandparentId!;
// The playing item isn't in the active queue. Still don't replace a
// playlist/collection queue with a series queue: the launcher (e.g.
// [JellyfinSequentialLauncher]) sets contextKey to the playlist or
@@ -144,13 +144,17 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
/// Identifies the queue session that asynchronous enqueue work belongs to.
int _queueSessionRevision = 0;
/// Gapless arm work is serialized, and each requested recomputation gets
/// a revision. This prevents an older resolve/setNext continuation from
/// landing after a queue edit, repeat change, or sleep-timer change.
/// Gapless arm work is serialized into one latest-request slot. The
/// generation stales continuations while the pending flag distinguishes
/// an explicit recomputation request from cancellation-only invalidation.
int _armRequestGeneration = 0;
int _processedArmRequestGeneration = 0;
bool _armRequestPending = false;
Future<void>? _armDrain;
/// The generation whose replacement [Player.open] has not committed yet.
/// Requests remain pending while an open owns the native playlist.
int? _openingGeneration;
int _consecutiveFailures = 0;
bool _resumeAfterInterruption = false;
bool _disposed = false;
@@ -309,6 +313,9 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
Future<void> _openCurrent(int generation, {bool play = true}) async {
final track = _queue.current;
if (track == null) return;
_openingGeneration = generation;
Player? committedPlayer;
try {
_currentTrack = track;
_currentSource = null;
_armed = null;
@@ -362,9 +369,24 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
}
if (generation != _generation || _player != player) return;
committedPlayer = player;
_setStatus(play ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused);
_bindTrackServices(track, source);
} finally {
// A stale open must never release a newer open's ownership. Only a
// committed current open schedules its successor; an unsuccessful
// current open cancels requests collected while it was unresolved.
if (_openingGeneration == generation) {
_openingGeneration = null;
if (committedPlayer != null && !_disposed && generation == _generation && _player == committedPlayer) {
_requestArmNext();
} else if (generation == _generation) {
_invalidateArmRequests();
} else {
_ensureArmDrain();
}
}
}
}
/// Manual advance: finalize the current tracker at its current position and
@@ -428,28 +450,34 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
}
bool _isCurrentArmRequest(Player player, int generation, int armRequest) {
return !_disposed && generation == _generation && armRequest == _armRequestGeneration && _player == player;
return !_disposed &&
_openingGeneration == null &&
generation == _generation &&
armRequest == _armRequestGeneration &&
_player == player;
}
void _requestArmNext() {
if (_disposed) return;
_armRequestGeneration++;
_armRequestPending = true;
_ensureArmDrain();
}
void _invalidateArmRequests() {
_armRequestGeneration++;
_armRequestPending = false;
}
void _ensureArmDrain() {
if (_disposed || _armDrain != null) return;
if (_disposed || !_armRequestPending || _openingGeneration != null || _armDrain != null) return;
final drain = _drainArmRequests();
_armDrain = drain;
unawaited(
drain.whenComplete(() {
if (_armDrain != drain) return;
_armDrain = null;
if (!_disposed && _processedArmRequestGeneration != _armRequestGeneration) {
if (!_disposed && _armRequestPending && _openingGeneration == null) {
_ensureArmDrain();
}
}),
@@ -457,12 +485,11 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
}
Future<void> _drainArmRequests() async {
while (!_disposed) {
while (!_disposed && _armRequestPending && _openingGeneration == null) {
final armRequest = _armRequestGeneration;
final generation = _generation;
_armRequestPending = false;
await _applyArmNext(generation, armRequest);
_processedArmRequestGeneration = armRequest;
if (armRequest == _armRequestGeneration) return;
}
}
+26 -4
View File
@@ -239,6 +239,8 @@ bool? _parsePlexTranscoderVideoCapability(Object? value) {
};
}
bool _shouldFallbackPlexItemLookup(Object error) => error is MediaServerHttpException && error.isTransient;
class _PlexMediaProviderState {
const _PlexMediaProviderState({
required this.libraries,
@@ -1071,7 +1073,10 @@ class PlexClient
/// Uses cache when offline or as fallback on network error
/// Note: OnDeck data is not relevant for offline mode
/// Always fetches with chapters/markers but caches at base endpoint
Future<Map<String, dynamic>> getMetadataWithImagesAndOnDeck(String ratingKey) async {
Future<Map<String, dynamic>> getMetadataWithImagesAndOnDeck(
String ratingKey, {
bool Function(Object error)? shouldFallback,
}) async {
// Cache key is always the base endpoint (no query params)
final cacheKey = '/library/metadata/$ratingKey';
@@ -1079,6 +1084,7 @@ class PlexClient
// because OnDeck is only available from network response, not cache
return await fetchWithCacheFallback<Map<String, dynamic>>(
cacheKey: cacheKey,
shouldFallback: shouldFallback,
networkCall: () => _http.get(
'/library/metadata/$ratingKey',
queryParameters: {
@@ -1136,12 +1142,16 @@ class PlexClient
/// Get metadata by rating key with images (includes clearLogo)
/// Uses cache when offline or as fallback on network error
/// Always fetches with chapters/markers but caches at base endpoint
Future<PlexMetadataDto?> _getMetadataWithImages(String ratingKey) async {
Future<PlexMetadataDto?> _getMetadataWithImages(
String ratingKey, {
bool Function(Object error)? shouldFallback,
}) async {
// Cache key is always the base endpoint (no query params)
final cacheKey = '/library/metadata/$ratingKey';
return fetchWithCacheFallback<PlexMetadataDto>(
cacheKey: cacheKey,
shouldFallback: shouldFallback,
networkCall: () => _http.get(
'/library/metadata/$ratingKey',
queryParameters: {'includeChapters': 1, 'includeMarkers': 1, 'checkFiles': 1, 'includeStreams': 1},
@@ -2857,8 +2867,13 @@ class PlexClient
@override
Future<MediaItem?> fetchItem(String id) async {
final metadata = await _getMetadataWithImages(id);
try {
final metadata = await _getMetadataWithImages(id, shouldFallback: _shouldFallbackPlexItemLookup);
return metadata == null ? null : PlexMappers.mediaItem(metadata);
} on MediaServerHttpException catch (error) {
if (error.statusCode == 404) return null;
rethrow;
}
}
@override
@@ -3621,13 +3636,20 @@ class PlexClient
/// Jellyfin has no analogous endpoint and returns onDeck=null there.
@override
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async {
final result = await getMetadataWithImagesAndOnDeck(id);
try {
final result = await getMetadataWithImagesAndOnDeck(id, shouldFallback: _shouldFallbackPlexItemLookup);
final itemDto = result['metadata'] as PlexMetadataDto?;
final onDeckDto = result['onDeckEpisode'] as PlexMetadataDto?;
return (
item: itemDto == null ? null : PlexMappers.mediaItem(itemDto),
onDeckEpisode: onDeckDto == null ? null : PlexMappers.mediaItem(onDeckDto),
);
} on MediaServerHttpException catch (error) {
if (error.statusCode == 404) {
return (item: null, onDeckEpisode: null);
}
rethrow;
}
}
@override
+9 -21
View File
@@ -259,30 +259,18 @@ class TrackManager {
final hasAnyTracks = tracks.audio.isNotEmpty || tracks.subtitle.isNotEmpty;
if (!hasAnyTracks) return false;
final info = mediaInfo;
if (info == null || info.subtitleTracks.isEmpty) return true;
if (tracks.subtitle.isEmpty) return false;
final nativeSubtitleTracks = tracks.subtitle
final realAudioTracks = tracks.audio
.where((track) => track.id != AudioTrack.auto.id && track.id != AudioTrack.off.id)
.toList(growable: false);
final realSubtitleTracks = tracks.subtitle
.where((track) => track.id != SubtitleTrack.auto.id && track.id != SubtitleTrack.off.id)
.toList(growable: false);
final preferred = preferredSubtitleTrack;
final preferredHasSemanticIdentity =
preferred != null &&
preferred.id != SubtitleTrack.off.id &&
(preferred.id.startsWith('source:') ||
preferred.uri != null ||
preferred.title != null ||
preferred.language != null);
if (preferredHasSemanticIdentity) {
final service = TrackSelectionService(metadata: metadata, plexMediaInfo: info);
return service.findBestSubtitleMatch(nativeSubtitleTracks, preferred) != null;
}
final service = TrackSelectionService(metadata: metadata, plexMediaInfo: mediaInfo);
final selectedAudioTrack = service.selectAudioTrack(realAudioTracks, preferredAudioTrack)?.track;
final serverSelectedTrack = info.subtitleTracks.where((track) => track.selected).firstOrNull;
if (serverSelectedTrack == null) return true;
return findMpvTrackForPlexSubtitle(serverSelectedTrack, nativeSubtitleTracks, allPlexTracks: info.subtitleTracks) !=
null;
// Selection owns the catalog-completeness decision. A null subtitle result
// is the only state in which a requested source track can still arrive.
return service.selectSubtitleTrack(realSubtitleTracks, preferredSubtitleTrack, selectedAudioTrack) != null;
}
/// Core track selection: delegates to [TrackSelectionService]. Returns
+160 -11
View File
@@ -78,6 +78,108 @@ int _scoreAudioMatch(AudioTrack mpvTrack, MediaAudioTrack plexTrack, {required b
return score;
}
enum _DirectEmbeddedSubtitleCatalog { incomplete, complete }
bool _isDirectEmbeddedPlexSubtitle(MediaSubtitleTrack track) => !track.isExternal;
bool _isDirectEmbeddedMpvSubtitle(SubtitleTrack track) =>
track.id != SubtitleTrack.auto.id && track.id != SubtitleTrack.off.id && !track.isExternal && !track.isContainer;
/// Classifies only ordinary direct-embedded rows. External/keyed source
/// subtitles and native container tracks have independent arrival semantics
/// and cannot prove that this catalog is complete.
_DirectEmbeddedSubtitleCatalog _classifyDirectEmbeddedSubtitleCatalog(
List<MediaSubtitleTrack> plexTracks,
List<SubtitleTrack> mpvTracks,
) {
var plexTrackCount = 0;
for (final track in plexTracks) {
if (_isDirectEmbeddedPlexSubtitle(track)) plexTrackCount++;
}
var mpvTrackCount = 0;
for (final track in mpvTracks) {
if (_isDirectEmbeddedMpvSubtitle(track)) mpvTrackCount++;
}
return plexTrackCount > 0 && plexTrackCount == mpvTrackCount
? _DirectEmbeddedSubtitleCatalog.complete
: _DirectEmbeddedSubtitleCatalog.incomplete;
}
bool _hasSubtitleFact(String? value) => value != null && value.trim().isNotEmpty;
bool _lowMetadataSubtitleFactsAreCompatible(SubtitleTrack mpvTrack, MediaSubtitleTrack plexTrack) {
final plexLanguage = plexTrack.languageCode;
if (_hasSubtitleFact(mpvTrack.language) &&
_hasSubtitleFact(plexLanguage) &&
!_languagesMatch(mpvTrack.language, plexLanguage)) {
return false;
}
if (_hasSubtitleFact(mpvTrack.codec) &&
_hasSubtitleFact(plexTrack.codec) &&
!_subtitleCodecsMatch(mpvTrack.codec, plexTrack.codec)) {
return false;
}
final plexHasTitle = _hasSubtitleFact(plexTrack.title) || _hasSubtitleFact(plexTrack.displayTitle);
if (_hasSubtitleFact(mpvTrack.title) &&
plexHasTitle &&
_titleScore(mpvTrack.title, plexTrack.title, plexTrack.displayTitle) == 0) {
return false;
}
return mpvTrack.isForced == plexTrack.forced;
}
int _scoreLowMetadataSubtitleFacts(SubtitleTrack mpvTrack, MediaSubtitleTrack plexTrack) {
var score = 0;
final plexLanguage = plexTrack.languageCode;
if (_hasSubtitleFact(mpvTrack.language) &&
_hasSubtitleFact(plexLanguage) &&
_languagesMatch(mpvTrack.language, plexLanguage)) {
score += 10;
if (_languageCodesExactMatch(mpvTrack.language, plexLanguage)) score++;
}
if (_hasSubtitleFact(mpvTrack.codec) &&
_hasSubtitleFact(plexTrack.codec) &&
_subtitleCodecsMatch(mpvTrack.codec, plexTrack.codec)) {
score += 5;
}
if (_hasSubtitleFact(mpvTrack.title) &&
(_hasSubtitleFact(plexTrack.title) || _hasSubtitleFact(plexTrack.displayTitle)) &&
_titleScore(mpvTrack.title, plexTrack.title, plexTrack.displayTitle) > 0) {
score += 3;
}
if (mpvTrack.isForced == plexTrack.forced) score += 2;
return score;
}
T? _findUniqueBestLowMetadataMatch<T extends Object>(
Iterable<T> candidates, {
required bool Function(T candidate) isCompatible,
required int Function(T candidate) score,
}) {
T? bestMatch;
var bestScore = -1;
var bestIsUnique = false;
for (final candidate in candidates) {
if (!isCompatible(candidate)) continue;
final candidateScore = score(candidate);
if (candidateScore > bestScore) {
bestMatch = candidate;
bestScore = candidateScore;
bestIsUnique = true;
} else if (candidateScore == bestScore) {
bestIsUnique = false;
}
}
return bestIsUnique ? bestMatch : null;
}
/// Find the MPV subtitle track that matches a Plex subtitle track
SubtitleTrack? findMpvTrackForPlexSubtitle(
MediaSubtitleTrack plexTrack,
@@ -140,7 +242,22 @@ SubtitleTrack? findMpvTrackForPlexSubtitle(
// Prefer metadata matches. Container sidecars may expose no language/title/
// codec at all, so their stable subtitle order is the last-resort identity.
return bestScore >= 10 || bestMatchUsesContainerOrdinal ? bestMatch : null;
if (bestScore >= 10 || bestMatchUsesContainerOrdinal) return bestMatch;
final plexTracks = allPlexTracks;
if (plexTracks == null ||
!_isDirectEmbeddedPlexSubtitle(plexTrack) ||
_classifyDirectEmbeddedSubtitleCatalog(plexTracks, mpvTracks) != _DirectEmbeddedSubtitleCatalog.complete) {
return null;
}
// A complete ordinary direct catalog may safely resolve low-metadata rows
// only from their facts. Native order is deliberately not an identity.
return _findUniqueBestLowMetadataMatch(
mpvTracks.where(_isDirectEmbeddedMpvSubtitle),
isCompatible: (candidate) => _lowMetadataSubtitleFactsAreCompatible(candidate, plexTrack),
score: (candidate) => _scoreLowMetadataSubtitleFacts(candidate, plexTrack),
);
}
/// Find the Plex subtitle track that matches an MPV subtitle track
@@ -204,7 +321,20 @@ MediaSubtitleTrack? findPlexTrackForMpvSubtitle(
// Prefer metadata matches, with container order as the symmetric fallback
// needed to persist a metadata-free native track back to its Plex stream.
return bestScore >= 10 || bestMatchUsesContainerOrdinal ? bestMatch : null;
if (bestScore >= 10 || bestMatchUsesContainerOrdinal) return bestMatch;
final mpvTracks = allMpvTracks;
if (mpvTracks == null ||
!_isDirectEmbeddedMpvSubtitle(mpvTrack) ||
_classifyDirectEmbeddedSubtitleCatalog(plexTracks, mpvTracks) != _DirectEmbeddedSubtitleCatalog.complete) {
return null;
}
return _findUniqueBestLowMetadataMatch(
plexTracks.where(_isDirectEmbeddedPlexSubtitle),
isCompatible: (candidate) => _lowMetadataSubtitleFactsAreCompatible(mpvTrack, candidate),
score: (candidate) => _scoreLowMetadataSubtitleFacts(mpvTrack, candidate),
);
}
/// Find the MPV audio track that matches a Plex audio track
@@ -596,6 +726,22 @@ class TrackSelectionService {
return TrackSelectionResult(selected, TrackSelectionPriority.profile);
}
MediaSubtitleTrack? _sourceSubtitleTrack(String nativeId) {
if (!nativeId.startsWith('source:')) return null;
final sourceId = int.tryParse(nativeId.substring('source:'.length));
return sourceId == null ? null : plexMediaInfo?.subtitleTracks.where((track) => track.id == sourceId).firstOrNull;
}
bool _hasCompleteDirectPlexCatalogFor(MediaSubtitleTrack? sourceTrack, List<SubtitleTrack> availableTracks) {
final info = plexMediaInfo;
return metadata.backend == MediaBackend.plex &&
info != null &&
sourceTrack != null &&
_isDirectEmbeddedPlexSubtitle(sourceTrack) &&
_classifyDirectEmbeddedSubtitleCatalog(info.subtitleTracks, availableTracks) ==
_DirectEmbeddedSubtitleCatalog.complete;
}
SubtitleTrack? findBestSubtitleMatch(List<SubtitleTrack> availableTracks, SubtitleTrack preferred) {
// Handle special "no subtitles" case
if (preferred.id == 'no') {
@@ -603,10 +749,7 @@ class TrackSelectionService {
}
if (preferred.id.startsWith('source:')) {
final sourceId = int.tryParse(preferred.id.substring('source:'.length));
final sourceTrack = sourceId == null
? null
: plexMediaInfo?.subtitleTracks.where((track) => track.id == sourceId).firstOrNull;
final sourceTrack = _sourceSubtitleTrack(preferred.id);
if (sourceTrack == null) return null;
return findMpvTrackForPlexSubtitle(sourceTrack, availableTracks, allPlexTracks: plexMediaInfo?.subtitleTracks);
}
@@ -763,9 +906,9 @@ class TrackSelectionService {
/// Priority 4: Default track
/// Priority 5: Off
///
/// Returns null while the source catalog advertises subtitles but the
/// native player has not exposed any of them yet. That transient state is
/// not equivalent to an explicit server decision to turn subtitles off.
/// Returns null only while the source catalog can still deliver the requested
/// subtitle. A complete catalog with no unambiguous match proceeds through
/// the safe default/off priorities instead of waiting indefinitely.
TrackSelectionResult<SubtitleTrack>? selectSubtitleTrack(
List<SubtitleTrack> availableTracks,
SubtitleTrack? preferredSubtitleTrack,
@@ -781,7 +924,10 @@ class TrackSelectionService {
return TrackSelectionResult(subtitleToSelect, TrackSelectionPriority.navigation);
}
}
if (preferredSubtitleTrack.id.startsWith('source:')) return null;
if (preferredSubtitleTrack.id.startsWith('source:') &&
!_hasCompleteDirectPlexCatalogFor(_sourceSubtitleTrack(preferredSubtitleTrack.id), availableTracks)) {
return null;
}
}
// Priority 2: Trust the server's selected track. Plex computes this from
@@ -800,7 +946,10 @@ class TrackSelectionService {
if (matchedMpvTrack != null) {
return TrackSelectionResult(matchedMpvTrack, TrackSelectionPriority.serverSelected);
}
if (metadata.backend == MediaBackend.plex) return null;
if (metadata.backend == MediaBackend.plex &&
!_hasCompleteDirectPlexCatalogFor(serverSelectedTrack, availableTracks)) {
return null;
}
} else if (metadata.backend == MediaBackend.jellyfin) {
final defaultStreamIndex = info.defaultSubtitleStreamIndex;
if (defaultStreamIndex == -1) {
@@ -73,9 +73,16 @@ class _SessionIndicator extends StatelessWidget {
@override
Widget build(BuildContext context) {
final semanticValue = [
'${t.watchTogether.participants}: $participantCount',
if (isHost) t.watchTogether.youAreHost,
if (isSyncing) t.watchTogether.syncing,
].join(', ');
return FocusableWrapper(
onSelect: onTap,
semanticLabel: t.watchTogether.openSessionControls,
semanticValue: semanticValue,
descendantsAreFocusable: false,
borderRadius: 20,
useBackgroundFocus: true,
@@ -173,6 +180,7 @@ class _SessionMenuSheet extends StatelessWidget {
FocusableWrapper(
onSelect: () => _copySessionCode(context, provider.sessionId!),
semanticLabel: t.watchTogether.copySessionCode,
semanticValue: provider.sessionId!,
descendantsAreFocusable: false,
borderRadius: 8,
useBackgroundFocus: true,
+2 -5
View File
@@ -136,6 +136,7 @@ class _CollapsibleTextState extends State<CollapsibleText> {
: _expanded
? t.accessibility.collapseText
: t.accessibility.expandText,
excludeChildSemantics: false,
descendantsAreFocusable: false,
disableScale: true,
useBackgroundFocus: true,
@@ -147,11 +148,7 @@ class _CollapsibleTextState extends State<CollapsibleText> {
}
return ClickableCursor(
child: GestureDetector(
onTap: _toggleExpanded,
excludeFromSemantics: widget.suppressExpandSemantics,
child: result,
),
child: GestureDetector(onTap: _toggleExpanded, excludeFromSemantics: true, child: result),
);
},
);
+1
View File
@@ -51,6 +51,7 @@ class DeviceCodeDialog extends StatelessWidget {
child: FocusableWrapper(
onSelect: () => _copy(context),
semanticLabel: t.services.deviceCode.copyCode,
semanticValue: code.userCode,
descendantsAreFocusable: false,
useBackgroundFocus: true,
borderRadius: 8,
@@ -23,6 +23,9 @@ class FocusablePopupMenuButton<T> extends StatefulWidget {
final VoidCallback? onNavigateLeft;
final VoidCallback? onNavigateRight;
final String? semanticLabel;
/// Optional current value announced after the effective semantic label.
final String? semanticValue;
final double borderRadius;
final bool useBackgroundFocus;
final bool enableLongPress;
@@ -47,6 +50,7 @@ class FocusablePopupMenuButton<T> extends StatefulWidget {
this.onNavigateLeft,
this.onNavigateRight,
this.semanticLabel,
this.semanticValue,
this.borderRadius = 100,
this.useBackgroundFocus = true,
this.enableLongPress = true,
@@ -73,6 +77,7 @@ class _FocusablePopupMenuButtonState<T> extends State<FocusablePopupMenuButton<T
useBackgroundFocus: widget.useBackgroundFocus,
descendantsAreFocusable: false,
semanticLabel: widget.semanticLabel ?? widget.tooltip,
semanticValue: widget.semanticValue,
enableLongPress: widget.enableLongPress,
onNavigateUp: widget.onNavigateUp,
onNavigateDown: widget.onNavigateDown,
+11 -8
View File
@@ -81,6 +81,15 @@ class _HotKeyRecorderState extends State<HotKeyRecorder> {
}
}
List<String> _hotKeyDisplayLabels(HotKey hotKey) => [
for (final modifier in hotKey.modifiers ?? []) physicalKeyLabel(modifier.physicalKeys.first),
physicalKeyLabel(hotKey.key),
];
/// Formats a shortcut from the same ordered key labels rendered by
/// [HotKeyVirtualView].
String formatHotKeyDisplay(HotKey hotKey) => _hotKeyDisplayLabels(hotKey).join(' + ');
/// Renders a [HotKey] as a row of styled key label chips.
class HotKeyVirtualView extends StatelessWidget {
const HotKeyVirtualView({super.key, required this.hotKey});
@@ -89,14 +98,8 @@ class HotKeyVirtualView extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Wrap(
spacing: 8,
children: [
for (final modifier in hotKey.modifiers ?? [])
_VirtualKeyView(keyLabel: physicalKeyLabel(modifier.physicalKeys.first)),
_VirtualKeyView(keyLabel: physicalKeyLabel(hotKey.key)),
],
);
final keyLabels = _hotKeyDisplayLabels(hotKey);
return Wrap(spacing: 8, children: [for (final keyLabel in keyLabels) _VirtualKeyView(keyLabel: keyLabel)]);
}
}
+1
View File
@@ -299,6 +299,7 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix
onLongPress: showContextMenu,
onNavigateRight: () => _transportKey.currentState?.requestFocusOnFirst(),
semanticLabel: widget.track.title,
semanticValue: artist,
descendantsAreFocusable: false,
disableScale: true,
useBackgroundFocus: true,
+1
View File
@@ -61,6 +61,7 @@ class OAuthProxyDialog extends StatelessWidget {
FocusableWrapper(
onSelect: () => _copyUrl(context),
semanticLabel: t.services.oauthProxy.copyUrl,
semanticValue: start.url,
descendantsAreFocusable: false,
borderRadius: 8,
useBackgroundFocus: true,
@@ -5,6 +5,7 @@ import '../../../media/media_version.dart';
import '../../../media/media_source_info.dart';
import '../../../models/transcode_quality_preset.dart';
import '../../../mpv/mpv.dart';
import '../../../services/playback_initialization_types.dart';
import '../../../services/playback_subtitle_resolver.dart';
import '../../../services/shader_service.dart';
import '../helpers/track_filter_helper.dart';
@@ -23,7 +24,7 @@ class TrackControlsState {
final List<MediaSubtitleTrack> sourceSubtitleTracks;
final PlaybackSourceSubtitleChoice? selectedSubtitleChoice;
final int? selectedSecondarySubtitleStreamId;
final Set<int> sourceSubtitleSidecarIds;
final List<PlaybackSubtitleSidecar> sourceSubtitleSidecars;
final int? sourcePartId;
/// Total media duration in milliseconds. Used by the version/quality sheet
@@ -88,7 +89,7 @@ class TrackControlsState {
this.sourceSubtitleTracks = const [],
this.selectedSubtitleChoice,
this.selectedSecondarySubtitleStreamId,
this.sourceSubtitleSidecarIds = const <int>{},
this.sourceSubtitleSidecars = const <PlaybackSubtitleSidecar>[],
this.sourcePartId,
this.sourceDurationMs,
this.boxFitMode = 0,
@@ -141,7 +142,13 @@ class TrackControlsState {
/// Direct play keeps embedded/native switching instant while still exposing
/// unloaded server sidecars that require one source reopen when selected.
List<MediaSubtitleTrack> get directPlaySourceSidecars => !isTranscoding && onSwitchSubtitle != null
? sourceSubtitleTracks.where((track) => sourceSubtitleSidecarIds.contains(track.id)).toList(growable: false)
? sourceSubtitleTracks
.where(
(track) => sourceSubtitleSidecars.any(
(sidecar) => sidecar.sourceStreamId != null && sidecar.sourceStreamId == track.id,
),
)
.toList(growable: false)
: const <MediaSubtitleTrack>[];
/// External subtitle search needs both a searchable media item and a server
@@ -134,7 +134,9 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
: const <MediaSubtitleTrack>[],
selectedSubtitleChoice: canSwitchSourceSubtitles ? versionQuality.selectedSubtitleChoice : null,
selectedSecondarySubtitleStreamId: canSwitchSourceSubtitles ? widget.selectedSecondarySubtitleStreamId : null,
sourceSubtitleSidecarIds: canSwitchSourceSubtitles ? widget.sourceSubtitleSidecarIds : const <int>{},
sourceSubtitleSidecars: canSwitchSourceSubtitles
? widget.sourceSubtitleSidecars
: const <PlaybackSubtitleSidecar>[],
sourcePartId: canSwitchSourceSubtitles ? widget.sourcePartId : null,
sourceDurationMs: widget.metadata.durationMs,
boxFitMode: widget.boxFitMode,
@@ -418,12 +418,26 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
final hasSecondary = widget.supportsSecondary && secondarySub != null;
final selectedSourceId = widget.trackControlsState.selectedSubtitleChoice?.sourceStreamId;
final selectedSecondarySourceId = widget.trackControlsState.selectedSecondarySubtitleStreamId;
final attachedSourceSidecarIds = <int>{};
for (final sidecar in widget.trackControlsState.sourceSubtitleSidecars) {
final sourceStreamId = sidecar.sourceStreamId;
final uri = sidecar.track.uri;
if (sourceStreamId == null || uri == null) continue;
if (widget.tracks.any((track) => track.isExternal && track.uri == uri)) {
attachedSourceSidecarIds.add(sourceStreamId);
}
}
final unloadedSourceSidecars = widget.sourceSidecars
.where((track) => track.id != selectedSourceId && track.id != selectedSecondarySourceId)
.where(
(track) =>
!attachedSourceSidecarIds.contains(track.id) &&
track.id != selectedSourceId &&
track.id != selectedSecondarySourceId,
)
.toList(growable: false);
// +1 for "Off" row. The selected direct-play sidecar is already present
// in [tracks], so only the other server sidecars are appended.
// +1 for "Off". Source sidecars represented by native external tracks,
// plus active source IDs awaiting native discovery, are not appended.
final itemCount = widget.tracks.length + unloadedSourceSidecars.length + 1;
final selectedIndex = isOffSelected ? null : widget.tracks.indexWhere((t) => t.id == selectedSub.id) + 1;
@@ -30,6 +30,7 @@ import 'package:flutter/services.dart'
import '../../services/fullscreen_state_manager.dart';
import '../../services/macos_window_service.dart';
import '../../services/pip_service.dart';
import '../../services/playback_initialization_types.dart';
import '../../services/playback_subtitle_resolver.dart';
import 'package:window_manager/window_manager.dart';
@@ -422,7 +423,7 @@ class PlexVideoControls extends StatefulWidget {
final List<MediaSubtitleTrack> sourceSubtitleTracks;
final PlaybackSourceSubtitleChoice? selectedSubtitleChoice;
final int? selectedSecondarySubtitleStreamId;
final Set<int> sourceSubtitleSidecarIds;
final List<PlaybackSubtitleSidecar> sourceSubtitleSidecars;
final int? sourcePartId;
final PlaybackSourceChangeCallback? onPlaybackSourceChanged;
final int boxFitMode;
@@ -540,7 +541,7 @@ class PlexVideoControls extends StatefulWidget {
this.sourceSubtitleTracks = const [],
this.selectedSubtitleChoice,
this.selectedSecondarySubtitleStreamId,
this.sourceSubtitleSidecarIds = const <int>{},
this.sourceSubtitleSidecars = const <PlaybackSubtitleSidecar>[],
this.sourcePartId,
this.onPlaybackSourceChanged,
this.boxFitMode = 0,
@@ -421,7 +421,6 @@ class _TimelineSliderState extends State<TimelineSlider> {
autoScroll: false,
disableScale: true,
focusColor: Colors.transparent,
semanticLabel: t.videoControls.timelineSlider,
descendantsAreFocusable: false,
child: slider,
);
+25 -1
View File
@@ -22,6 +22,19 @@ import 'package:plugin_platform_interface/plugin_platform_interface.dart';
import '../test_helpers/prefs.dart';
final class _EnsureOpenTrackingInterceptor extends QueryInterceptor {
var calls = 0;
var completed = false;
@override
Future<bool> ensureOpen(QueryExecutor executor, QueryExecutorUser user) async {
calls++;
final result = await executor.ensureOpen(user);
completed = true;
return result;
}
}
void main() {
final suite = _AppDatabaseTestSuite();
suite.registerTests();
@@ -731,7 +744,18 @@ class _AppDatabaseTestSuite {
await seeded.close();
seeded = null;
reopened = AppDatabase.forTesting(NativeDatabase(file));
resetSharedPreferencesForTest();
final prefs = await BaseSharedPreferencesService.sharedCache();
final openTracker = _EnsureOpenTrackingInterceptor();
final bootstrap = await AppDatabase.open(
isTvos: false,
databaseFile: file,
preferences: prefs,
executorFactory: (databaseFile) => NativeDatabase(databaseFile).interceptWith(openTracker),
);
reopened = bootstrap.database;
expect(openTracker.calls, greaterThanOrEqualTo(1));
expect(openTracker.completed, isTrue);
final owners = await reopened.select(reopened.downloadOwners).get();
DownloadOwnerItem owner(String profileId, String globalKey) =>
owners.singleWhere((row) => row.profileId == profileId && row.globalKey == globalKey);
+29 -12
View File
@@ -4,6 +4,7 @@ import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:plezy/media/ids.dart';
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as path;
import 'package:plezy/database/app_database.dart';
import 'package:plezy/database/download_operations.dart';
import 'package:plezy/models/download_models.dart';
@@ -130,12 +131,10 @@ void main() {
group('insertQueuedDownload', () {
test('atomically persists media identity, scope, policy, and queue state', () async {
final tempDir = await Directory.systemTemp.createTemp('plezy_atomic_queue_');
final databaseFile = File('${tempDir.path}/downloads.sqlite');
addTearDown(() async {
if (await tempDir.exists()) await tempDir.delete(recursive: true);
});
await db.close();
final tempDir = await Directory.systemTemp.createTemp('plezy_atomic_queue_');
final databaseFile = File(path.join(tempDir.path, 'downloads.sqlite'));
try {
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
final outcome = await db.insertQueuedDownload(
serverId: ServerId('srv'),
@@ -169,6 +168,13 @@ void main() {
expect(queued.priority, 7);
expect(queued.downloadSubtitles, isFalse);
expect(queued.downloadArtwork, isTrue);
} finally {
await db.close();
db = AppDatabase.forTesting(NativeDatabase.memory());
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
}
});
test('requeues retryable rows without replacing identity or physical fields', () async {
@@ -369,12 +375,10 @@ void main() {
});
test('rolls back both new and replacement media rows when queue insertion fails', () async {
final tempDir = await Directory.systemTemp.createTemp('plezy_atomic_rollback_');
final databaseFile = File('${tempDir.path}/downloads.sqlite');
addTearDown(() async {
if (await tempDir.exists()) await tempDir.delete(recursive: true);
});
await db.close();
final tempDir = await Directory.systemTemp.createTemp('plezy_atomic_rollback_');
final databaseFile = File(path.join(tempDir.path, 'downloads.sqlite'));
try {
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
await db.insertDownload(
serverId: ServerId('srv'),
@@ -397,7 +401,10 @@ void main() {
throwsA(anything),
);
expect(await db.getDownloadedMedia('srv:new'), isNull);
expect(await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:new'))).get(), isEmpty);
expect(
await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:new'))).get(),
isEmpty,
);
await expectLater(
db.insertQueuedDownload(
@@ -411,7 +418,10 @@ void main() {
await db.close();
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
expect(await db.getDownloadedMedia('srv:new'), isNull);
expect(await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:new'))).get(), isEmpty);
expect(
await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:new'))).get(),
isEmpty,
);
final preserved = await db.getDownloadedMedia('srv:existing');
expect(preserved?.status, DownloadStatus.failed.index);
expect(preserved?.progress, 41);
@@ -420,6 +430,13 @@ void main() {
await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:existing'))).get(),
isEmpty,
);
} finally {
await db.close();
db = AppDatabase.forTesting(NativeDatabase.memory());
if (await tempDir.exists()) {
await tempDir.delete(recursive: true);
}
}
});
});
+91
View File
@@ -1,4 +1,7 @@
import 'dart:ui' show SemanticsAction, Tristate;
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart' show SemanticsNode;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/card_focus_scope.dart';
@@ -121,4 +124,92 @@ void main() {
semanticsOwner.removeListener(countSemanticsUpdate);
semantics.dispose();
});
testWidgets('semantic label replaces child semantics by default', (tester) async {
final semantics = tester.ensureSemantics();
var activations = 0;
var childActivations = 0;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableWrapper(
semanticLabel: 'Open details',
semanticValue: 'Ready',
onSelect: () => activations++,
child: Semantics(
label: 'Decorative artwork',
value: 'Decorative state',
button: true,
onTap: () => childActivations++,
child: const Text('Poster'),
),
),
),
),
);
final finder = find.bySemanticsLabel('Open details');
expect(finder, findsOneWidget);
expect(find.bySemanticsLabel('Decorative artwork'), findsNothing);
expect(find.bySemanticsLabel('Poster'), findsNothing);
final node = tester.getSemantics(finder);
final data = node.getSemanticsData();
expect(data.value, 'Ready');
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isEnabled, Tristate.isTrue);
expect(data.hasAction(SemanticsAction.tap), isTrue);
expect(_semanticTapNodeCount(tester), 1);
node.owner!.performAction(node.id, SemanticsAction.tap);
expect(activations, 1);
expect(childActivations, 0);
semantics.dispose();
});
testWidgets('merge mode supplements non-interactive child semantics', (tester) async {
final semantics = tester.ensureSemantics();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusableWrapper(
semanticLabel: 'Expand',
excludeChildSemantics: false,
onSelect: () {},
child: Semantics(value: 'Available offline', child: const Text('Visible synopsis')),
),
),
),
);
final finder = find.bySemanticsLabel(RegExp('Expand'));
expect(finder, findsOneWidget);
expect(find.bySemanticsLabel(RegExp('Visible synopsis')), findsOneWidget);
final data = tester.getSemantics(finder).getSemanticsData();
expect(data.label, contains('Expand'));
expect(data.label, contains('Visible synopsis'));
expect(data.value, 'Available offline');
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isEnabled, Tristate.isTrue);
expect(data.hasAction(SemanticsAction.tap), isTrue);
expect(_semanticTapNodeCount(tester), 1);
semantics.dispose();
});
}
int _semanticTapNodeCount(WidgetTester tester) {
var count = 0;
void visit(SemanticsNode node) {
if (node.getSemanticsData().hasAction(SemanticsAction.tap)) count++;
node.visitChildren((child) {
visit(child);
return true;
});
}
visit(tester.binding.renderViews.single.owner!.semanticsOwner!.rootSemanticsNode!);
return count;
}
@@ -185,4 +185,96 @@ void main() {
expect(cacheParserCalls, 0);
expect(responseParserCalls, 0);
});
group('fetchWithCacheFallback error selection', () {
const cachedBody = {'value': 'cached'};
test('omitted selector preserves fallback on an HTTP 500', () async {
const key = '/metadata/default-fallback';
await cache.put(client.serverId, key, cachedBody);
var cacheParserCalls = 0;
var responseParserCalls = 0;
final value = await client.fetchWithCacheFallback<String>(
cacheKey: key,
networkCall: () async =>
MediaServerResponse(statusCode: 500, data: const {'value': 'server error'}, headers: const {}),
parseCache: (cached) {
cacheParserCalls++;
return (cached as Map<String, dynamic>)['value'] as String;
},
parseResponse: (_) {
responseParserCalls++;
return 'network';
},
);
expect(value, 'cached');
expect(cacheParserCalls, 1);
expect(responseParserCalls, 0);
});
test('rejecting selector rethrows an HTTP status without reading cached data', () async {
const key = '/metadata/rejected-fallback';
await cache.put(client.serverId, key, cachedBody);
var cacheParserCalls = 0;
var responseParserCalls = 0;
await expectLater(
client.fetchWithCacheFallback<String>(
cacheKey: key,
networkCall: () async =>
MediaServerResponse(statusCode: 500, data: const {'value': 'server error'}, headers: const {}),
shouldFallback: (error) => error is MediaServerHttpException && error.isTransient,
parseCache: (_) {
cacheParserCalls++;
return 'cached';
},
parseResponse: (_) {
responseParserCalls++;
return 'network';
},
),
throwsA(isA<MediaServerHttpException>().having((error) => error.statusCode, 'statusCode', 500)),
);
expect(cacheParserCalls, 0);
expect(responseParserCalls, 0);
expect(await cache.get(client.serverId, key), cachedBody);
});
test('accepting selector serves cache after a transient transport failure', () async {
const key = '/metadata/transient-fallback';
await cache.put(client.serverId, key, cachedBody);
final transient = MediaServerHttpException(
type: MediaServerHttpErrorType.connectionTimeout,
message: 'timed out',
);
Object? selectedError;
var cacheParserCalls = 0;
var responseParserCalls = 0;
final value = await client.fetchWithCacheFallback<String>(
cacheKey: key,
networkCall: () => Future<MediaServerResponse>.error(transient),
shouldFallback: (error) {
selectedError = error;
return error is MediaServerHttpException && error.isTransient;
},
parseCache: (cached) {
cacheParserCalls++;
return (cached as Map<String, dynamic>)['value'] as String;
},
parseResponse: (_) {
responseParserCalls++;
return 'network';
},
);
expect(value, 'cached');
expect(identical(selectedError, transient), isTrue);
expect(cacheParserCalls, 1);
expect(responseParserCalls, 0);
});
});
}
@@ -23,6 +23,7 @@ import 'package:plezy/services/storage_service.dart';
import 'package:plezy/services/system_shelf_service.dart';
import 'package:provider/provider.dart';
import '../test_helpers/io_fakes.dart';
import '../test_helpers/prefs.dart';
void main() {
@@ -60,6 +61,15 @@ void main() {
final trackerProviders = <TrackersProvider>[];
final companionProviders = <CompanionRemoteProvider>[];
final disposedActiveIds = <String>[];
final trackerHttpClients = <FakeHttpClient>[];
// The probe instantiates TrackersProvider (four eager auth owners); the
// separate Trakt provider remains lazy in this reduced shell.
const trackerAuthClientsPerProfile = 4;
FakeHttpClient trackerHttpClientFactory() {
final client = FakeHttpClient(200, const <int>[]);
trackerHttpClients.add(client);
return client;
}
addTearDown(() async {
await tester.pumpWidget(const SizedBox.shrink());
@@ -97,6 +107,7 @@ void main() {
child: MaterialApp(
home: ProfileSessionScreen.forTesting(
initialPromptHandled: true,
httpClientFactory: trackerHttpClientFactory,
profileShellBuilder: (context) => _ProfileProbeShell(
discoverProviders: discoverProviders,
hiddenProviders: hiddenProviders,
@@ -109,6 +120,9 @@ void main() {
),
);
await tester.pumpAndSettle();
expect(trackerHttpClients, hasLength(trackerAuthClientsPerProfile));
final ownerHttpClients = List<FakeHttpClient>.of(trackerHttpClients);
_expectCloseCount(ownerHttpClients, 0);
expect(find.text('active:local-owner'), findsOneWidget);
expect(SystemShelfService().debugActiveOwner, owner.id);
@@ -131,6 +145,10 @@ void main() {
expect(await activeProfile.activate(kids), isTrue);
await tester.pumpAndSettle();
expect(trackerHttpClients, hasLength(trackerAuthClientsPerProfile * 2));
final kidsHttpClients = trackerHttpClients.sublist(ownerHttpClients.length);
_expectCloseCount(ownerHttpClients, 1);
_expectCloseCount(kidsHttpClients, 0);
expect(find.text('old profile route'), findsNothing);
expect(find.text('active:local-kids'), findsOneWidget);
@@ -154,19 +172,27 @@ void main() {
await activeProfile.clearActiveProfile();
await tester.pumpAndSettle();
expect(trackerHttpClients, hasLength(trackerAuthClientsPerProfile * 3));
final signedOutHttpClients = trackerHttpClients.sublist(ownerHttpClients.length + kidsHttpClients.length);
_expectCloseCount(ownerHttpClients, 1);
_expectCloseCount(kidsHttpClients, 1);
_expectCloseCount(signedOutHttpClients, 0);
expect(SystemShelfService().debugActiveOwner, isNull);
expect(discoverProviders.last.profileId, isNull);
// Companion provider disposal cancels Drift-backed profile watches. Give
// their asynchronous cancellation timers a frame before test invariants
// are checked.
await tester.pumpWidget(const SizedBox.shrink());
for (var i = 0; i < 3; i++) {
await tester.pump(const Duration(milliseconds: 1));
}
await tester.pumpAndSettle();
expect(trackerHttpClients.toSet(), hasLength(trackerAuthClientsPerProfile * 3));
_expectCloseCount(trackerHttpClients, 1);
});
}
void _expectCloseCount(Iterable<FakeHttpClient> clients, int expected) {
for (final client in clients) {
expect(client.closeCount, expected);
}
}
class _ProfileProbeShell extends StatefulWidget {
const _ProfileProbeShell({
required this.discoverProviders,
@@ -10,6 +10,7 @@ import 'package:plezy/services/trackers/tracker_session.dart';
import 'package:plezy/services/trackers/mal/mal_tracker.dart';
import 'package:plezy/services/trackers/simkl/simkl_tracker.dart';
import '../test_helpers/io_fakes.dart';
import '../test_helpers/prefs.dart';
final _malStore = trackerAccountStore(TrackerService.mal);
@@ -62,6 +63,31 @@ void main() {
p.dispose();
});
test('forTesting owns one fresh client per eager auth owner', () {
final clients = <FakeHttpClient>[];
final p = TrackersProvider.forTesting(
connectPipeline: _ControlledConnectPipeline(_mal()).call,
httpClientFactory: () {
final client = FakeHttpClient(200, const <int>[]);
clients.add(client);
return client;
},
);
expect(clients, hasLength(4));
expect(clients.toSet(), hasLength(4));
for (final client in clients) {
expect(client.closeCount, 0);
}
p.dispose();
for (final client in clients) {
expect(client.closeCount, 1);
expect(client.isClosed, isTrue);
}
});
test('onActiveProfileChanged loads sessions from per-profile stores', () async {
const uuid = 'profile-1';
await _malStore.save(uuid, _mal(username: 'alice'));
@@ -6,6 +6,7 @@ import 'package:plezy/services/trackers/tracker_constants.dart';
import 'package:plezy/services/trackers/tracker_session.dart';
import 'package:plezy/services/trakt/trakt_sync_service.dart';
import '../test_helpers/io_fakes.dart';
import '../test_helpers/prefs.dart';
final _store = trackerAccountStore(TrackerService.trakt);
@@ -34,6 +35,25 @@ void main() {
p.dispose();
});
test('owns the injected auth client until disposal', () {
final clients = <FakeHttpClient>[];
final p = TraktAccountProvider(
httpClientFactory: () {
final client = FakeHttpClient(200, const <int>[]);
clients.add(client);
return client;
},
);
expect(clients, hasLength(1));
expect(clients.single.closeCount, 0);
p.dispose();
expect(clients.single.closeCount, 1);
expect(clients.single.isClosed, isTrue);
});
test('onActiveProfileChanged loads stored session and notifies', () async {
// Pre-seed the store for a specific profile uuid.
const uuid = 'profile-1';
+29
View File
@@ -1,3 +1,5 @@
import 'dart:ui' show SemanticsAction;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -202,6 +204,33 @@ void main() {
expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail');
});
testWidgets('source switcher announces the active source as its value', (tester) async {
TvDetectionService.debugSetAppleTVOverride(false);
final semantics = tester.ensureSemantics();
final sources = await _pumpExplore(tester);
var finder = find.bySemanticsLabel(t.explore.selectSource);
expect(finder, findsOneWidget);
final node = tester.getSemantics(finder);
var data = node.getSemanticsData();
expect(data.value, 'Trakt');
expect(data.flagsCollection.isButton, isTrue);
expect(data.hasAction(SemanticsAction.tap), isTrue);
node.owner!.performAction(node.id, SemanticsAction.tap);
await tester.pumpAndSettle();
await tester.tap(find.text('MyAnimeList'));
await tester.pumpAndSettle();
expect(sources.activeSource?.id, CatalogSourceId.mal);
finder = find.bySemanticsLabel(t.explore.selectSource);
expect(finder, findsOneWidget);
data = tester.getSemantics(finder).getSemanticsData();
expect(data.value, 'MyAnimeList');
expect(data.hasAction(SemanticsAction.tap), isTrue);
semantics.dispose();
});
testWidgets('source switcher exposes every catalog source with its brand logo', (tester) async {
final sources = await _pumpExplore(tester);
tester.state<ExploreScreenState>(find.byType(ExploreScreen)).focusActiveTabIfReady();
@@ -488,6 +488,7 @@ class _SettingsHarness {
required this.theme,
required this.trakt,
required this.trackers,
required this.trackerHttpClients,
required this.seerr,
required this.downloadManager,
required this.downloadProvider,
@@ -501,6 +502,7 @@ class _SettingsHarness {
final ThemeProvider theme;
final TraktAccountProvider trakt;
final TrackersProvider trackers;
final List<FakeHttpClient> trackerHttpClients;
final SeerrAccountProvider seerr;
final DownloadManagerService downloadManager;
final DownloadProvider downloadProvider;
@@ -519,6 +521,11 @@ class _SettingsHarness {
activeProfile.dispose();
await plexHome.dispose();
await database.close();
expect(trackerHttpClients, hasLength(5));
expect(trackerHttpClients.toSet(), hasLength(5));
for (final client in trackerHttpClients) {
expect(client.closeCount, 1);
}
}
}
@@ -545,8 +552,15 @@ Future<_SettingsHarness> _pumpSettingsScreen(
final activeProfile = ActiveProfileProvider(registry: profiles, plexHome: plexHome, connections: connections);
final libraries = LibrariesProvider();
final theme = ThemeProvider();
final trakt = TraktAccountProvider();
final trackers = TrackersProvider();
final trackerHttpClients = <FakeHttpClient>[];
FakeHttpClient trackerHttpClientFactory() {
final client = FakeHttpClient(HttpStatus.ok, const <int>[]);
trackerHttpClients.add(client);
return client;
}
final trakt = TraktAccountProvider(httpClientFactory: trackerHttpClientFactory);
final trackers = TrackersProvider(httpClientFactory: trackerHttpClientFactory);
final seerr = SeerrAccountProvider();
final settingsService = SettingsService.instance;
final storageService = DownloadStorageService.instance;
@@ -582,6 +596,7 @@ Future<_SettingsHarness> _pumpSettingsScreen(
theme: theme,
trakt: trakt,
trackers: trackers,
trackerHttpClients: trackerHttpClients,
seerr: seerr,
downloadManager: downloadManager,
downloadProvider: downloadProvider,
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/screens/video_player/completion_latch.dart';
void main() {
@@ -78,22 +79,41 @@ void main() {
});
group('completionNavigationAction', () {
test('presents a resolved next episode', () {
test('presents a resolved next item', () {
expect(
completionNavigationAction(hasNext: true, adjacentLoadFailed: false),
completionNavigationAction(hasNext: true, adjacentStatus: QueueNavigationStatus.found),
CompletionNavigationAction.presentNext,
);
});
test('retries adjacency instead of exiting after a load failure', () {
test('retries queued movie adjacency instead of exiting after a load failure', () {
expect(
completionNavigationAction(hasNext: false, adjacentLoadFailed: true),
completionNavigationAction(hasNext: false, adjacentStatus: QueueNavigationStatus.failed),
CompletionNavigationAction.retryAdjacent,
);
});
test('does not exit while the initial adjacency load is unresolved', () {
// VideoPlayerScreen initializes adjacency status to failed until its
// fire-and-forget first load commits a resolved status.
expect(
completionNavigationAction(hasNext: false, adjacentStatus: QueueNavigationStatus.failed),
CompletionNavigationAction.retryAdjacent,
);
});
test('exits a standalone movie after adjacency resolves unavailable', () {
expect(
completionNavigationAction(hasNext: false, adjacentStatus: QueueNavigationStatus.unavailable),
CompletionNavigationAction.exit,
);
});
test('exits only after the queue boundary was resolved', () {
expect(completionNavigationAction(hasNext: false, adjacentLoadFailed: false), CompletionNavigationAction.exit);
expect(
completionNavigationAction(hasNext: false, adjacentStatus: QueueNavigationStatus.boundary),
CompletionNavigationAction.exit,
);
});
});
@@ -6,6 +6,7 @@ import 'package:plezy/media/media_item.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_server_client.dart';
import 'package:plezy/media/play_queue.dart';
import 'package:plezy/models/plex/play_queue_response.dart';
import 'package:plezy/providers/multi_server_provider.dart';
import 'package:plezy/providers/playback_state_provider.dart';
import 'package:plezy/services/data_aggregation_service.dart';
@@ -26,6 +27,17 @@ MediaItem _jfEpisode(String id, {required String seriesId, ServerId? serverId})
grandparentId: seriesId,
);
MediaItem _jfMovie(String id) => testMediaItem(
id: id,
backend: MediaBackend.jellyfin,
kind: MediaKind.movie,
title: 'Movie $id',
serverId: ServerId('srv-jf'),
);
PlexMediaItem _plexMovie(String id, int playQueueItemId) =>
PlexMediaItem(id: id, kind: MediaKind.movie, title: 'Movie $id', playQueueItemId: playQueueItemId);
MediaItem _plexEpisode(String id, {required String seriesId, int? viewCount}) => testMediaItem(
id: id,
backend: MediaBackend.plex,
@@ -102,7 +114,7 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('loadAdjacentEpisodes', () {
testWidgets('returns unavailable when no play queue is active for non-series media', (tester) async {
testWidgets('returns unavailable when no play queue is active for a standalone movie', (tester) async {
final playback = PlaybackStateProvider();
addTearDown(playback.dispose);
final manager = _StubManager(null);
@@ -116,7 +128,7 @@ void main() {
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
],
child: _ProbeWidget(metadata: _meta('42'), onResult: (r) => result = r),
child: _ProbeWidget(metadata: _jfMovie('42'), onResult: (r) => result = r),
),
);
await tester.pump();
@@ -124,11 +136,218 @@ void main() {
expect(result, isNotNull);
expect(result!.nextStatus, QueueNavigationStatus.unavailable);
expect(result!.previousStatus, QueueNavigationStatus.unavailable);
expect(result!.hasNext, isFalse);
expect(result!.hasPrevious, isFalse);
expect(playback.isQueueActive, isFalse);
});
testWidgets('preserves adjacency for a movie in an active local queue', (tester) async {
final previous = _jfMovie('movie-1');
final current = _jfMovie('movie-2');
final next = _jfMovie('movie-3');
final playback = PlaybackStateProvider();
addTearDown(playback.dispose);
playback.setPlaybackFromLocalQueue(
LocalPlayQueue(
id: 'jellyfin:playlist-movies',
items: [previous, current, next],
currentIndex: 1,
backendId: MediaBackend.jellyfin.id,
),
contextKey: 'playlist-movies',
);
final client = _RecordingClient(seriesEpisodes: const []);
final manager = _StubManager(client);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(serverProvider.dispose);
AdjacentEpisodes? result;
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
],
child: _ProbeWidget(metadata: current, onResult: (r) => result = r),
),
);
await tester.pump();
await tester.pump();
expect(client.seriesQueueCalls, isEmpty);
expect(playback.isQueueActive, isTrue);
expect(playback.shuffleContextKey, 'playlist-movies');
expect(playback.currentQueueItem, same(current));
expect(playback.currentPlayQueueItemID, 1);
expect(playback.loadedItems, [previous, current, next]);
expect(result, isNotNull);
expect(result!.nextStatus, QueueNavigationStatus.found);
expect(result!.previousStatus, QueueNavigationStatus.found);
expect(result!.next, same(next));
expect(result!.previous, same(previous));
expect(result!.hasNext, isTrue);
expect(result!.hasPrevious, isTrue);
});
testWidgets('preserves a local movie collection queue for a current-item clone', (tester) async {
final previous = _jfMovie('movie-1');
final storedCurrent = _jfMovie('movie-2');
final next = _jfMovie('movie-3');
final playback = PlaybackStateProvider();
addTearDown(playback.dispose);
playback.setPlaybackFromLocalQueue(
LocalPlayQueue(
id: 'jellyfin:collection-movies',
items: [previous, storedCurrent, next],
currentIndex: 1,
backendId: MediaBackend.jellyfin.id,
),
contextKey: 'collection-movies',
);
final client = _RecordingClient(seriesEpisodes: const []);
final manager = _StubManager(client);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(serverProvider.dispose);
final currentClone = storedCurrent.copyWith(viewOffsetMs: 42);
AdjacentEpisodes? result;
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
],
child: _ProbeWidget(metadata: currentClone, onResult: (r) => result = r),
),
);
await tester.pump();
await tester.pump();
expect(client.seriesQueueCalls, isEmpty);
expect(playback.isQueueActive, isTrue);
expect(playback.shuffleContextKey, 'collection-movies');
expect(playback.currentQueueItem, same(storedCurrent));
expect(playback.currentPlayQueueItemID, 1);
expect(playback.loadedItems, [previous, storedCurrent, next]);
expect(result, isNotNull);
expect(result!.next, same(next));
expect(result!.previous, same(previous));
expect(result!.hasNext, isTrue);
expect(result!.hasPrevious, isTrue);
});
testWidgets('extends a Plex movie queue window for adjacency', (tester) async {
final previous = _plexMovie('movie-1', 1001);
final current = _plexMovie('movie-2', 1002);
final next = _plexMovie('movie-3', 1003);
final playback = PlaybackStateProvider();
addTearDown(playback.dispose);
await playback.setPlaybackFromPlayQueue(
PlayQueueResponse(
playQueueID: 77,
playQueueSelectedItemID: 1002,
playQueueShuffled: false,
playQueueTotalCount: 3,
playQueueVersion: 1,
items: [current],
),
'collection-movies',
);
var windowFetches = 0;
String? requestedCenter;
playback.setPlayQueueWindowFetcher((playQueueId, {center, window = 50}) async {
windowFetches++;
requestedCenter = center;
expect(playQueueId, 77);
return PlayQueueResponse(
playQueueID: playQueueId,
playQueueSelectedItemID: 1002,
playQueueShuffled: false,
playQueueTotalCount: 3,
playQueueVersion: 2,
items: [previous, current, next],
);
});
final manager = _StubManager(null);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(serverProvider.dispose);
AdjacentEpisodes? result;
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
],
child: _ProbeWidget(metadata: current, onResult: (r) => result = r),
),
);
await tester.pump();
await tester.pump();
expect(windowFetches, 1);
expect(requestedCenter, '1002');
expect(playback.isQueueActive, isTrue);
expect(playback.playQueueId, 77);
expect(playback.currentQueueItem, same(current));
expect(playback.currentPlayQueueItemID, 1002);
expect(playback.loadedItems, [previous, current, next]);
expect(result, isNotNull);
expect(result!.nextStatus, QueueNavigationStatus.found);
expect(result!.previousStatus, QueueNavigationStatus.found);
expect(result!.next, same(next));
expect(result!.previous, same(previous));
expect(result!.hasNext, isTrue);
expect(result!.hasPrevious, isTrue);
});
testWidgets('does not use an unrelated active queue for a standalone movie', (tester) async {
final queuedPrevious = _jfMovie('queued-1');
final queuedCurrent = _jfMovie('queued-2');
final unrelated = _jfMovie('unrelated');
final playback = PlaybackStateProvider();
addTearDown(playback.dispose);
playback.setPlaybackFromLocalQueue(
LocalPlayQueue(
id: 'jellyfin:stale-playlist',
items: [queuedPrevious, queuedCurrent],
currentIndex: 1,
backendId: MediaBackend.jellyfin.id,
),
contextKey: 'stale-playlist',
);
final client = _RecordingClient(seriesEpisodes: const []);
final manager = _StubManager(client);
final serverProvider = MultiServerProvider(manager, DataAggregationService(manager));
addTearDown(serverProvider.dispose);
AdjacentEpisodes? result;
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
],
child: _ProbeWidget(metadata: unrelated, onResult: (r) => result = r),
),
);
await tester.pump();
await tester.pump();
expect(client.seriesQueueCalls, isEmpty);
expect(playback.isQueueActive, isTrue);
expect(playback.shuffleContextKey, 'stale-playlist');
expect(playback.currentQueueItem, same(queuedCurrent));
expect(playback.currentPlayQueueItemID, 1);
expect(playback.loadedItems, [queuedPrevious, queuedCurrent]);
expect(result, isNotNull);
expect(result!.nextStatus, QueueNavigationStatus.failed);
expect(result!.previousStatus, QueueNavigationStatus.failed);
expect(result!.hasNext, isFalse);
expect(result!.hasPrevious, isFalse);
});
testWidgets('catches downstream exceptions and reports failed adjacency', (tester) async {
// Required providers are absent, so context.read throws. The service
// converts the exception into an explicit failed result.
@@ -36,6 +36,22 @@ MediaItem _track(String id) => testMediaItem(
String _urlFor(MediaItem track) => 'fake://${track.id}';
class _CallGate {
final Completer<void> _entered = Completer<void>();
final Completer<void> _released = Completer<void>();
Future<void> get entered => _entered.future;
Future<void> block() {
if (!_entered.isCompleted) _entered.complete();
return _released.future;
}
void release() {
if (!_released.isCompleted) _released.complete();
}
}
/// In-memory audio player: records calls, exposes manual stream controllers
/// so tests drive transitions/completion/errors deterministically.
class FakePlayer implements Player {
@@ -91,6 +107,7 @@ class FakePlayer implements Player {
final List<double> volumes = [];
Completer<void>? playGate;
Completer<void>? pauseGate;
final List<_CallGate> _openGates = [];
/// Arming these URIs throws, simulating a native setNext failure.
final Set<String> failingSetNextUris = {};
@@ -104,9 +121,15 @@ class FakePlayer implements Player {
/// consumed by an auto-advance ([emitTransition]).
Media? get armed => _armedMedia;
_CallGate _gateNextOpen() {
final gate = _CallGate();
_openGates.add(gate);
return gate;
}
void emitTransition(String uri) {
_armedMedia = null; // the backend advanced into the armed entry
_state = _state.copyWith(position: Duration.zero, duration: _trackDuration);
_state = _state.copyWith(completed: false, position: Duration.zero, duration: _trackDuration);
trackTransitionCtrl.add(uri);
}
@@ -115,6 +138,16 @@ class FakePlayer implements Player {
completedCtrl.add(true);
}
/// Emits the real boundary contract: completed always pulses, but a
/// transition can follow only when the native playlist still has an arm.
bool emitNaturalBoundary() {
final armed = _armedMedia;
emitCompleted();
if (armed == null) return false;
emitTransition(armed.uri);
return true;
}
void emitError(String message) => errorCtrl.add(PlayerError(message));
void setPosition(Duration position) {
@@ -171,6 +204,11 @@ class FakePlayer implements Player {
List<SubtitleTrack>? externalSubtitles,
Duration? timelineDuration,
}) async {
final gate = _openGates.isEmpty ? null : _openGates.removeAt(0);
if (gate != null) await gate.block();
// PlayerNative.open uses loadfile replace, which drops any armed entry.
// Clear before recording the committed replacement open.
_armedMedia = null;
openedUris.add(media.uri);
_state = _state.copyWith(playing: play, completed: false, position: Duration.zero, duration: _trackDuration);
if (play) playingCtrl.add(true);
@@ -431,15 +469,24 @@ class FakeMusicSourceResolver implements MusicSourceResolver {
final MediaServerClient? client;
final Set<String> failingIds = {};
final Map<String, int> resolveCounts = {};
final Map<String, Completer<void>> resolveGates = {};
final Map<String, List<_CallGate>> _resolveGates = {};
/// Per-track URL overrides (e.g. content:// shapes for offline tracks).
final Map<String, String> urlOverrides = {};
_CallGate _gateNextResolve(String trackId) {
final gate = _CallGate();
(_resolveGates[trackId] ??= []).add(gate);
return gate;
}
@override
Future<MusicSource> resolve(MediaItem track) async {
resolveCounts[track.id] = (resolveCounts[track.id] ?? 0) + 1;
await resolveGates[track.id]?.future;
final gates = _resolveGates[track.id];
final gate = gates == null || gates.isEmpty ? null : gates.removeAt(0);
if (gates?.isEmpty ?? false) _resolveGates.remove(track.id);
if (gate != null) await gate.block();
if (failingIds.contains(track.id)) {
throw StateError('resolve failed for ${track.id}');
}
@@ -712,32 +759,137 @@ void main() {
});
test('a superseded slow gapless resolve cannot overwrite the newly requested arm', () async {
final oldArmGate = Completer<void>();
h.resolver.resolveGates[t2.id] = oldArmGate;
final oldArmGate = h.resolver._gateNextResolve(t2.id);
await h.playTracks([t1, t2]);
await oldArmGate.entered;
expect(h.player.armed, isNull);
h.service.addNext([t3]);
oldArmGate.complete();
oldArmGate.release();
await pumpEventQueue();
expect(h.player.armed?.uri, _urlFor(t3));
});
test('end-of-track sleep invalidates a slow gapless resolve', () async {
final armGate = Completer<void>();
h.resolver.resolveGates[t2.id] = armGate;
final armGate = h.resolver._gateNextResolve(t2.id);
await h.playTracks([t1, t2]);
await armGate.entered;
h.service.setSleepTimer(null, endOfTrack: true);
armGate.complete();
armGate.release();
await pumpEventQueue();
expect(h.service.sleepTimerEndOfTrack, isTrue);
expect(h.player.armed, isNull);
});
test('manual advance cancels a blocked arm until replacement open commits', () async {
final staleArmGate = h.resolver._gateNextResolve(t2.id);
final replacementResolveGate = h.resolver._gateNextResolve(t2.id);
await h.playTracks([t1, t2, t3]);
await staleArmGate.entered;
final replacementOpenGate = h.player._gateNextOpen();
final advance = h.service.next();
await replacementResolveGate.entered;
replacementResolveGate.release();
await replacementOpenGate.entered;
staleArmGate.release();
await pumpEventQueue();
expect(h.resolver.resolveCounts[t3.id], isNull);
expect(h.player.armed, isNull);
expect(h.player.openedUris, [_urlFor(t1)]);
replacementOpenGate.release();
await advance;
await pumpEventQueue();
expect(h.service.currentTrack?.id, t2.id);
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)]);
expect(h.resolver.resolveCounts[t3.id], 1);
expect(h.player.armed?.uri, _urlFor(t3));
expect(h.player.emitNaturalBoundary(), isTrue);
await pumpEventQueue();
expect(h.service.currentTrack?.id, t3.id);
expect(h.service.currentIndex, 2);
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)], reason: 'C must advance from the native arm');
});
test('queue edits during a replacement open coalesce into the latest post-open arm', () async {
final t4 = _track('t4');
final t5 = _track('t5');
await h.playTracks([t1, t2, t3]);
final replacementOpenGate = h.player._gateNextOpen();
final advance = h.service.next();
await replacementOpenGate.entered;
final latestArmGate = h.resolver._gateNextResolve(t5.id);
h.service.addNext([t4]);
h.service.addNext([t5]);
await pumpEventQueue();
expect(h.resolver.resolveCounts[t4.id], isNull);
expect(h.resolver.resolveCounts[t5.id], isNull);
expect(h.player.armed, isNull);
replacementOpenGate.release();
await advance;
await latestArmGate.entered;
expect(h.resolver.resolveCounts[t4.id], isNull);
expect(h.resolver.resolveCounts[t5.id], 1);
expect(h.player.armed, isNull);
latestArmGate.release();
await pumpEventQueue();
expect(h.service.queue.map((track) => track.id), [t1.id, t2.id, t5.id, t4.id, t3.id]);
expect(h.player.armed?.uri, _urlFor(t5));
expect(h.player.emitNaturalBoundary(), isTrue);
await pumpEventQueue();
expect(h.service.currentTrack?.id, t5.id);
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)]);
});
test('completed fallback cancels a blocked arm until replacement open commits', () async {
final staleArmGate = h.resolver._gateNextResolve(t2.id);
final replacementResolveGate = h.resolver._gateNextResolve(t2.id);
await h.playTracks([t1, t2, t3]);
await staleArmGate.entered;
final replacementOpenGate = h.player._gateNextOpen();
h.player.emitCompleted();
await replacementResolveGate.entered;
replacementResolveGate.release();
await replacementOpenGate.entered;
staleArmGate.release();
await pumpEventQueue();
expect(h.resolver.resolveCounts[t3.id], isNull);
expect(h.player.armed, isNull);
replacementOpenGate.release();
await pumpEventQueue();
expect(h.service.currentTrack?.id, t2.id);
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)]);
expect(h.player.armed?.uri, _urlFor(t3));
expect(h.player.emitNaturalBoundary(), isTrue);
await pumpEventQueue();
expect(h.service.currentTrack?.id, t3.id);
expect(h.player.openedUris, [_urlFor(t1), _urlFor(t2)]);
});
test('a slow instant mix cannot replace a newer explicit queue', () async {
final mixGate = Completer<List<MediaItem>>();
h.client.instantMixGate = mixGate;
@@ -941,6 +1093,23 @@ void main() {
expect(h.client.reportsFor('stopped').map((r) => r.itemId), ['t1']);
});
test('failed explicit resume does not publish playing status', () async {
await h.playTracks([t1]);
await h.service.pause();
final gate = Completer<void>();
final denied = StateError('audio focus denied');
h.player.playGate = gate;
final resume = h.service.play();
expect(h.service.status, MusicPlaybackStatus.paused);
final failureExpectation = expectLater(resume, throwsA(same(denied)));
gate.completeError(denied, StackTrace.current);
await failureExpectation;
expect(h.player.playCalls, 1);
expect(h.service.status, MusicPlaybackStatus.paused);
});
test('a late play completion cannot revive a stopped session', () async {
await h.playTracks([t1]);
await h.service.pause();
@@ -34,6 +34,232 @@ void main() {
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) =>
testPlexClient(serverId: publicServerId, profileScopeId: defaultProfileScopeId, handler: handler);
group('Plex item lookup HTTP/cache contract', () {
const jsonHeaders = {'content-type': 'application/json'};
final apis = [(name: 'fetchItem', includeOnDeck: false), (name: 'fetchItemWithOnDeck', includeOnDeck: true)];
String endpointFor(String id) => '/library/metadata/$id';
Map<String, dynamic> itemResponse(String id, {String title = 'Cached item'}) => {
'MediaContainer': {
'librarySectionID': 7,
'librarySectionTitle': 'Movies',
'Metadata': [
{'ratingKey': id, 'type': 'movie', 'title': title, 'summary': 'Cached summary'},
],
},
};
http.Response jsonResponse(Object? body, int statusCode) =>
http.Response(jsonEncode(body), statusCode, headers: jsonHeaders);
Future<Map<String, dynamic>> seedItem(String id, {String title = 'Cached item'}) async {
final response = itemResponse(id, title: title);
await PlexApiCache.instance.put(defaultProfileScopeId.cacheServerId, endpointFor(id), response);
return response;
}
Future<({MediaItem? item, MediaItem? onDeckEpisode})> lookup(
PlexClient client,
String id, {
required bool includeOnDeck,
}) async {
if (includeOnDeck) {
final result = await client.fetchItemWithOnDeck(id);
return result;
}
return (item: await client.fetchItem(id), onDeckEpisode: null);
}
for (final api in apis) {
for (final cacheState in [(name: 'uncached', seed: false), (name: 'cached', seed: true)]) {
test('${api.name} maps a ${cacheState.name} HTTP 404 to no item', () async {
final id = '${api.name}-${cacheState.name}-404';
Map<String, dynamic>? seededResponse;
if (cacheState.seed) {
seededResponse = await seedItem(id);
}
var requestCount = 0;
final client = makeClient((_) async {
requestCount++;
return jsonResponse({
'MediaContainer': {
'Metadata': [
{'ratingKey': 'must-not-parse', 'type': 'movie', 'title': 'Must Not Parse'},
],
},
}, 404);
});
addTearDown(client.close);
final result = await lookup(client, id, includeOnDeck: api.includeOnDeck);
expect(result.item, isNull);
expect(result.onDeckEpisode, isNull);
expect(requestCount, 1);
final cached = await PlexApiCache.instance.get(defaultProfileScopeId.cacheServerId, endpointFor(id));
if (cacheState.seed) {
expect(cached, seededResponse);
} else {
expect(cached, isNull);
}
});
}
for (final statusCode in [401, 403, 500]) {
test('${api.name} propagates cached HTTP $statusCode instead of serving stale metadata', () async {
final id = '${api.name}-cached-$statusCode';
final seededResponse = await seedItem(id);
var requestCount = 0;
final client = makeClient((_) async {
requestCount++;
return jsonResponse(itemResponse('must-not-parse', title: 'Must Not Parse'), statusCode);
});
addTearDown(client.close);
await expectLater(
lookup(client, id, includeOnDeck: api.includeOnDeck),
throwsA(
isA<MediaServerHttpException>()
.having((error) => error.statusCode, 'statusCode', statusCode)
.having((error) => error.requestUri?.path, 'request path', endpointFor(id)),
),
);
expect(requestCount, 1);
expect(await PlexApiCache.instance.get(defaultProfileScopeId.cacheServerId, endpointFor(id)), seededResponse);
});
}
test('${api.name} serves cached metadata after a transient timeout', () async {
final id = '${api.name}-cached-timeout';
await seedItem(id);
var requestCount = 0;
final client = makeClient((_) {
requestCount++;
return Future<http.Response>.error(TimeoutException('item lookup timed out'));
});
addTearDown(client.close);
final result = await lookup(client, id, includeOnDeck: api.includeOnDeck);
expect(result.item, isNotNull);
expect(result.item!.id, id);
expect(result.item!.title, 'Cached item');
expect(result.onDeckEpisode, isNull);
expect(requestCount, 1);
});
test('${api.name} propagates a transient timeout when no cache row exists', () async {
final id = '${api.name}-uncached-timeout';
var requestCount = 0;
final client = makeClient((_) {
requestCount++;
return Future<http.Response>.error(TimeoutException('item lookup timed out'));
});
addTearDown(client.close);
await expectLater(
lookup(client, id, includeOnDeck: api.includeOnDeck),
throwsA(
isA<MediaServerHttpException>()
.having((error) => error.type, 'type', MediaServerHttpErrorType.connectionTimeout)
.having((error) => error.isTransient, 'isTransient', isTrue),
),
);
expect(requestCount, 1);
});
test('${api.name} propagates cancellation despite cached metadata', () async {
final id = '${api.name}-cached-cancelled';
final seededResponse = await seedItem(id);
var requestCount = 0;
final client = makeClient((request) {
requestCount++;
return Future<http.Response>.error(http.RequestAbortedException(request.url));
});
addTearDown(client.close);
await expectLater(
lookup(client, id, includeOnDeck: api.includeOnDeck),
throwsA(
isA<MediaServerHttpException>()
.having((error) => error.isCancellation, 'isCancellation', isTrue)
.having((error) => error.requestUri?.path, 'request path', endpointFor(id)),
),
);
expect(requestCount, 1);
expect(await PlexApiCache.instance.get(defaultProfileScopeId.cacheServerId, endpointFor(id)), seededResponse);
});
test('${api.name} propagates malformed JSON despite cached metadata', () async {
final id = '${api.name}-cached-malformed-json';
final seededResponse = await seedItem(id);
var requestCount = 0;
final client = makeClient((_) async {
requestCount++;
return http.Response('{', 200, headers: jsonHeaders);
});
addTearDown(client.close);
await expectLater(
lookup(client, id, includeOnDeck: api.includeOnDeck),
throwsA(
isA<MediaServerHttpException>()
.having((error) => error.type, 'type', MediaServerHttpErrorType.unknown)
.having((error) => error.statusCode, 'statusCode', 200),
),
);
expect(requestCount, 1);
expect(await PlexApiCache.instance.get(defaultProfileScopeId.cacheServerId, endpointFor(id)), seededResponse);
});
test('${api.name} propagates a valid JSON payload rejected by the item DTO', () async {
final id = '${api.name}-cached-unmappable';
await seedItem(id);
var requestCount = 0;
final client = makeClient((_) async {
requestCount++;
return jsonResponse({
'MediaContainer': {
'Metadata': [
{'ratingKey': id, 'type': 'movie', 'title': 7},
],
},
}, 200);
});
addTearDown(client.close);
await expectLater(lookup(client, id, includeOnDeck: api.includeOnDeck), throwsA(isA<TypeError>()));
expect(requestCount, 1);
});
test('${api.name} uses profile-scoped cache without HTTP while explicitly offline', () async {
final id = '${api.name}-offline';
await seedItem(id);
var requestCount = 0;
final client = makeClient((_) async {
requestCount++;
return jsonResponse(const {'mustNotReachNetwork': true}, 500);
});
addTearDown(client.close);
client.setOfflineMode(true);
final result = await lookup(client, id, includeOnDeck: api.includeOnDeck);
expect(result.item, isNotNull);
expect(result.item!.id, id);
expect(result.item!.title, 'Cached item');
expect(result.onDeckEpisode, isNull);
expect(requestCount, 0);
});
}
});
group('Plex mutation result families', () {
test('void mutation completes on success and preserves status/transport failures', () async {
final item = testMediaItem(
+4 -2
View File
@@ -171,7 +171,7 @@ void main() {
final shaders = await ShaderAssetLoader.getShadersForPreset(customPreset(storedName));
expect(shaders, hasLength(1));
expect(path.dirname(shaders.single), path.join(supportDirectory.path, 'custom_shaders'));
expect(path.equals(path.dirname(shaders.single), path.join(supportDirectory.path, 'custom_shaders')), isTrue);
expect(await File(shaders.single).readAsString(), 'shader');
await ShaderAssetLoader.deleteCustomShader(storedName);
@@ -185,7 +185,9 @@ void main() {
final managedFile = File(path.join(customDirectory.path, storedName))..writeAsStringSync('legacy');
expect(ShaderAssetLoader.isValidCustomShaderFileName(storedName), isTrue);
expect(await ShaderAssetLoader.getShadersForPreset(customPreset(storedName)), [managedFile.path]);
final shaders = await ShaderAssetLoader.getShadersForPreset(customPreset(storedName));
expect(shaders, hasLength(1));
expect(path.equals(shaders.single, managedFile.path), isTrue);
await ShaderAssetLoader.deleteCustomShader(storedName);
expect(managedFile.existsSync(), isFalse);
+82 -3
View File
@@ -178,6 +178,15 @@ MediaSourceInfo _mediaInfoWithSubtitles({bool selected = false}) {
);
}
MediaSourceInfo _metadataFreeDirectMediaInfo({bool selected = true}) {
return MediaSourceInfo(
videoUrl: 'https://example.com/video.mp4',
audioTracks: [MediaAudioTrack(id: 1, languageCode: 'eng', selected: true)],
subtitleTracks: [MediaSubtitleTrack(id: 20, codec: 'ass', selected: selected, forced: false)],
chapters: const [],
);
}
Future<void> _drainAsync() async {
for (var i = 0; i < 5; i++) {
await Future<void>.delayed(Duration.zero);
@@ -373,6 +382,58 @@ void main() {
expect(player.selectedSubtitle.single.id, 'no');
});
test('complete metadata-free direct catalog applies tracks without the five-second fallback', () async {
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.defaultPlaybackSpeed, 1.5);
fakeAsync((async) {
final player = _FakePlayer(
tracks: const Tracks(
audio: [AudioTrack(id: 'native-audio', language: 'eng')],
subtitle: [SubtitleTrack(id: 'native-ass', codec: 'ass')],
),
);
final mgr = _make(
player: player,
mediaInfo: _metadataFreeDirectMediaInfo(),
preferredSubtitleTrack: const SubtitleTrack(id: 'source:20', codec: 'ass'),
);
mgr.applyTrackSelectionWhenReady();
async.flushMicrotasks();
expect(player.selectedAudio.map((track) => track.id), ['native-audio']);
expect(player.selectedSubtitle.map((track) => track.id), ['native-ass']);
expect(player.rates, [1.5]);
expect(async.nonPeriodicTimerCount, 0);
mgr.dispose();
});
});
test('server-selected metadata-free direct catalog applies without the five-second fallback', () async {
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.defaultPlaybackSpeed, 1.25);
fakeAsync((async) {
final player = _FakePlayer(
tracks: const Tracks(
audio: [AudioTrack(id: 'native-audio', language: 'eng')],
subtitle: [SubtitleTrack(id: 'native-ass', codec: 'ass')],
),
);
final mgr = _make(player: player, mediaInfo: _metadataFreeDirectMediaInfo());
mgr.applyTrackSelectionWhenReady();
async.flushMicrotasks();
expect(player.selectedAudio.map((track) => track.id), ['native-audio']);
expect(player.selectedSubtitle.map((track) => track.id), ['native-ass']);
expect(player.rates, [1.25]);
expect(async.nonPeriodicTimerCount, 0);
mgr.dispose();
});
});
test('waits through a partial catalog until the selected Plex subtitle arrives', () async {
await SettingsService.getInstance();
final player = _FakePlayer(
@@ -380,7 +441,16 @@ void main() {
audio: [AudioTrack(id: '1', language: 'eng')],
),
);
final mgr = _make(player: player, mediaInfo: _mediaInfoWithSubtitles(selected: true));
final mediaInfo = MediaSourceInfo(
videoUrl: 'https://example.com/video.mp4',
audioTracks: [MediaAudioTrack(id: 1, languageCode: 'eng', selected: true)],
subtitleTracks: [
MediaSubtitleTrack(id: 10, languageCode: 'eng', selected: true, forced: false),
MediaSubtitleTrack(id: 11, languageCode: 'fre', selected: false, forced: false),
],
chapters: const [],
);
final mgr = _make(player: player, mediaInfo: mediaInfo);
addTearDown(mgr.dispose);
mgr.applyTrackSelectionWhenReady();
@@ -414,10 +484,19 @@ void main() {
audio: [AudioTrack(id: '1', language: 'eng')],
),
);
final mediaInfo = MediaSourceInfo(
videoUrl: 'https://example.com/video.mp4',
audioTracks: [MediaAudioTrack(id: 1, languageCode: 'eng', selected: true)],
subtitleTracks: [
MediaSubtitleTrack(id: 10, languageCode: 'eng', selected: true, forced: false),
MediaSubtitleTrack(id: 11, languageCode: 'fre', selected: false, forced: false),
],
chapters: const [],
);
final mgr = _make(
player: player,
mediaInfo: _mediaInfoWithSubtitles(selected: true),
preferredSubtitleTrack: const SubtitleTrack(id: 'previous', language: 'fre'),
mediaInfo: mediaInfo,
preferredSubtitleTrack: const SubtitleTrack(id: 'source:11', language: 'fre'),
);
addTearDown(mgr.dispose);
@@ -31,10 +31,10 @@ import '../test_helpers/media_items.dart';
// Plex-selected, Plex-server-explicit-no-subtitles, default fallback,
// and the off-by-default branch.
//
// Top-level matching helpers (`findMpvTrackForPlexAudio`,
// `findPlexTrackForMpvAudio`, `findMpvTrackForPlexSubtitle`,
// `findPlexTrackForMpvSubtitle`) are exercised indirectly through
// `selectAudioTrack` (Priority 2) and `selectSubtitleTrack` (Priority 2).
// Top-level subtitle matching helpers are exercised directly for complete,
// partial, unique, ambiguous, and container catalogs. Audio helpers are
// exercised through `selectAudioTrack` (Priority 2) and their focused
// disambiguation tests below.
//
// What's NOT covered:
// - `selectAndApplyTracks` — depends on a real Player + SettingsService
@@ -478,6 +478,86 @@ void main() {
expect(result.track.language, 'fre');
});
test('complete metadata-free direct catalog selects its unique native subtitle', () {
final sourceTrack = _plexSub(20, codec: 'ass', selected: true);
final info = _info(subs: [sourceTrack]);
final nativeTrack = _sub('native-ass', codec: 'ass');
final service = _svc(info: info);
final preferredResult = service.selectSubtitleTrack(
[nativeTrack],
const SubtitleTrack(id: 'source:20', codec: 'ass'),
null,
)!;
final serverResult = service.selectSubtitleTrack([nativeTrack], null, null)!;
expect(preferredResult.priority, TrackSelectionPriority.navigation);
expect(preferredResult.track, same(nativeTrack));
expect(serverResult.priority, TrackSelectionPriority.serverSelected);
expect(serverResult.track, same(nativeTrack));
expect(findMpvTrackForPlexSubtitle(sourceTrack, [nativeTrack], allPlexTracks: [sourceTrack]), same(nativeTrack));
expect(findPlexTrackForMpvSubtitle(nativeTrack, [sourceTrack], allMpvTracks: [nativeTrack]), same(sourceTrack));
});
test('complete low-metadata direct catalog uses facts instead of ordinal order', () {
final sourceAss = _plexSub(30, codec: 'ass', selected: true);
final sourceSrt = _plexSub(31, codec: 'srt');
final plexTracks = [sourceAss, sourceSrt];
final nativeSrt = _sub('native-srt', codec: 'srt');
final nativeAss = _sub('native-ass', codec: 'ass');
final nativeTracks = [nativeSrt, nativeAss];
expect(findMpvTrackForPlexSubtitle(sourceAss, nativeTracks, allPlexTracks: plexTracks), same(nativeAss));
expect(findPlexTrackForMpvSubtitle(nativeAss, plexTracks, allMpvTracks: nativeTracks), same(sourceAss));
});
test('ambiguous metadata-free direct catalog does not use ordinal fallback', () {
final selectedSource = _plexSub(40, codec: 'ass', selected: true);
final otherSource = _plexSub(41, codec: 'ass');
final plexTracks = [selectedSource, otherSource];
final nativeTracks = [_sub('native-second', codec: 'ass'), _sub('native-first', codec: 'ass')];
final service = _svc(info: _info(subs: plexTracks));
expect(findMpvTrackForPlexSubtitle(selectedSource, nativeTracks, allPlexTracks: plexTracks), isNull);
expect(findPlexTrackForMpvSubtitle(nativeTracks.first, plexTracks, allMpvTracks: nativeTracks), isNull);
final preferredResult = service.selectSubtitleTrack(
nativeTracks,
const SubtitleTrack(id: 'source:40', codec: 'ass'),
null,
)!;
final serverResult = service.selectSubtitleTrack(nativeTracks, null, null)!;
expect(preferredResult.priority, TrackSelectionPriority.off);
expect(preferredResult.track.id, SubtitleTrack.off.id);
expect(serverResult.priority, TrackSelectionPriority.off);
expect(serverResult.track.id, SubtitleTrack.off.id);
});
test('ambiguous complete direct catalog falls through to native default', () {
final plexTracks = [_plexSub(50, codec: 'ass', selected: true), _plexSub(51, codec: 'ass')];
final nativeTracks = [_sub('native-first', codec: 'ass'), _sub('native-default', codec: 'ass', isDefault: true)];
final result = _svc(
info: _info(subs: plexTracks),
).selectSubtitleTrack(nativeTracks, const SubtitleTrack(id: 'source:50', codec: 'ass'), null)!;
expect(result.priority, TrackSelectionPriority.defaultTrack);
expect(result.track.id, 'native-default');
});
test('partial metadata-free direct catalog remains pending', () {
final plexTracks = [_plexSub(60, codec: 'ass', selected: true), _plexSub(61, codec: 'ass')];
final nativeTracks = [_sub('native-only', codec: 'ass', isDefault: true)];
final service = _svc(info: _info(subs: plexTracks));
expect(
service.selectSubtitleTrack(nativeTracks, const SubtitleTrack(id: 'source:60', codec: 'ass'), null),
isNull,
);
expect(service.selectSubtitleTrack(nativeTracks, null, null), isNull);
});
test('partial native catalog stays undetermined until the selected Plex track arrives', () {
final info = _info(
subs: [
+119 -27
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:io';
import 'package:drift/drift.dart' show ApplyInterceptor, QueryExecutor, QueryExecutorUser, QueryInterceptor;
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -10,6 +11,35 @@ import 'package:plezy/database/tvos_database_recovery_store.dart';
import 'package:plezy/main.dart';
import 'package:plezy/media/ids.dart';
import 'package:plezy/models/download_models.dart';
import 'package:plezy/services/base_shared_preferences_service.dart';
import 'test_helpers/prefs.dart';
final class _OpenTrackingInterceptor extends QueryInterceptor {
_OpenTrackingInterceptor({this.failure});
final Object? failure;
var ensureOpenCalls = 0;
var ensureOpenCompleted = false;
var closed = false;
@override
Future<bool> ensureOpen(QueryExecutor executor, QueryExecutorUser user) async {
ensureOpenCalls++;
final failure = this.failure;
if (failure != null) throw failure;
final result = await executor.ensureOpen(user);
ensureOpenCompleted = true;
return result;
}
@override
Future<void> close(QueryExecutor inner) async {
await inner.close();
closed = true;
}
}
void main() {
testWidgets('renders a Flutter frame before starting the initialization gate', (tester) async {
@@ -112,40 +142,104 @@ void main() {
expect(find.text('ready 9'), findsNothing);
});
test('storage-full database open discards native work before retrying', () async {
final database = AppDatabase.forTesting(NativeDatabase.memory());
addTearDown(database.close);
await database.insertDownload(
test('storage-full lazy database open discards native work before retrying', () async {
resetSharedPreferencesForTest();
final tempDir = await Directory.systemTemp.createTemp('plezy_startup_storage_full_');
final file = File('${tempDir.path}/plezy_downloads.db');
final prefs = await BaseSharedPreferencesService.sharedCache();
final failedOpen = _OpenTrackingInterceptor(
failure: const FileSystemException('write failed: No space left on device'),
);
final successfulOpen = _OpenTrackingInterceptor();
AppDatabase? seeded;
AppDatabase? resultDatabase;
try {
seeded = AppDatabase.forTesting(NativeDatabase(file));
await seeded.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'active',
globalKey: 'srv:active',
type: 'movie',
status: DownloadStatus.downloading.index,
);
await database.updateBgTaskId('srv:active', 'native-task');
await database.addToQueue(mediaGlobalKey: 'srv:active');
await database.insertDownload(
await seeded.updateBgTaskId('srv:active', 'native-task');
await seeded.addToQueue(mediaGlobalKey: 'srv:active');
await seeded.insertDownload(
serverId: ServerId('srv'),
ratingKey: 'complete',
globalKey: 'srv:complete',
type: 'movie',
status: DownloadStatus.completed.index,
);
await seeded.close();
seeded = null;
var openAttempts = 0;
var recoveries = 0;
final bootstrap = AppDatabaseBootstrap(
database: database,
recoveryOutcome: TvosDatabaseRecoveryOutcome.notApplicable,
);
final result = await openAppDatabaseWithDownloadRecovery(
openDatabase: () async {
final bootstrap = await openAppDatabaseWithDownloadRecovery(
openDatabase: () {
return AppDatabase.open(
isTvos: false,
databaseFile: file,
preferences: prefs,
executorFactory: (databaseFile) {
openAttempts++;
if (openAttempts == 1) {
throw const FileSystemException('write failed: No space left on device');
final interceptor = openAttempts == 1 ? failedOpen : successfulOpen;
return NativeDatabase(databaseFile).interceptWith(interceptor);
},
);
},
recoverNativeDownloads: () async {
expect(failedOpen.closed, isTrue);
recoveries++;
},
storageFullMessage: 'Storage full',
);
resultDatabase = bootstrap.database;
final active = await resultDatabase.getDownloadedMedia('srv:active');
final complete = await resultDatabase.getDownloadedMedia('srv:complete');
expect(bootstrap.recoveryOutcome, TvosDatabaseRecoveryOutcome.notApplicable);
expect(openAttempts, 2);
expect(recoveries, 1);
expect(failedOpen.ensureOpenCalls, 1);
expect(successfulOpen.ensureOpenCalls, greaterThanOrEqualTo(1));
expect(successfulOpen.ensureOpenCompleted, isTrue);
expect(active?.status, DownloadStatus.failed.index);
expect(active?.bgTaskId, isNull);
expect(active?.errorMessage, 'Storage full');
expect(complete?.status, DownloadStatus.completed.index);
expect(await resultDatabase.select(resultDatabase.downloadQueue).get(), isEmpty);
expect(await resultDatabase.customSelect('SELECT 1').get(), isNotEmpty);
} finally {
await resultDatabase?.close();
await seeded?.close();
await tempDir.delete(recursive: true);
}
return bootstrap;
});
test('non-storage lazy database-open errors bypass download recovery', () async {
resetSharedPreferencesForTest();
final tempDir = await Directory.systemTemp.createTemp('plezy_startup_open_error_');
final file = File('${tempDir.path}/plezy_downloads.db');
final prefs = await BaseSharedPreferencesService.sharedCache();
final error = StateError('injected database setup failure');
final failedOpen = _OpenTrackingInterceptor(failure: error);
var openAttempts = 0;
var recoveries = 0;
try {
final open = openAppDatabaseWithDownloadRecovery(
openDatabase: () {
return AppDatabase.open(
isTvos: false,
databaseFile: file,
preferences: prefs,
executorFactory: (databaseFile) {
openAttempts++;
return NativeDatabase(databaseFile).interceptWith(failedOpen);
},
);
},
recoverNativeDownloads: () async {
recoveries++;
@@ -153,15 +247,13 @@ void main() {
storageFullMessage: 'Storage full',
);
final active = await database.getDownloadedMedia('srv:active');
final complete = await database.getDownloadedMedia('srv:complete');
expect(result, same(bootstrap));
expect(openAttempts, 2);
expect(recoveries, 1);
expect(active?.status, DownloadStatus.failed.index);
expect(active?.bgTaskId, isNull);
expect(active?.errorMessage, 'Storage full');
expect(complete?.status, DownloadStatus.completed.index);
expect(await database.select(database.downloadQueue).get(), isEmpty);
await expectLater(open, throwsA(same(error)));
expect(failedOpen.ensureOpenCalls, 1);
expect(failedOpen.closed, isTrue);
expect(openAttempts, 1);
expect(recoveries, 0);
} finally {
await tempDir.delete(recursive: true);
}
});
}
+8
View File
@@ -36,9 +36,17 @@ class FakeHttpClient extends http.BaseClient {
final int statusCode;
final List<int> body;
int closeCount = 0;
bool get isClosed => closeCount != 0;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
return http.StreamedResponse(Stream<List<int>>.value(body), statusCode, request: request);
}
@override
void close() {
closeCount++;
super.close();
}
}
@@ -34,6 +34,9 @@ class FakeSyncPlayer implements Player {
/// When set, the next command throws this and clears the field.
Object? nextCommandError;
/// When set, the next command waits for this future and clears the field.
Future<void>? nextCommandFuture;
/// Simulates bitstream audio ignoring rate changes: setRate succeeds but
/// neither state nor the rate stream reflect it.
bool ignoreRateChanges = false;
@@ -92,6 +95,9 @@ class FakeSyncPlayer implements Player {
Future<void> play() async {
commandLog.add('play');
_maybeThrow();
final pending = nextCommandFuture;
nextCommandFuture = null;
if (pending != null) await pending;
if (_state.playing) return;
_state = _state.copyWith(playing: true);
_playingController.add(true);
@@ -101,6 +107,9 @@ class FakeSyncPlayer implements Player {
Future<void> pause() async {
commandLog.add('pause');
_maybeThrow();
final pending = nextCommandFuture;
nextCommandFuture = null;
if (pending != null) await pending;
if (!_state.playing) return;
_state = _state.copyWith(playing: false);
_playingController.add(false);
@@ -110,6 +119,9 @@ class FakeSyncPlayer implements Player {
Future<void> seek(Duration position) async {
commandLog.add('seek:${position.inMilliseconds}');
_maybeThrow();
final pending = nextCommandFuture;
nextCommandFuture = null;
if (pending != null) await pending;
_state = _state.copyWith(position: position);
if (emitRestartOnSeek) _playbackRestartController.add(null);
}
@@ -118,6 +130,9 @@ class FakeSyncPlayer implements Player {
Future<void> setRate(double rate) async {
commandLog.add('rate:$rate');
_maybeThrow();
final pending = nextCommandFuture;
nextCommandFuture = null;
if (pending != null) await pending;
if (ignoreRateChanges || _state.rate == rate) return;
_state = _state.copyWith(rate: rate);
_rateController.add(rate);
+33 -4
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:fake_async/fake_async.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -131,18 +133,45 @@ void main() {
});
});
test('non-recoverable PlatformException rethrows', () {
test('pending teardown NOT_INITIALIZED reports failure and fires onLost once', () {
fakeAsync((async) {
final (attached, player, lostEvents) = build(async);
final playingIntents = <bool>[];
attached.playingIntents.listen(playingIntents.add);
final pending = Completer<void>();
player.nextCommandFuture = pending.future;
bool? result;
attached.play().then((value) => result = value);
async.flushMicrotasks();
expect(result, isNull);
pending.completeError(PlatformException(code: 'NOT_INITIALIZED'));
async.flushMicrotasks();
expect(result, isFalse);
expect(lostEvents, ['lost']);
player.emitPlaying(true);
async.flushMicrotasks();
expect(playingIntents, [true], reason: 'the failed command must remove its outstanding expectation');
attached.dispose();
});
});
test('SET_PROPERTY_FAILED remains non-recoverable', () {
fakeAsync((async) {
final (attached, player, lostEvents) = build(async);
player.nextCommandError = PlatformException(code: 'SOMETHING_ELSE');
player.nextCommandError = PlatformException(code: 'SET_PROPERTY_FAILED');
Object? error;
attached.play().catchError((Object e) {
error = e;
attached.play().catchError((Object caught) {
error = caught;
return false;
});
async.flushMicrotasks();
expect(error, isA<PlatformException>());
expect((error as PlatformException).code, 'SET_PROPERTY_FAILED');
expect(lostEvents, isEmpty);
attached.dispose();
});
@@ -1,4 +1,7 @@
import 'dart:ui' show SemanticsAction, Tristate;
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart' show SemanticsNode;
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/watch_together/models/watch_session.dart';
@@ -69,6 +72,48 @@ void main() {
expect(harness.onLeaveSessionCalls, 1);
expect(tester.takeException(), isNull);
});
testWidgets('session controls announce live state and the copyable room code', (tester) async {
final semantics = tester.ensureSemantics();
final harness = _OverlayHarness(isHost: false);
addTearDown(harness.dispose);
await tester.pumpWidget(harness.build());
var finder = find.bySemanticsLabel(t.watchTogether.openSessionControls);
expect(finder, findsOneWidget);
var data = tester.getSemantics(finder).getSemanticsData();
expect(data.value, '${t.watchTogether.participants}: 1');
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isEnabled, Tristate.isTrue);
expect(data.hasAction(SemanticsAction.tap), isTrue);
expect(_semanticTapNodeCount(tester), 1);
harness.provider.updateStatus(isHost: true, isSyncing: true);
await tester.pump();
finder = find.bySemanticsLabel(t.watchTogether.openSessionControls);
data = tester.getSemantics(finder).getSemanticsData();
expect(
data.value,
['${t.watchTogether.participants}: 1', t.watchTogether.youAreHost, t.watchTogether.syncing].join(', '),
);
await tester.tap(find.byKey(_OverlayHarness.indicatorKey));
await tester.pump();
await tester.pump(const Duration(milliseconds: 250));
final codeFinder = find.bySemanticsLabel(t.watchTogether.copySessionCode);
expect(codeFinder, findsOneWidget);
data = tester.getSemantics(codeFinder).getSemanticsData();
expect(data.value, 'ROOM42');
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isEnabled, Tristate.isTrue);
expect(data.hasAction(SemanticsAction.tap), isTrue);
expect(find.bySemanticsLabel('ROOM42'), findsNothing);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump();
semantics.dispose();
});
}
Future<void> _openLeaveConfirmation(WidgetTester tester, _OverlayHarness harness) async {
@@ -118,7 +163,8 @@ class _OverlayHarness {
class _FakeWatchTogetherProvider extends WatchTogetherProvider {
_FakeWatchTogetherProvider({required this.isHostValue, this.leaveError});
final bool isHostValue;
bool isHostValue;
bool isSyncingValue = false;
final Object? leaveError;
var leaveCalls = 0;
var _isDisposing = false;
@@ -126,6 +172,9 @@ class _FakeWatchTogetherProvider extends WatchTogetherProvider {
@override
bool get isHost => isHostValue;
@override
bool get isSyncing => isSyncingValue;
@override
String? get sessionId => 'ROOM42';
@@ -140,6 +189,12 @@ class _FakeWatchTogetherProvider extends WatchTogetherProvider {
@override
int get participantCount => participants.length;
void updateStatus({required bool isHost, required bool isSyncing}) {
isHostValue = isHost;
isSyncingValue = isSyncing;
notifyListeners();
}
@override
Future<void> leaveSession() async {
if (!_isDisposing) leaveCalls++;
@@ -153,3 +208,17 @@ class _FakeWatchTogetherProvider extends WatchTogetherProvider {
super.dispose();
}
}
int _semanticTapNodeCount(WidgetTester tester) {
var count = 0;
void visit(SemanticsNode node) {
if (node.getSemanticsData().hasAction(SemanticsAction.tap)) count++;
node.visitChildren((child) {
visit(child);
return true;
});
}
visit(tester.binding.renderViews.single.owner!.semanticsOwner!.rootSemanticsNode!);
return count;
}
+73 -1
View File
@@ -1,10 +1,15 @@
import 'dart:ui' show SemanticsAction, Tristate;
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';
import 'package:flutter/semantics.dart' show SemanticsNode;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/widgets/collapsible_text.dart';
void main() {
setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
testWidgets('select expands overflowing focused text', (tester) async {
final focusNode = FocusNode(debugLabel: 'test_collapsible_text');
addTearDown(focusNode.dispose);
@@ -76,6 +81,53 @@ void main() {
semantics.dispose();
});
testWidgets('overflowing synopsis merges visible text with one expand action', (tester) async {
final semantics = tester.ensureSemantics();
const text =
'This program summary is intentionally long enough to overflow a narrow details sheet and reveal more detail.';
await tester.pumpWidget(
const MaterialApp(
home: Scaffold(
body: SizedBox(width: 120, child: CollapsibleText(text: text, maxLines: 1)),
),
),
);
final collapsedSynopsis = _visibleSynopsis(tester);
expect(collapsedSynopsis, isNotEmpty);
expect(collapsedSynopsis, isNot(text));
var annotation = find.byWidgetPredicate(
(widget) => widget is Semantics && widget.properties.label == t.accessibility.expandText,
);
expect(annotation, findsOneWidget);
var node = tester.getSemantics(annotation);
var data = node.getSemanticsData();
expect(data.label, contains(collapsedSynopsis));
expect(data.label, contains(t.accessibility.expandText));
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isEnabled, Tristate.isTrue);
expect(data.hasAction(SemanticsAction.tap), isTrue);
expect(_semanticTapNodeCount(tester), 1);
node.owner!.performAction(node.id, SemanticsAction.tap);
await tester.pump();
expect(_visibleSynopsis(tester), text);
annotation = find.byWidgetPredicate(
(widget) => widget is Semantics && widget.properties.label == t.accessibility.collapseText,
);
expect(annotation, findsOneWidget);
node = tester.getSemantics(annotation);
data = node.getSemanticsData();
expect(data.label, contains(text));
expect(data.label, contains(t.accessibility.collapseText));
expect(data.hasAction(SemanticsAction.tap), isTrue);
expect(_semanticTapNodeCount(tester), 1);
semantics.dispose();
});
testWidgets('reports whether text overflows', (tester) async {
bool? overflows;
@@ -103,3 +155,23 @@ String _collapsiblePlainText(WidgetTester tester) {
final textFinder = find.byWidgetPredicate((widget) => widget is Text && widget.textSpan != null);
return tester.widget<Text>(textFinder).textSpan!.toPlainText();
}
String _visibleSynopsis(WidgetTester tester) {
final textFinder = find.byWidgetPredicate((widget) => widget is Text && widget.textSpan != null);
final span = tester.widget<Text>(textFinder).textSpan! as TextSpan;
return (span.children!.first as TextSpan).text!;
}
int _semanticTapNodeCount(WidgetTester tester) {
var count = 0;
void visit(SemanticsNode node) {
if (node.getSemanticsData().hasAction(SemanticsAction.tap)) count++;
node.visitChildren((child) {
visit(child);
return true;
});
}
visit(tester.binding.renderViews.single.owner!.semanticsOwner!.rootSemanticsNode!);
return count;
}
+38
View File
@@ -0,0 +1,38 @@
import 'dart:ui' show SemanticsAction, Tristate;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/models/trackers/device_code.dart';
import 'package:plezy/widgets/device_code_dialog.dart';
void main() {
setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
testWidgets('copy control exposes the activation code as its value', (tester) async {
final semantics = tester.ensureSemantics();
const code = DeviceCode(
deviceCode: 'device-token',
userCode: 'ABCD-EFGH',
verificationUrl: 'https://example.com/activate',
expiresIn: 600,
interval: 5,
);
await tester.pumpWidget(
MaterialApp(
home: DeviceCodeDialog(code: code, serviceName: 'Example', onCancel: () {}),
),
);
final finder = find.bySemanticsLabel(t.services.deviceCode.copyCode);
expect(finder, findsOneWidget);
final data = tester.getSemantics(finder).getSemanticsData();
expect(data.value, code.userCode);
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isEnabled, Tristate.isTrue);
expect(data.hasAction(SemanticsAction.tap), isTrue);
expect(find.bySemanticsLabel(code.userCode), findsNothing);
semantics.dispose();
});
}
@@ -1,4 +1,7 @@
import 'dart:ui' show SemanticsAction, Tristate;
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart' show SemanticsNode;
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/theme/mono_theme.dart';
@@ -34,4 +37,46 @@ void main() {
expect(find.text('One'), findsOneWidget);
});
testWidgets('forwards a scalar value on one icon-only menu node', (tester) async {
final semantics = tester.ensureSemantics();
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: FocusablePopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
semanticLabel: 'Choose source',
semanticValue: 'Trakt',
itemBuilder: (_) => const [AppMenuItem(value: 'one', label: 'One')],
),
),
),
);
final finder = find.bySemanticsLabel('Choose source');
expect(finder, findsOneWidget);
final data = tester.getSemantics(finder).getSemanticsData();
expect(data.value, 'Trakt');
expect(data.flagsCollection.isButton, isTrue);
expect(_semanticTapNodeCount(tester), 1);
expect(data.flagsCollection.isEnabled, Tristate.isTrue);
expect(data.hasAction(SemanticsAction.tap), isTrue);
expect(find.bySemanticsLabel('Trakt'), findsNothing);
semantics.dispose();
});
}
int _semanticTapNodeCount(WidgetTester tester) {
var count = 0;
void visit(SemanticsNode node) {
if (node.getSemanticsData().hasAction(SemanticsAction.tap)) count++;
node.visitChildren((child) {
visit(child);
return true;
});
}
visit(tester.binding.renderViews.single.owner!.semanticsOwner!.rootSemanticsNode!);
return count;
}
+36
View File
@@ -169,6 +169,42 @@ void main() {
expect(saved.single!.modifiers, [HotKeyModifier.control]);
});
testWidgets('record control announces and updates its displayed chord', (tester) async {
final semantics = tester.ensureSemantics();
const initial = HotKey(key: PhysicalKeyboardKey.keyP, modifiers: [HotKeyModifier.control]);
final initialValue = [
physicalKeyLabel(PhysicalKeyboardKey.controlLeft),
physicalKeyLabel(PhysicalKeyboardKey.keyP),
].join(' + ');
expect(formatHotKeyDisplay(initial), initialValue);
await _pumpRecorder(tester, saved: <HotKey?>[], currentHotKey: initial);
Finder annotation(String label) =>
find.byWidgetPredicate((widget) => widget is Semantics && widget.properties.label == label);
var finder = annotation(t.hotkeys.pressToRecord);
expect(finder, findsOneWidget);
expect(tester.getSemantics(finder).getSemanticsData().value, initialValue);
await tester.tap(find.byType(HotKeyRecorder));
await tester.pump();
finder = annotation(t.hotkeys.recordingShortcut);
expect(finder, findsOneWidget);
expect(tester.getSemantics(finder).getSemanticsData().value, initialValue);
await tester.sendKeyEvent(LogicalKeyboardKey.keyK, physicalKey: PhysicalKeyboardKey.keyK);
await _pumpFocusChange(tester);
const updated = HotKey(key: PhysicalKeyboardKey.keyK);
final updatedValue = physicalKeyLabel(PhysicalKeyboardKey.keyK);
expect(formatHotKeyDisplay(updated), updatedValue);
finder = annotation(t.hotkeys.pressToRecord);
expect(finder, findsOneWidget);
expect(tester.getSemantics(finder).getSemanticsData().value, updatedValue);
expect(find.bySemanticsLabel(updatedValue), findsNothing);
semantics.dispose();
});
for (final entry in <(String, LogicalKeyboardKey, PhysicalKeyboardKey)>[
('Enter', LogicalKeyboardKey.enter, PhysicalKeyboardKey.enter),
('select', LogicalKeyboardKey.select, PhysicalKeyboardKey.select),
+34
View File
@@ -210,6 +210,40 @@ void main() {
expect(find.byType(IconButton), findsNWidgets(2)); // play/pause + next (mobile layout)
});
testWidgets('details control announces the current title and artist exactly once', (tester) async {
final semantics = tester.ensureSemantics();
final service = _FakeMusicService(track: _track);
final observer = MusicUiRouteObserver();
await tester.pumpWidget(wrap(service: service, observer: observer));
await tester.pumpAndSettle();
var finder = find.bySemanticsLabel('Dawn');
expect(finder, findsOneWidget);
expect(tester.getSemantics(finder).getSemanticsData().value, 'Test Artist');
expect(find.bySemanticsLabel('Test Artist'), findsNothing);
service.advanceTo(
testMediaItem(
id: 'track_2',
backend: MediaBackend.plex,
kind: MediaKind.track,
title: 'Noon',
grandparentId: 'artist_2',
grandparentTitle: 'Second Artist',
durationMs: 180000,
serverId: 'server_1',
),
);
await tester.pumpAndSettle();
finder = find.bySemanticsLabel('Noon');
expect(finder, findsOneWidget);
expect(tester.getSemantics(finder).getSemanticsData().value, 'Second Artist');
expect(find.bySemanticsLabel('Second Artist'), findsNothing);
semantics.dispose();
});
testWidgets('progress resets immediately when the current track changes', (tester) async {
final nextTrack = testMediaItem(
id: 'track_2',
+36
View File
@@ -0,0 +1,36 @@
import 'dart:ui' show SemanticsAction, Tristate;
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/services/trackers/oauth_proxy_client.dart';
import 'package:plezy/widgets/oauth_proxy_dialog.dart';
void main() {
setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
testWidgets('copy control exposes the OAuth URL as its value', (tester) async {
final semantics = tester.ensureSemantics();
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1024, 768);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.view.resetPhysicalSize);
const start = OAuthProxyStart(session: 'session-token', url: 'https://example.com/oauth/start', expiresIn: 600);
await tester.pumpWidget(
MaterialApp(
home: OAuthProxyDialog(start: start, serviceName: 'Example', onCancel: () {}),
),
);
final finder = find.bySemanticsLabel(t.services.oauthProxy.copyUrl);
expect(finder, findsOneWidget);
final data = tester.getSemantics(finder).getSemanticsData();
expect(data.value, start.url);
expect(data.flagsCollection.isButton, isTrue);
expect(data.flagsCollection.isEnabled, Tristate.isTrue);
expect(data.hasAction(SemanticsAction.tap), isTrue);
expect(find.bySemanticsLabel(start.url), findsNothing);
semantics.dispose();
});
}
+151 -12
View File
@@ -8,6 +8,7 @@ import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/services/playback_subtitle_resolver.dart';
import 'package:plezy/services/playback_initialization_types.dart';
import 'package:plezy/theme/mono_tokens.dart';
import 'package:plezy/widgets/overlay_sheet.dart';
import 'package:plezy/widgets/video_controls/models/track_controls_state.dart';
@@ -119,7 +120,8 @@ void main() {
expect(switchedChoice, const PlaybackSourceSubtitleChoice.source(0));
});
testWidgets('direct play keeps native tracks and appends unloaded source sidecars', (tester) async {
testWidgets('keeps an unloaded source sidecar selectable', (tester) async {
const remoteUri = 'https://example.test/source/remote.ass';
final player = _FakeTrackSheetPlayer(
tracks: const Tracks(
subtitle: [SubtitleTrack(id: 's1', language: 'eng', codec: 'srt')],
@@ -146,7 +148,12 @@ void main() {
),
],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
sourceSubtitleSidecarIds: const {2},
sourceSubtitleSidecars: [
PlaybackSubtitleSidecar(
sourceStreamId: 2,
track: SubtitleTrack.uri(remoteUri, title: 'Remote sidecar', codec: 'ass'),
),
],
onSwitchSubtitle: (choice) async {
switchedSourceChoice = choice;
},
@@ -161,6 +168,100 @@ void main() {
expect(switchedSourceChoice, const PlaybackSourceSubtitleChoice.source(2));
});
testWidgets('does not duplicate a previously attached sidecar after selecting an embedded track', (tester) async {
const remoteUri = 'https://example.test/source/attached.ass';
final player = _FakeTrackSheetPlayer(
tracks: const Tracks(
subtitle: [
SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
SubtitleTrack(id: 's2', title: 'Remote sidecar', codec: 'ass', isExternal: true, uri: remoteUri),
],
),
track: const TrackSelection(
subtitle: SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
),
);
await _pumpTrackSheet(
tester,
player: player,
trackControlsState: TrackControlsState(
sourceSubtitleTracks: [
MediaSubtitleTrack(id: 1, languageCode: 'eng', codec: 'srt', selected: true, forced: false),
MediaSubtitleTrack(
id: 2,
title: 'Remote sidecar',
codec: 'ass',
external: true,
selected: false,
forced: false,
),
],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
sourceSubtitleSidecars: [
PlaybackSubtitleSidecar(
sourceStreamId: 2,
track: SubtitleTrack.uri(remoteUri, title: 'Remote sidecar', codec: 'ass'),
),
],
onSwitchSubtitle: (_) async {},
subtitleSearchSupported: false,
),
);
expect(find.text('Remote sidecar'), findsOneWidget);
});
testWidgets('keeps an unloaded source sidecar beside an unrelated downloaded subtitle', (tester) async {
const sourceUri = 'https://example.test/source/unloaded.ass';
const downloadedUri = 'file:///tmp/downloaded.srt';
final player = _FakeTrackSheetPlayer(
tracks: const Tracks(
subtitle: [
SubtitleTrack(id: 's1', title: 'Embedded subtitle', codec: 'srt'),
SubtitleTrack(
id: 'downloaded',
title: 'Downloaded subtitle',
codec: 'srt',
isExternal: true,
uri: downloadedUri,
),
],
),
track: const TrackSelection(
subtitle: SubtitleTrack(id: 's1', title: 'Embedded subtitle', codec: 'srt'),
),
);
await _pumpTrackSheet(
tester,
player: player,
trackControlsState: TrackControlsState(
sourceSubtitleTracks: [
MediaSubtitleTrack(
id: 2,
title: 'Remote sidecar',
codec: 'ass',
external: true,
selected: false,
forced: false,
),
],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
sourceSubtitleSidecars: [
PlaybackSubtitleSidecar(
sourceStreamId: 2,
track: SubtitleTrack.uri(sourceUri, title: 'Remote sidecar', codec: 'ass'),
),
],
onSwitchSubtitle: (_) async {},
subtitleSearchSupported: false,
),
);
expect(find.text('Downloaded subtitle'), findsOneWidget);
expect(find.text('Remote sidecar'), findsOneWidget);
});
testWidgets('keeps a source sheet open until the async selection commits', (tester) async {
final player = _FakeTrackSheetPlayer(
tracks: const Tracks(
@@ -204,7 +305,16 @@ void main() {
),
],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
sourceSubtitleSidecarIds: const {2},
sourceSubtitleSidecars: [
PlaybackSubtitleSidecar(
sourceStreamId: 2,
track: SubtitleTrack.uri(
'https://example.test/source/pending.ass',
title: 'Remote sidecar',
codec: 'ass',
),
),
],
onSwitchSubtitle: (_) => selectionGate.future,
subtitleSearchSupported: false,
),
@@ -262,18 +372,26 @@ void main() {
expect(switchedSourceChoice, isNull);
});
testWidgets('does not append a source sidecar already loaded as the secondary track', (tester) async {
testWidgets('does not append source sidecars already loaded as primary and secondary tracks', (tester) async {
const primaryUri = 'https://example.test/source/primary.ass';
const secondaryUri = 'https://example.test/source/secondary.ass';
final player = _FakeTrackSheetPlayer(
supportsSecondarySubtitles: true,
tracks: const Tracks(
subtitle: [
SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
SubtitleTrack(id: 's2', title: 'Remote sidecar', codec: 'ass', isExternal: true),
SubtitleTrack(id: 's1', title: 'Primary sidecar', codec: 'ass', isExternal: true, uri: primaryUri),
SubtitleTrack(id: 's2', title: 'Secondary sidecar', codec: 'ass', isExternal: true, uri: secondaryUri),
],
),
track: const TrackSelection(
subtitle: SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
secondarySubtitle: SubtitleTrack(id: 's2', title: 'Remote sidecar', codec: 'ass', isExternal: true),
subtitle: SubtitleTrack(id: 's1', title: 'Primary sidecar', codec: 'ass', isExternal: true, uri: primaryUri),
secondarySubtitle: SubtitleTrack(
id: 's2',
title: 'Secondary sidecar',
codec: 'ass',
isExternal: true,
uri: secondaryUri,
),
),
);
@@ -282,9 +400,17 @@ void main() {
player: player,
trackControlsState: TrackControlsState(
sourceSubtitleTracks: [
MediaSubtitleTrack(
id: 1,
title: 'Primary sidecar',
codec: 'ass',
external: true,
selected: true,
forced: false,
),
MediaSubtitleTrack(
id: 2,
title: 'Remote sidecar',
title: 'Secondary sidecar',
codec: 'ass',
external: true,
selected: false,
@@ -293,13 +419,23 @@ void main() {
],
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
selectedSecondarySubtitleStreamId: 2,
sourceSubtitleSidecarIds: const {2},
sourceSubtitleSidecars: [
PlaybackSubtitleSidecar(
sourceStreamId: 1,
track: SubtitleTrack.uri(primaryUri, title: 'Primary sidecar', codec: 'ass'),
),
PlaybackSubtitleSidecar(
sourceStreamId: 2,
track: SubtitleTrack.uri(secondaryUri, title: 'Secondary sidecar', codec: 'ass'),
),
],
onSwitchSubtitle: (_) async {},
subtitleSearchSupported: false,
),
);
expect(find.text('Remote sidecar'), findsOneWidget);
expect(find.text('Primary sidecar'), findsOneWidget);
expect(find.text('Secondary sidecar'), findsOneWidget);
});
});
@@ -364,10 +500,13 @@ void main() {
});
test('counts direct-play source sidecars without replacing native tracks', () {
const sourceUri = 'https://example.test/source/available.ass';
final sourceSidecar = MediaSubtitleTrack(id: 1, external: true, selected: false, forced: false);
final state = TrackControlsState(
sourceSubtitleTracks: [sourceSidecar],
sourceSubtitleSidecarIds: {sourceSidecar.id},
sourceSubtitleSidecars: [
PlaybackSubtitleSidecar(sourceStreamId: sourceSidecar.id, track: SubtitleTrack.uri(sourceUri)),
],
onSwitchSubtitle: (_) async {},
);
+49
View File
@@ -1099,6 +1099,55 @@ void main() {
expect(seekEvents, 0);
});
testWidgets('focused slider owns one adjustable semantics node', (tester) async {
LocaleSettings.setLocaleSync(AppLocale.en);
final semantics = tester.ensureSemantics();
final focusNode = FocusNode(debugLabel: 'semantic_timeline');
addTearDown(focusNode.dispose);
final seekEnds = <Duration>[];
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
child: TimelineSlider(
position: const Duration(minutes: 1),
duration: const Duration(minutes: 10),
chapters: const [],
chaptersLoaded: true,
focusNode: focusNode,
onSeek: (_) {},
onSeekEnd: seekEnds.add,
),
),
),
),
);
focusNode.requestFocus();
await tester.pump();
final finder = find.bySemanticsLabel(t.videoControls.timelineSlider);
expect(finder, findsOneWidget);
final node = tester.getSemantics(finder);
final data = node.getSemanticsData();
expect(data.label, t.videoControls.timelineSlider);
expect(data.value, '1:00');
expect(data.increasedValue, '1:10');
expect(data.decreasedValue, '0:50');
expect(data.flagsCollection.isSlider, isTrue);
expect(data.flagsCollection.isEnabled, ui.Tristate.isTrue);
expect(data.flagsCollection.isButton, isFalse);
expect(data.hasAction(ui.SemanticsAction.tap), isFalse);
expect(data.hasAction(ui.SemanticsAction.increase), isTrue);
expect(data.hasAction(ui.SemanticsAction.decrease), isTrue);
node.owner!.performAction(node.id, ui.SemanticsAction.increase);
node.owner!.performAction(node.id, ui.SemanticsAction.decrease);
expect(seekEnds, const [Duration(minutes: 1, seconds: 10), Duration(seconds: 50)]);
semantics.dispose();
});
testWidgets('does not pass chapters to painter when timeline markers are hidden', (tester) async {
await tester.pumpWidget(
MaterialApp(