From 4af77f4696d53128ab34305377e08cca0aa44e37 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:16:04 +0200 Subject: [PATCH] fix(app): restore playback and state lifecycle contracts --- lib/database/app_database.dart | 12 +- lib/focus/focusable_wrapper.dart | 11 +- lib/media/media_server_client.dart | 18 +- lib/navigation/profile_session_screen.dart | 12 +- lib/providers/trackers_provider.dart | 29 ++- lib/providers/trakt_account_provider.dart | 8 +- lib/screens/explore_screen.dart | 1 + .../settings/hotkey_recorder_widget.dart | 1 + .../video_player/completion_latch.dart | 12 +- lib/screens/video_player/parts/build.dart | 16 +- .../video_player/parts/playback_prompts.dart | 7 +- lib/screens/video_player_screen.dart | 2 + lib/services/episode_navigation_service.dart | 46 ++-- .../music/music_playback_service_impl.dart | 149 +++++++----- lib/services/plex_client.dart | 44 +++- lib/services/track_manager.dart | 30 +-- lib/services/track_selection_service.dart | 171 ++++++++++++- .../widgets/watch_together_overlay.dart | 8 + lib/widgets/collapsible_text.dart | 7 +- lib/widgets/device_code_dialog.dart | 1 + lib/widgets/focusable_popup_menu_button.dart | 5 + lib/widgets/hotkey_recorder.dart | 19 +- lib/widgets/music/mini_player.dart | 1 + lib/widgets/oauth_proxy_dialog.dart | 1 + .../models/track_controls_state.dart | 13 +- .../video_controls/parts/track_controls.dart | 4 +- .../video_controls/sheets/track_sheet.dart | 20 +- .../video_controls/video_controls.dart | 5 +- .../widgets/timeline_slider.dart | 1 - test/database/app_database_test.dart | 26 +- test/database/download_operations_test.dart | 183 +++++++------- test/focus/focusable_wrapper_test.dart | 91 +++++++ .../media/media_server_client_cache_test.dart | 92 +++++++ .../profile_session_screen_test.dart | 38 ++- test/providers/trackers_provider_test.dart | 26 ++ .../trakt_account_provider_test.dart | 20 ++ test/screens/explore_screen_test.dart | 29 +++ .../settings/settings_screen_test.dart | 19 +- .../video_player/completion_latch_test.dart | 30 ++- .../episode_navigation_service_test.dart | 223 ++++++++++++++++- .../music/music_playback_service_test.dart | 187 ++++++++++++++- .../plex_client_http_contract_test.dart | 226 ++++++++++++++++++ test/services/shader_asset_loader_test.dart | 6 +- test/services/track_manager_test.dart | 85 ++++++- .../track_selection_service_test.dart | 88 ++++++- test/startup_bootstrap_test.dart | 182 ++++++++++---- test/test_helpers/io_fakes.dart | 8 + test/test_helpers/watch_together_fakes.dart | 15 ++ test/watch_together/attached_player_test.dart | 37 ++- .../watch_together_overlay_test.dart | 71 +++++- test/widgets/collapsible_text_test.dart | 74 +++++- test/widgets/device_code_dialog_test.dart | 38 +++ .../focusable_popup_menu_button_test.dart | 45 ++++ test/widgets/hotkey_recorder_test.dart | 36 +++ test/widgets/music/mini_player_test.dart | 34 +++ test/widgets/oauth_proxy_dialog_test.dart | 36 +++ test/widgets/track_sheet_test.dart | 163 ++++++++++++- test/widgets/video_controls_test.dart | 49 ++++ 58 files changed, 2448 insertions(+), 363 deletions(-) create mode 100644 test/widgets/device_code_dialog_test.dart create mode 100644 test/widgets/oauth_proxy_dialog_test.dart diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index cae5d3e1..01f4f65a 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -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 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, diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index 88050c25..cf58b497 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -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 with SingleTickerPr checked: widget.checked, onTap: widget.onSelect, onLongPress: widget.onLongPress, - excludeSemantics: true, + excludeSemantics: widget.excludeChildSemantics, child: result, ); } diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index ad323d04..5667a61f 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -159,12 +159,17 @@ abstract class MediaServerClient { /// children). Future 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 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 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); diff --git a/lib/navigation/profile_session_screen.dart b/lib/navigation/profile_session_screen.dart index 9e574307..3a659e3c 100644 --- a/lib/navigation/profile_session_screen.dart +++ b/lib/navigation/profile_session_screen.dart @@ -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 createState() => _ProfileSessionScreenState(); @@ -146,7 +150,7 @@ class _ProfileSessionScreenState extends State { ), 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 { ), 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); diff --git a/lib/providers/trackers_provider.dart b/lib/providers/trackers_provider.dart index 8384aae8..3508adf7 100644 --- a/lib/providers/trackers_provider.dart +++ b/lib/providers/trackers_provider.dart @@ -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); + /// [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, 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); diff --git a/lib/providers/trakt_account_provider.dart b/lib/providers/trakt_account_provider.dart index f391c3da..6ab25e81 100644 --- a/lib/providers/trakt_account_provider.dart +++ b/lib/providers/trakt_account_provider.dart @@ -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; diff --git a/lib/screens/explore_screen.dart b/lib/screens/explore_screen.dart index 0d3550f7..bce2f485 100644 --- a/lib/screens/explore_screen.dart +++ b/lib/screens/explore_screen.dart @@ -188,6 +188,7 @@ class ExploreScreenState extends State 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), diff --git a/lib/screens/settings/hotkey_recorder_widget.dart b/lib/screens/settings/hotkey_recorder_widget.dart index d9066af1..7add7024 100644 --- a/lib/screens/settings/hotkey_recorder_widget.dart +++ b/lib/screens/settings/hotkey_recorder_widget.dart @@ -131,6 +131,7 @@ class _HotKeyRecorderWidgetState extends State { 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( diff --git a/lib/screens/video_player/completion_latch.dart b/lib/screens/video_player/completion_latch.dart index d0293126..8cffeb70 100644 --- a/lib/screens/video_player/completion_latch.dart +++ b/lib/screens/video_player/completion_latch.dart @@ -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; } diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index 9baf4e0e..72cd34cf 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -64,8 +64,11 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { return const PlaybackSourceSubtitleChoice.off(); } + List _sourceSubtitleSidecarsForControls() => + _playbackSession?.context.result.subtitleSidecars ?? const []; + List _sourceSubtitleTracksForControls() { - final sidecarSourceIds = _sourceSubtitleSidecarIdsForControls(); + final sidecarSourceIds = {for (final sidecar in _sourceSubtitleSidecarsForControls()) ?sidecar.sourceStreamId}; return selectableSourceSubtitleTracks( _currentMediaInfo?.subtitleTracks ?? const [], isTranscoding: _isTranscoding, @@ -74,13 +77,6 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { ); } - Set _sourceSubtitleSidecarIdsForControls() { - return { - for (final sidecar in _playbackSession?.context.result.subtitleSidecars ?? const []) - ?sidecar.sourceStreamId, - }; - } - Widget _buildLoadingSpinner() { return const Scaffold( backgroundColor: Colors.black, @@ -273,8 +269,8 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { } final sourceAudioTracks = _currentMediaInfo?.audioTracks ?? const []; + 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, diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart index f25b8a50..59d45cba 100644 --- a/lib/screens/video_player/parts/playback_prompts.dart +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -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); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 8ad82a5d..ca18e7d8 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -342,6 +342,8 @@ class VideoPlayerScreenState extends State 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; diff --git a/lib/services/episode_navigation_service.dart b/lib/services/episode_navigation_service.dart index f455c8d1..26f5b190 100644 --- a/lib/services/episode_navigation_service.dart +++ b/lib/services/episode_navigation_service.dart @@ -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 ~5–10 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 diff --git a/lib/services/music/music_playback_service_impl.dart b/lib/services/music/music_playback_service_impl.dart index 0293c5b2..86215b56 100644 --- a/lib/services/music/music_playback_service_impl.dart +++ b/lib/services/music/music_playback_service_impl.dart @@ -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? _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,62 +313,80 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO Future _openCurrent(int generation, {bool play = true}) async { final track = _queue.current; if (track == null) return; - _currentTrack = track; - _currentSource = null; - _armed = null; - _staleArm = null; - _setStatus(MusicPlaybackStatus.loading, forceNotify: true); - - await _coordinator.claimMusic(); - if (generation != _generation) return; - final player = _ensurePlayer(); - _ensureMediaControls(); - // Re-asserted per open (cheap, idempotent): the native side drops the - // background-mode opt-in when the user swipes the task away, so a - // session that survives task removal heals itself here. - unawaited(_mediaControls?.setBackgroundMode(true)); - - // Clear any native arm left over from the previous item before the open - // replaces it, so a stray transition can't fire mid-switch. + _openingGeneration = generation; + Player? committedPlayer; try { - await player.setNext(null); - } catch (e) { - appLogger.d('setNext(null) before open failed', error: e); - } + _currentTrack = track; + _currentSource = null; + _armed = null; + _staleArm = null; + _setStatus(MusicPlaybackStatus.loading, forceNotify: true); - MusicSource source; - try { - source = await _resolver.resolve(track); - } catch (e, st) { - appLogger.w('Music source resolve failed for ${track.id}', error: e, stackTrace: st); - if (generation == _generation) _handlePlaybackFailure(e); - return; - } - if (generation != _generation || _player != player) return; - _currentSource = source; + await _coordinator.claimMusic(); + if (generation != _generation) return; + final player = _ensurePlayer(); + _ensureMediaControls(); + // Re-asserted per open (cheap, idempotent): the native side drops the + // background-mode opt-in when the user swipes the task away, so a + // session that survives task removal heals itself here. + unawaited(_mediaControls?.setBackgroundMode(true)); - // Claim audio focus before audio starts so other media apps pause (mpv - // has no built-in focus handling; harmless no-op off Android). Result is - // ignored — mirrors the video screen, playback proceeds either way. - try { - await player.requestAudioFocus(); - } catch (e) { - appLogger.d('Audio focus request failed', error: e); - } - if (generation != _generation || _player != player) return; + // Clear any native arm left over from the previous item before the open + // replaces it, so a stray transition can't fire mid-switch. + try { + await player.setNext(null); + } catch (e) { + appLogger.d('setNext(null) before open failed', error: e); + } - try { - await player.open(Media(source.url, headers: source.headers), play: play); - } catch (e, st) { - appLogger.w('Music open failed for ${track.id}', error: e, stackTrace: st); - if (generation == _generation) _handlePlaybackFailure(e); - return; - } - if (generation != _generation || _player != player) return; + MusicSource source; + try { + source = await _resolver.resolve(track); + } catch (e, st) { + appLogger.w('Music source resolve failed for ${track.id}', error: e, stackTrace: st); + if (generation == _generation) _handlePlaybackFailure(e); + return; + } + if (generation != _generation || _player != player) return; + _currentSource = source; - _setStatus(play ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused); - _bindTrackServices(track, source); - _requestArmNext(); + // Claim audio focus before audio starts so other media apps pause (mpv + // has no built-in focus handling; harmless no-op off Android). Result is + // ignored — mirrors the video screen, playback proceeds either way. + try { + await player.requestAudioFocus(); + } catch (e) { + appLogger.d('Audio focus request failed', error: e); + } + if (generation != _generation || _player != player) return; + + try { + await player.open(Media(source.url, headers: source.headers), play: play); + } catch (e, st) { + appLogger.w('Music open failed for ${track.id}', error: e, stackTrace: st); + if (generation == _generation) _handlePlaybackFailure(e); + return; + } + 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 _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; } } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index b6e2adc7..9ded3135 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -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> getMetadataWithImagesAndOnDeck(String ratingKey) async { + Future> 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>( 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 _getMetadataWithImages(String ratingKey) async { + Future _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( 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 fetchItem(String id) async { - final metadata = await _getMetadataWithImages(id); - return metadata == null ? null : PlexMappers.mediaItem(metadata); + 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); - 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), - ); + 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 diff --git a/lib/services/track_manager.dart b/lib/services/track_manager.dart index c30e56fb..4a5fe1cc 100644 --- a/lib/services/track_manager.dart +++ b/lib/services/track_manager.dart @@ -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 diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index 4086fef3..99d11749 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -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 plexTracks, + List 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( + Iterable 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 availableTracks) { + final info = plexMediaInfo; + return metadata.backend == MediaBackend.plex && + info != null && + sourceTrack != null && + _isDirectEmbeddedPlexSubtitle(sourceTrack) && + _classifyDirectEmbeddedSubtitleCatalog(info.subtitleTracks, availableTracks) == + _DirectEmbeddedSubtitleCatalog.complete; + } + SubtitleTrack? findBestSubtitleMatch(List 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? selectSubtitleTrack( List 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) { diff --git a/lib/watch_together/widgets/watch_together_overlay.dart b/lib/watch_together/widgets/watch_together_overlay.dart index 4356786c..51d62b03 100644 --- a/lib/watch_together/widgets/watch_together_overlay.dart +++ b/lib/watch_together/widgets/watch_together_overlay.dart @@ -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, diff --git a/lib/widgets/collapsible_text.dart b/lib/widgets/collapsible_text.dart index 3b14cd83..d31784fd 100644 --- a/lib/widgets/collapsible_text.dart +++ b/lib/widgets/collapsible_text.dart @@ -136,6 +136,7 @@ class _CollapsibleTextState extends State { : _expanded ? t.accessibility.collapseText : t.accessibility.expandText, + excludeChildSemantics: false, descendantsAreFocusable: false, disableScale: true, useBackgroundFocus: true, @@ -147,11 +148,7 @@ class _CollapsibleTextState extends State { } return ClickableCursor( - child: GestureDetector( - onTap: _toggleExpanded, - excludeFromSemantics: widget.suppressExpandSemantics, - child: result, - ), + child: GestureDetector(onTap: _toggleExpanded, excludeFromSemantics: true, child: result), ); }, ); diff --git a/lib/widgets/device_code_dialog.dart b/lib/widgets/device_code_dialog.dart index d35d59bf..b161a772 100644 --- a/lib/widgets/device_code_dialog.dart +++ b/lib/widgets/device_code_dialog.dart @@ -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, diff --git a/lib/widgets/focusable_popup_menu_button.dart b/lib/widgets/focusable_popup_menu_button.dart index 01716480..d0fd2b3e 100644 --- a/lib/widgets/focusable_popup_menu_button.dart +++ b/lib/widgets/focusable_popup_menu_button.dart @@ -23,6 +23,9 @@ class FocusablePopupMenuButton 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 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 extends State { } } +List _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)]); } } diff --git a/lib/widgets/music/mini_player.dart b/lib/widgets/music/mini_player.dart index c8d91e2f..da86aac4 100644 --- a/lib/widgets/music/mini_player.dart +++ b/lib/widgets/music/mini_player.dart @@ -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, diff --git a/lib/widgets/oauth_proxy_dialog.dart b/lib/widgets/oauth_proxy_dialog.dart index 6ec125ae..2b497c92 100644 --- a/lib/widgets/oauth_proxy_dialog.dart +++ b/lib/widgets/oauth_proxy_dialog.dart @@ -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, diff --git a/lib/widgets/video_controls/models/track_controls_state.dart b/lib/widgets/video_controls/models/track_controls_state.dart index 57e3a49e..421384cd 100644 --- a/lib/widgets/video_controls/models/track_controls_state.dart +++ b/lib/widgets/video_controls/models/track_controls_state.dart @@ -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 sourceSubtitleTracks; final PlaybackSourceSubtitleChoice? selectedSubtitleChoice; final int? selectedSecondarySubtitleStreamId; - final Set sourceSubtitleSidecarIds; + final List 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 {}, + this.sourceSubtitleSidecars = const [], 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 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 []; /// External subtitle search needs both a searchable media item and a server diff --git a/lib/widgets/video_controls/parts/track_controls.dart b/lib/widgets/video_controls/parts/track_controls.dart index d117162d..cb81cbf9 100644 --- a/lib/widgets/video_controls/parts/track_controls.dart +++ b/lib/widgets/video_controls/parts/track_controls.dart @@ -134,7 +134,9 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { : const [], selectedSubtitleChoice: canSwitchSourceSubtitles ? versionQuality.selectedSubtitleChoice : null, selectedSecondarySubtitleStreamId: canSwitchSourceSubtitles ? widget.selectedSecondarySubtitleStreamId : null, - sourceSubtitleSidecarIds: canSwitchSourceSubtitles ? widget.sourceSubtitleSidecarIds : const {}, + sourceSubtitleSidecars: canSwitchSourceSubtitles + ? widget.sourceSubtitleSidecars + : const [], sourcePartId: canSwitchSourceSubtitles ? widget.sourcePartId : null, sourceDurationMs: widget.metadata.durationMs, boxFitMode: widget.boxFitMode, diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index b43a4f0f..c2342b59 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -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 = {}; + 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; diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index bd306e44..daa2db2e 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -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 sourceSubtitleTracks; final PlaybackSourceSubtitleChoice? selectedSubtitleChoice; final int? selectedSecondarySubtitleStreamId; - final Set sourceSubtitleSidecarIds; + final List 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 {}, + this.sourceSubtitleSidecars = const [], this.sourcePartId, this.onPlaybackSourceChanged, this.boxFitMode = 0, diff --git a/lib/widgets/video_controls/widgets/timeline_slider.dart b/lib/widgets/video_controls/widgets/timeline_slider.dart index b48bfc2e..3a735927 100644 --- a/lib/widgets/video_controls/widgets/timeline_slider.dart +++ b/lib/widgets/video_controls/widgets/timeline_slider.dart @@ -421,7 +421,6 @@ class _TimelineSliderState extends State { autoScroll: false, disableScale: true, focusColor: Colors.transparent, - semanticLabel: t.videoControls.timelineSlider, descendantsAreFocusable: false, child: slider, ); diff --git a/test/database/app_database_test.dart b/test/database/app_database_test.dart index 7bd93b58..887eddf1 100644 --- a/test/database/app_database_test.dart +++ b/test/database/app_database_test.dart @@ -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 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); diff --git a/test/database/download_operations_test.dart b/test/database/download_operations_test.dart index 3e3e9a3a..26a709ad 100644 --- a/test/database/download_operations_test.dart +++ b/test/database/download_operations_test.dart @@ -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,45 +131,50 @@ void main() { group('insertQueuedDownload', () { test('atomically persists media identity, scope, policy, and queue state', () async { + await db.close(); 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(); - db = AppDatabase.forTesting(NativeDatabase(databaseFile)); - final outcome = await db.insertQueuedDownload( - serverId: ServerId('srv'), - clientScopeId: 'srv/user-a', - ratingKey: 'episode-1', - globalKey: 'srv:episode-1', - type: 'episode', - parentRatingKey: 'season-1', - grandparentRatingKey: 'show-1', - mediaIndex: 3, - mediaSourceId: 'source-3', - priority: 7, - downloadSubtitles: false, - downloadArtwork: true, - ); - expect(outcome, QueueDownloadOutcome.admitted); - await db.close(); - db = AppDatabase.forTesting(NativeDatabase(databaseFile)); + final databaseFile = File(path.join(tempDir.path, 'downloads.sqlite')); + try { + db = AppDatabase.forTesting(NativeDatabase(databaseFile)); + final outcome = await db.insertQueuedDownload( + serverId: ServerId('srv'), + clientScopeId: 'srv/user-a', + ratingKey: 'episode-1', + globalKey: 'srv:episode-1', + type: 'episode', + parentRatingKey: 'season-1', + grandparentRatingKey: 'show-1', + mediaIndex: 3, + mediaSourceId: 'source-3', + priority: 7, + downloadSubtitles: false, + downloadArtwork: true, + ); + expect(outcome, QueueDownloadOutcome.admitted); + await db.close(); + db = AppDatabase.forTesting(NativeDatabase(databaseFile)); - final media = (await db.select(db.downloadedMedia).get()).single; - final queued = (await db.select(db.downloadQueue).get()).single; - expect(media.serverId, 'srv'); - expect(media.clientScopeId, 'srv/user-a'); - expect(media.ratingKey, 'episode-1'); - expect(media.parentRatingKey, 'season-1'); - expect(media.grandparentRatingKey, 'show-1'); - expect(media.status, DownloadStatus.queued.index); - expect(media.mediaIndex, 3); - expect(media.mediaSourceId, 'source-3'); - expect(queued.mediaGlobalKey, media.globalKey); - expect(queued.priority, 7); - expect(queued.downloadSubtitles, isFalse); - expect(queued.downloadArtwork, isTrue); + final media = (await db.select(db.downloadedMedia).get()).single; + final queued = (await db.select(db.downloadQueue).get()).single; + expect(media.serverId, 'srv'); + expect(media.clientScopeId, 'srv/user-a'); + expect(media.ratingKey, 'episode-1'); + expect(media.parentRatingKey, 'season-1'); + expect(media.grandparentRatingKey, 'show-1'); + expect(media.status, DownloadStatus.queued.index); + expect(media.mediaIndex, 3); + expect(media.mediaSourceId, 'source-3'); + expect(queued.mediaGlobalKey, media.globalKey); + 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,57 +375,68 @@ 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(); - db = AppDatabase.forTesting(NativeDatabase(databaseFile)); - await db.insertDownload( - serverId: ServerId('srv'), - ratingKey: 'existing', - globalKey: 'srv:existing', - type: 'movie', - status: DownloadStatus.failed.index, - ); - await db.updateDownloadProgress('srv:existing', 41, 410, 1000); - await db.customStatement(''' - CREATE TRIGGER reject_download_queue_insert - BEFORE INSERT ON download_queue - BEGIN - SELECT RAISE(ABORT, 'queue insert rejected'); - END - '''); - - await expectLater( - db.insertQueuedDownload(serverId: ServerId('srv'), ratingKey: 'new', globalKey: 'srv:new', type: 'movie'), - throwsA(anything), - ); - expect(await db.getDownloadedMedia('srv:new'), isNull); - expect(await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:new'))).get(), isEmpty); - - await expectLater( - db.insertQueuedDownload( + 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'), ratingKey: 'existing', globalKey: 'srv:existing', type: 'movie', - ), - throwsA(anything), - ); - 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); - final preserved = await db.getDownloadedMedia('srv:existing'); - expect(preserved?.status, DownloadStatus.failed.index); - expect(preserved?.progress, 41); - expect(preserved?.downloadedBytes, 410); - expect( - await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:existing'))).get(), - isEmpty, - ); + status: DownloadStatus.failed.index, + ); + await db.updateDownloadProgress('srv:existing', 41, 410, 1000); + await db.customStatement(''' + CREATE TRIGGER reject_download_queue_insert + BEFORE INSERT ON download_queue + BEGIN + SELECT RAISE(ABORT, 'queue insert rejected'); + END + '''); + + await expectLater( + db.insertQueuedDownload(serverId: ServerId('srv'), ratingKey: 'new', globalKey: 'srv:new', type: 'movie'), + throwsA(anything), + ); + expect(await db.getDownloadedMedia('srv:new'), isNull); + expect( + await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:new'))).get(), + isEmpty, + ); + + await expectLater( + db.insertQueuedDownload( + serverId: ServerId('srv'), + ratingKey: 'existing', + globalKey: 'srv:existing', + type: 'movie', + ), + throwsA(anything), + ); + 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, + ); + final preserved = await db.getDownloadedMedia('srv:existing'); + expect(preserved?.status, DownloadStatus.failed.index); + expect(preserved?.progress, 41); + expect(preserved?.downloadedBytes, 410); + expect( + 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); + } + } }); }); diff --git a/test/focus/focusable_wrapper_test.dart b/test/focus/focusable_wrapper_test.dart index 60ca133b..2d22ef9a 100644 --- a/test/focus/focusable_wrapper_test.dart +++ b/test/focus/focusable_wrapper_test.dart @@ -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; } diff --git a/test/media/media_server_client_cache_test.dart b/test/media/media_server_client_cache_test.dart index 9947f0aa..0f36b0c8 100644 --- a/test/media/media_server_client_cache_test.dart +++ b/test/media/media_server_client_cache_test.dart @@ -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( + cacheKey: key, + networkCall: () async => + MediaServerResponse(statusCode: 500, data: const {'value': 'server error'}, headers: const {}), + parseCache: (cached) { + cacheParserCalls++; + return (cached as Map)['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( + 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().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( + cacheKey: key, + networkCall: () => Future.error(transient), + shouldFallback: (error) { + selectedError = error; + return error is MediaServerHttpException && error.isTransient; + }, + parseCache: (cached) { + cacheParserCalls++; + return (cached as Map)['value'] as String; + }, + parseResponse: (_) { + responseParserCalls++; + return 'network'; + }, + ); + + expect(value, 'cached'); + expect(identical(selectedError, transient), isTrue); + expect(cacheParserCalls, 1); + expect(responseParserCalls, 0); + }); + }); } diff --git a/test/navigation/profile_session_screen_test.dart b/test/navigation/profile_session_screen_test.dart index 559edc7e..188f60c0 100644 --- a/test/navigation/profile_session_screen_test.dart +++ b/test/navigation/profile_session_screen_test.dart @@ -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 = []; final companionProviders = []; final disposedActiveIds = []; + final trackerHttpClients = []; + // 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 []); + 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.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 clients, int expected) { + for (final client in clients) { + expect(client.closeCount, expected); + } +} + class _ProfileProbeShell extends StatefulWidget { const _ProfileProbeShell({ required this.discoverProviders, diff --git a/test/providers/trackers_provider_test.dart b/test/providers/trackers_provider_test.dart index 4a1f1cb9..87a817ad 100644 --- a/test/providers/trackers_provider_test.dart +++ b/test/providers/trackers_provider_test.dart @@ -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 = []; + final p = TrackersProvider.forTesting( + connectPipeline: _ControlledConnectPipeline(_mal()).call, + httpClientFactory: () { + final client = FakeHttpClient(200, const []); + 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')); diff --git a/test/providers/trakt_account_provider_test.dart b/test/providers/trakt_account_provider_test.dart index 6e93bfa9..d9614576 100644 --- a/test/providers/trakt_account_provider_test.dart +++ b/test/providers/trakt_account_provider_test.dart @@ -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 = []; + final p = TraktAccountProvider( + httpClientFactory: () { + final client = FakeHttpClient(200, const []); + 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'; diff --git a/test/screens/explore_screen_test.dart b/test/screens/explore_screen_test.dart index fdc6b8b6..9ead4b23 100644 --- a/test/screens/explore_screen_test.dart +++ b/test/screens/explore_screen_test.dart @@ -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(find.byType(ExploreScreen)).focusActiveTabIfReady(); diff --git a/test/screens/settings/settings_screen_test.dart b/test/screens/settings/settings_screen_test.dart index af4f47f8..4ab7269c 100644 --- a/test/screens/settings/settings_screen_test.dart +++ b/test/screens/settings/settings_screen_test.dart @@ -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 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 trackerHttpClientFactory() { + final client = FakeHttpClient(HttpStatus.ok, const []); + 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, diff --git a/test/screens/video_player/completion_latch_test.dart b/test/screens/video_player/completion_latch_test.dart index 0ec81216..c5a22226 100644 --- a/test/screens/video_player/completion_latch_test.dart +++ b/test/screens/video_player/completion_latch_test.dart @@ -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, + ); }); }); diff --git a/test/services/episode_navigation_service_test.dart b/test/services/episode_navigation_service_test.dart index 23dfc67b..4b40b976 100644 --- a/test/services/episode_navigation_service_test.dart +++ b/test/services/episode_navigation_service_test.dart @@ -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.value(value: playback), ChangeNotifierProvider.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.value(value: playback), + ChangeNotifierProvider.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.value(value: playback), + ChangeNotifierProvider.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.value(value: playback), + ChangeNotifierProvider.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.value(value: playback), + ChangeNotifierProvider.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. diff --git a/test/services/music/music_playback_service_test.dart b/test/services/music/music_playback_service_test.dart index 5a798c95..26b563cd 100644 --- a/test/services/music/music_playback_service_test.dart +++ b/test/services/music/music_playback_service_test.dart @@ -36,6 +36,22 @@ MediaItem _track(String id) => testMediaItem( String _urlFor(MediaItem track) => 'fake://${track.id}'; +class _CallGate { + final Completer _entered = Completer(); + final Completer _released = Completer(); + + Future get entered => _entered.future; + + Future 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 volumes = []; Completer? playGate; Completer? pauseGate; + final List<_CallGate> _openGates = []; /// Arming these URIs throws, simulating a native setNext failure. final Set 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? 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 failingIds = {}; final Map resolveCounts = {}; - final Map> resolveGates = {}; + final Map> _resolveGates = {}; /// Per-track URL overrides (e.g. content:// shapes for offline tracks). final Map urlOverrides = {}; + _CallGate _gateNextResolve(String trackId) { + final gate = _CallGate(); + (_resolveGates[trackId] ??= []).add(gate); + return gate; + } + @override Future 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(); - 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(); - 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>(); 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(); + 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(); diff --git a/test/services/plex_client_http_contract_test.dart b/test/services/plex_client_http_contract_test.dart index 2e080e6c..463045aa 100644 --- a/test/services/plex_client_http_contract_test.dart +++ b/test/services/plex_client_http_contract_test.dart @@ -34,6 +34,232 @@ void main() { PlexClient makeClient(Future 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 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> 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? 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() + .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.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.error(TimeoutException('item lookup timed out')); + }); + addTearDown(client.close); + + await expectLater( + lookup(client, id, includeOnDeck: api.includeOnDeck), + throwsA( + isA() + .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.error(http.RequestAbortedException(request.url)); + }); + addTearDown(client.close); + + await expectLater( + lookup(client, id, includeOnDeck: api.includeOnDeck), + throwsA( + isA() + .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() + .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())); + + 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( diff --git a/test/services/shader_asset_loader_test.dart b/test/services/shader_asset_loader_test.dart index 1dfa1ffa..c7a66e21 100644 --- a/test/services/shader_asset_loader_test.dart +++ b/test/services/shader_asset_loader_test.dart @@ -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); diff --git a/test/services/track_manager_test.dart b/test/services/track_manager_test.dart index 4266e06c..88e1509e 100644 --- a/test/services/track_manager_test.dart +++ b/test/services/track_manager_test.dart @@ -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 _drainAsync() async { for (var i = 0; i < 5; i++) { await Future.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); diff --git a/test/services/track_selection_service_test.dart b/test/services/track_selection_service_test.dart index 981df165..ae6b50b9 100644 --- a/test/services/track_selection_service_test.dart +++ b/test/services/track_selection_service_test.dart @@ -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: [ diff --git a/test/startup_bootstrap_test.dart b/test/startup_bootstrap_test.dart index 64ba2a40..c04c2d1d 100644 --- a/test/startup_bootstrap_test.dart +++ b/test/startup_bootstrap_test.dart @@ -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 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 close(QueryExecutor inner) async { + await inner.close(); + closed = true; + } +} void main() { testWidgets('renders a Flutter frame before starting the initialization gate', (tester) async { @@ -112,56 +142,118 @@ 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( - 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( - serverId: ServerId('srv'), - ratingKey: 'complete', - globalKey: 'srv:complete', - type: 'movie', - status: DownloadStatus.completed.index, + 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 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 = await openAppDatabaseWithDownloadRecovery( + openDatabase: () { + return AppDatabase.open( + isTvos: false, + databaseFile: file, + preferences: prefs, + executorFactory: (databaseFile) { + openAttempts++; + 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); + } + }); + 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; - final bootstrap = AppDatabaseBootstrap( - database: database, - recoveryOutcome: TvosDatabaseRecoveryOutcome.notApplicable, - ); - final result = await openAppDatabaseWithDownloadRecovery( - openDatabase: () async { - openAttempts++; - if (openAttempts == 1) { - throw const FileSystemException('write failed: No space left on device'); - } - return bootstrap; - }, - recoverNativeDownloads: () async { - recoveries++; - }, - storageFullMessage: 'Storage full', - ); + 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++; + }, + 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); + } }); } diff --git a/test/test_helpers/io_fakes.dart b/test/test_helpers/io_fakes.dart index d2dcbde7..f16aaeed 100644 --- a/test/test_helpers/io_fakes.dart +++ b/test/test_helpers/io_fakes.dart @@ -36,9 +36,17 @@ class FakeHttpClient extends http.BaseClient { final int statusCode; final List body; + int closeCount = 0; + bool get isClosed => closeCount != 0; @override Future send(http.BaseRequest request) async { return http.StreamedResponse(Stream>.value(body), statusCode, request: request); } + + @override + void close() { + closeCount++; + super.close(); + } } diff --git a/test/test_helpers/watch_together_fakes.dart b/test/test_helpers/watch_together_fakes.dart index a8b71f87..778334d1 100644 --- a/test/test_helpers/watch_together_fakes.dart +++ b/test/test_helpers/watch_together_fakes.dart @@ -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? 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 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 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 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 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); diff --git a/test/watch_together/attached_player_test.dart b/test/watch_together/attached_player_test.dart index 87fe07a4..1fc1bbd5 100644 --- a/test/watch_together/attached_player_test.dart +++ b/test/watch_together/attached_player_test.dart @@ -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 = []; + attached.playingIntents.listen(playingIntents.add); + final pending = Completer(); + 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()); + expect((error as PlatformException).code, 'SET_PROPERTY_FAILED'); expect(lostEvents, isEmpty); attached.dispose(); }); diff --git a/test/watch_together/watch_together_overlay_test.dart b/test/watch_together/watch_together_overlay_test.dart index ea79c292..9974c9ae 100644 --- a/test/watch_together/watch_together_overlay_test.dart +++ b/test/watch_together/watch_together_overlay_test.dart @@ -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 _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 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; +} diff --git a/test/widgets/collapsible_text_test.dart b/test/widgets/collapsible_text_test.dart index 6dfa1cbe..3887923d 100644 --- a/test/widgets/collapsible_text_test.dart +++ b/test/widgets/collapsible_text_test.dart @@ -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(textFinder).textSpan!.toPlainText(); } + +String _visibleSynopsis(WidgetTester tester) { + final textFinder = find.byWidgetPredicate((widget) => widget is Text && widget.textSpan != null); + final span = tester.widget(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; +} diff --git a/test/widgets/device_code_dialog_test.dart b/test/widgets/device_code_dialog_test.dart new file mode 100644 index 00000000..539b1225 --- /dev/null +++ b/test/widgets/device_code_dialog_test.dart @@ -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(); + }); +} diff --git a/test/widgets/focusable_popup_menu_button_test.dart b/test/widgets/focusable_popup_menu_button_test.dart index 0224cc71..4947f225 100644 --- a/test/widgets/focusable_popup_menu_button_test.dart +++ b/test/widgets/focusable_popup_menu_button_test.dart @@ -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( + 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; } diff --git a/test/widgets/hotkey_recorder_test.dart b/test/widgets/hotkey_recorder_test.dart index 285ba6fc..2c8831d6 100644 --- a/test/widgets/hotkey_recorder_test.dart +++ b/test/widgets/hotkey_recorder_test.dart @@ -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: [], 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), diff --git a/test/widgets/music/mini_player_test.dart b/test/widgets/music/mini_player_test.dart index 2b8e069f..d269e31d 100644 --- a/test/widgets/music/mini_player_test.dart +++ b/test/widgets/music/mini_player_test.dart @@ -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', diff --git a/test/widgets/oauth_proxy_dialog_test.dart b/test/widgets/oauth_proxy_dialog_test.dart new file mode 100644 index 00000000..7ddffc5d --- /dev/null +++ b/test/widgets/oauth_proxy_dialog_test.dart @@ -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(); + }); +} diff --git a/test/widgets/track_sheet_test.dart b/test/widgets/track_sheet_test.dart index 44a2b0a1..00106a40 100644 --- a/test/widgets/track_sheet_test.dart +++ b/test/widgets/track_sheet_test.dart @@ -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 {}, ); diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index 2c428548..e83672ee 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -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 = []; + + 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(