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