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 Object _durabilityZoneKey = Object();
|
||||||
static final SerialFutureQueue _tvosRecoveryQueue = SerialFutureQueue();
|
static final SerialFutureQueue _tvosRecoveryQueue = SerialFutureQueue();
|
||||||
|
|
||||||
/// Resolves and opens the production database, then reconciles tvOS
|
/// Resolves and opens the production database, eagerly completing Drift
|
||||||
/// recovery before returning it to startup consumers.
|
/// setup and migrations on non-tvOS before returning. tvOS recovery retains
|
||||||
|
/// ownership of database access ordering.
|
||||||
static Future<AppDatabaseBootstrap> open({
|
static Future<AppDatabaseBootstrap> open({
|
||||||
bool isTvos = const bool.fromEnvironment('TVOS_BUILD'),
|
bool isTvos = const bool.fromEnvironment('TVOS_BUILD'),
|
||||||
File? databaseFile,
|
File? databaseFile,
|
||||||
@@ -110,6 +111,13 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
final store = recoveryStore ?? TvosDatabaseRecoveryStore(prefs, isTvos: isTvos);
|
final store = recoveryStore ?? TvosDatabaseRecoveryStore(prefs, isTvos: isTvos);
|
||||||
final database = AppDatabase._((executorFactory ?? _createNativeDatabase)(file), recoveryStore: store);
|
final database = AppDatabase._((executorFactory ?? _createNativeDatabase)(file), recoveryStore: store);
|
||||||
try {
|
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(
|
final outcome = await _tvosRecoveryQueue.run(
|
||||||
() => store.reconcile(
|
() => store.reconcile(
|
||||||
databaseExisted: databaseExisted,
|
databaseExisted: databaseExisted,
|
||||||
|
|||||||
@@ -145,6 +145,14 @@ class FocusableWrapper extends StatefulWidget {
|
|||||||
/// Optional current value announced after [semanticLabel].
|
/// Optional current value announced after [semanticLabel].
|
||||||
final String? semanticValue;
|
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.
|
/// Optional checked state for toggle-style controls.
|
||||||
final bool? checked;
|
final bool? checked;
|
||||||
|
|
||||||
@@ -211,6 +219,7 @@ class FocusableWrapper extends StatefulWidget {
|
|||||||
this.useComfortableZone = false,
|
this.useComfortableZone = false,
|
||||||
this.semanticLabel,
|
this.semanticLabel,
|
||||||
this.semanticValue,
|
this.semanticValue,
|
||||||
|
this.excludeChildSemantics = true,
|
||||||
this.checked,
|
this.checked,
|
||||||
this.canRequestFocus = true,
|
this.canRequestFocus = true,
|
||||||
this.onKeyEvent,
|
this.onKeyEvent,
|
||||||
@@ -594,7 +603,7 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
|||||||
checked: widget.checked,
|
checked: widget.checked,
|
||||||
onTap: widget.onSelect,
|
onTap: widget.onSelect,
|
||||||
onLongPress: widget.onLongPress,
|
onLongPress: widget.onLongPress,
|
||||||
excludeSemantics: true,
|
excludeSemantics: widget.excludeChildSemantics,
|
||||||
child: result,
|
child: result,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,12 +159,17 @@ abstract class MediaServerClient {
|
|||||||
/// children).
|
/// children).
|
||||||
Future<void> refreshLibraryMetadata(String libraryId);
|
Future<void> refreshLibraryMetadata(String libraryId);
|
||||||
|
|
||||||
/// Fetch a single item by its backend-opaque id. Returns `null` when the
|
/// Fetch a single item by its backend-opaque id. An online HTTP 404 returns
|
||||||
/// item no longer exists or the user can't see it.
|
/// `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);
|
Future<MediaItem?> fetchItem(String id);
|
||||||
|
|
||||||
/// Fetch a single item *and* its on-deck episode (the next unwatched /
|
/// Fetch a single item *and* its on-deck episode (the next unwatched /
|
||||||
/// in-progress episode) in one round-trip when the backend supports it.
|
/// 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`;
|
/// Plex bundles both via `/library/metadata/{id}?includeOnDeck=1`;
|
||||||
/// Jellyfin has no equivalent endpoint and returns `onDeckEpisode: null`,
|
/// Jellyfin has no equivalent endpoint and returns `onDeckEpisode: null`,
|
||||||
/// leaving callers to fetch on-deck separately if they need it.
|
/// 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.
|
/// shared base class isn't an option, but a `mixin on MediaServerClient` is.
|
||||||
mixin MediaServerCacheMixin implements MediaServerClient {
|
mixin MediaServerCacheMixin implements MediaServerClient {
|
||||||
/// Fetch with cache fallback: offline → cached only; online → try network,
|
/// 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
|
/// Returns `null` when offline mode is on and no cached row exists, or
|
||||||
/// when both network and cache come up empty.
|
/// when both network and cache come up empty.
|
||||||
@@ -728,6 +738,7 @@ mixin MediaServerCacheMixin implements MediaServerClient {
|
|||||||
required Future<MediaServerResponse> Function() networkCall,
|
required Future<MediaServerResponse> Function() networkCall,
|
||||||
required T? Function(dynamic cachedData) parseCache,
|
required T? Function(dynamic cachedData) parseCache,
|
||||||
required T? Function(MediaServerResponse response) parseResponse,
|
required T? Function(MediaServerResponse response) parseResponse,
|
||||||
|
bool Function(Object error)? shouldFallback,
|
||||||
bool cacheResponse = true,
|
bool cacheResponse = true,
|
||||||
}) async {
|
}) async {
|
||||||
final cacheScope = ServerId(cacheServerId);
|
final cacheScope = ServerId(cacheServerId);
|
||||||
@@ -744,6 +755,7 @@ mixin MediaServerCacheMixin implements MediaServerClient {
|
|||||||
}
|
}
|
||||||
return parseResponse(response);
|
return parseResponse(response);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (shouldFallback != null && !shouldFallback(e)) rethrow;
|
||||||
appLogger.w('Network request failed for $cacheKey, trying cache', error: e);
|
appLogger.w('Network request failed for $cacheKey, trying cache', error: e);
|
||||||
final cached = await cache.get(cacheScope, cacheKey);
|
final cached = await cache.get(cacheScope, cacheKey);
|
||||||
if (cached != null) return parseCache(cached);
|
if (cached != null) return parseCache(cached);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../connection/connection_registry.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.
|
/// on the root navigator so they survive this subtree being replaced.
|
||||||
class ProfileSessionScreen extends StatefulWidget {
|
class ProfileSessionScreen extends StatefulWidget {
|
||||||
const ProfileSessionScreen({super.key, this.isOfflineMode = false, this.initialPromptHandled = false})
|
const ProfileSessionScreen({super.key, this.isOfflineMode = false, this.initialPromptHandled = false})
|
||||||
: profileShellBuilder = null;
|
: profileShellBuilder = null,
|
||||||
|
trackerHttpClientFactory = null;
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
const ProfileSessionScreen.forTesting({
|
const ProfileSessionScreen.forTesting({
|
||||||
@@ -66,11 +68,13 @@ class ProfileSessionScreen extends StatefulWidget {
|
|||||||
this.isOfflineMode = false,
|
this.isOfflineMode = false,
|
||||||
this.initialPromptHandled = false,
|
this.initialPromptHandled = false,
|
||||||
required this.profileShellBuilder,
|
required this.profileShellBuilder,
|
||||||
});
|
required http.Client Function() httpClientFactory,
|
||||||
|
}) : trackerHttpClientFactory = httpClientFactory;
|
||||||
|
|
||||||
final bool isOfflineMode;
|
final bool isOfflineMode;
|
||||||
final bool initialPromptHandled;
|
final bool initialPromptHandled;
|
||||||
final WidgetBuilder? profileShellBuilder;
|
final WidgetBuilder? profileShellBuilder;
|
||||||
|
final http.Client Function()? trackerHttpClientFactory;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ProfileSessionScreen> createState() => _ProfileSessionScreenState();
|
State<ProfileSessionScreen> createState() => _ProfileSessionScreenState();
|
||||||
@@ -146,7 +150,7 @@ class _ProfileSessionScreenState extends State<ProfileSessionScreen> {
|
|||||||
),
|
),
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (context) {
|
create: (context) {
|
||||||
final provider = TraktAccountProvider();
|
final provider = TraktAccountProvider(httpClientFactory: widget.trackerHttpClientFactory);
|
||||||
unawaited(
|
unawaited(
|
||||||
provider.onActiveProfileChanged(activeId).catchError((Object e, StackTrace s) {
|
provider.onActiveProfileChanged(activeId).catchError((Object e, StackTrace s) {
|
||||||
appLogger.w('Trakt profile hydrate failed', error: e, stackTrace: s);
|
appLogger.w('Trakt profile hydrate failed', error: e, stackTrace: s);
|
||||||
@@ -157,7 +161,7 @@ class _ProfileSessionScreenState extends State<ProfileSessionScreen> {
|
|||||||
),
|
),
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (context) {
|
create: (context) {
|
||||||
final provider = TrackersProvider();
|
final provider = TrackersProvider(httpClientFactory: widget.trackerHttpClientFactory);
|
||||||
unawaited(
|
unawaited(
|
||||||
provider.onActiveProfileChanged(activeId).catchError((Object e, StackTrace s) {
|
provider.onActiveProfileChanged(activeId).catchError((Object e, StackTrace s) {
|
||||||
appLogger.w('Trackers profile hydrate failed', error: e, stackTrace: s);
|
appLogger.w('Trackers profile hydrate failed', error: e, stackTrace: s);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../models/trackers/device_code.dart';
|
import '../models/trackers/device_code.dart';
|
||||||
import '../services/trackers/anilist/anilist_auth_service.dart';
|
import '../services/trackers/anilist/anilist_auth_service.dart';
|
||||||
@@ -34,17 +35,33 @@ typedef TrackerSessionConnectPipeline =
|
|||||||
/// Plex profile. Single rebind seam: [onActiveProfileChanged] loads all three
|
/// Plex profile. Single rebind seam: [onActiveProfileChanged] loads all three
|
||||||
/// sessions from their stores and pushes them to their trackers.
|
/// sessions from their stores and pushes them to their trackers.
|
||||||
class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
|
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
|
@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 TrackerSessionConnectPipeline _connectPipeline;
|
||||||
final MalAuthService _malAuth = MalAuthService();
|
final MalAuthService _malAuth;
|
||||||
final AnilistAuthService _anilistAuth = AnilistAuthService();
|
final AnilistAuthService _anilistAuth;
|
||||||
final SimklAuthService _simklAuth = SimklAuthService();
|
final SimklAuthService _simklAuth;
|
||||||
final TrackerAccountStore _malStore = trackerAccountStore(TrackerService.mal);
|
final TrackerAccountStore _malStore = trackerAccountStore(TrackerService.mal);
|
||||||
final TrackerAccountStore _anilistStore = trackerAccountStore(TrackerService.anilist);
|
final TrackerAccountStore _anilistStore = trackerAccountStore(TrackerService.anilist);
|
||||||
final TrackerAccountStore _simklStore = trackerAccountStore(TrackerService.simkl);
|
final TrackerAccountStore _simklStore = trackerAccountStore(TrackerService.simkl);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../mixins/disposable_change_notifier_mixin.dart';
|
import '../mixins/disposable_change_notifier_mixin.dart';
|
||||||
import '../models/trackers/device_code.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
|
/// Single rebind seam: `onActiveProfileChanged` loads the new profile's
|
||||||
/// session and pushes it to both `TraktScrobbleService` and `TraktSyncService`.
|
/// session and pushes it to both `TraktScrobbleService` and `TraktSyncService`.
|
||||||
class TraktAccountProvider extends ChangeNotifier with DisposableChangeNotifierMixin {
|
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);
|
final TrackerAccountStore _store = trackerAccountStore(TrackerService.trakt);
|
||||||
|
|
||||||
TrackerSession? _session;
|
TrackerSession? _session;
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ class ExploreScreenState extends State<ExploreScreen>
|
|||||||
menuKey: _sourceMenuKey,
|
menuKey: _sourceMenuKey,
|
||||||
tooltip: t.explore.selectSource,
|
tooltip: t.explore.selectSource,
|
||||||
semanticLabel: t.explore.selectSource,
|
semanticLabel: t.explore.selectSource,
|
||||||
|
semanticValue: active.displayName,
|
||||||
anchorAlignment: anchorAlignment,
|
anchorAlignment: anchorAlignment,
|
||||||
onSelected: (id) => unawaited(sources.setActiveSource(id)),
|
onSelected: (id) => unawaited(sources.setActiveSource(id)),
|
||||||
itemBuilder: (context) => _sourceMenuEntries(sources, active),
|
itemBuilder: (context) => _sourceMenuEntries(sources, active),
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ class _HotKeyRecorderWidgetState extends State<HotKeyRecorderWidget> {
|
|||||||
onNavigateRight: canEdit && hasShortcut ? _clearFocusNode.requestFocus : null,
|
onNavigateRight: canEdit && hasShortcut ? _clearFocusNode.requestFocus : null,
|
||||||
onNavigateDown: (canSave ? _saveFocusNode : _cancelFocusNode).requestFocus,
|
onNavigateDown: (canSave ? _saveFocusNode : _cancelFocusNode).requestFocus,
|
||||||
semanticLabel: recordLabel,
|
semanticLabel: recordLabel,
|
||||||
|
semanticValue: _recordedHotKey == null ? null : formatHotKeyDisplay(_recordedHotKey!),
|
||||||
descendantsAreFocusable: false,
|
descendantsAreFocusable: false,
|
||||||
useBackgroundFocus: true,
|
useBackgroundFocus: true,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'dart:math' as math;
|
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
|
/// 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.
|
/// 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.
|
/// How a player EOF signal should be interpreted.
|
||||||
enum EofSignalClass { genuine, spurious, unknown }
|
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 }
|
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 (hasNext) return CompletionNavigationAction.presentNext;
|
||||||
if (adjacentLoadFailed) return CompletionNavigationAction.retryAdjacent;
|
if (adjacentStatus == QueueNavigationStatus.failed) {
|
||||||
|
return CompletionNavigationAction.retryAdjacent;
|
||||||
|
}
|
||||||
return CompletionNavigationAction.exit;
|
return CompletionNavigationAction.exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,8 +64,11 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
|||||||
return const PlaybackSourceSubtitleChoice.off();
|
return const PlaybackSourceSubtitleChoice.off();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<PlaybackSubtitleSidecar> _sourceSubtitleSidecarsForControls() =>
|
||||||
|
_playbackSession?.context.result.subtitleSidecars ?? const <PlaybackSubtitleSidecar>[];
|
||||||
|
|
||||||
List<MediaSubtitleTrack> _sourceSubtitleTracksForControls() {
|
List<MediaSubtitleTrack> _sourceSubtitleTracksForControls() {
|
||||||
final sidecarSourceIds = _sourceSubtitleSidecarIdsForControls();
|
final sidecarSourceIds = {for (final sidecar in _sourceSubtitleSidecarsForControls()) ?sidecar.sourceStreamId};
|
||||||
return selectableSourceSubtitleTracks(
|
return selectableSourceSubtitleTracks(
|
||||||
_currentMediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[],
|
_currentMediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[],
|
||||||
isTranscoding: _isTranscoding,
|
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() {
|
Widget _buildLoadingSpinner() {
|
||||||
return const Scaffold(
|
return const Scaffold(
|
||||||
backgroundColor: Colors.black,
|
backgroundColor: Colors.black,
|
||||||
@@ -273,8 +269,8 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final sourceAudioTracks = _currentMediaInfo?.audioTracks ?? const <MediaAudioTrack>[];
|
final sourceAudioTracks = _currentMediaInfo?.audioTracks ?? const <MediaAudioTrack>[];
|
||||||
|
final sourceSubtitleSidecars = _sourceSubtitleSidecarsForControls();
|
||||||
final sourceSubtitleTracks = _sourceSubtitleTracksForControls();
|
final sourceSubtitleTracks = _sourceSubtitleTracksForControls();
|
||||||
final sourceSubtitleSidecarIds = _sourceSubtitleSidecarIdsForControls();
|
|
||||||
|
|
||||||
return Video(
|
return Video(
|
||||||
player: player!,
|
player: player!,
|
||||||
@@ -296,7 +292,7 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
|||||||
sourceSubtitleTracks: sourceSubtitleTracks,
|
sourceSubtitleTracks: sourceSubtitleTracks,
|
||||||
selectedSubtitleChoice: _selectedSourceSubtitleChoiceForControls(sourceSubtitleTracks),
|
selectedSubtitleChoice: _selectedSourceSubtitleChoiceForControls(sourceSubtitleTracks),
|
||||||
selectedSecondarySubtitleStreamId: _playbackSession?.subtitleSelection.secondarySourceStreamId,
|
selectedSecondarySubtitleStreamId: _playbackSession?.subtitleSelection.secondarySourceStreamId,
|
||||||
sourceSubtitleSidecarIds: sourceSubtitleSidecarIds,
|
sourceSubtitleSidecars: sourceSubtitleSidecars,
|
||||||
sourcePartId: _currentMediaInfo?.partId,
|
sourcePartId: _currentMediaInfo?.partId,
|
||||||
onPlaybackSourceChanged: _switchPlaybackSource,
|
onPlaybackSourceChanged: _switchPlaybackSource,
|
||||||
onTogglePIPMode: _togglePIPMode,
|
onTogglePIPMode: _togglePIPMode,
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
|
|||||||
|
|
||||||
var navigationAction = completionNavigationAction(
|
var navigationAction = completionNavigationAction(
|
||||||
hasNext: _nextEpisode != null,
|
hasNext: _nextEpisode != null,
|
||||||
adjacentLoadFailed: _currentMetadata.isEpisode && _nextEpisodeStatus == QueueNavigationStatus.failed,
|
adjacentStatus: _nextEpisodeStatus,
|
||||||
);
|
);
|
||||||
if (navigationAction == CompletionNavigationAction.retryAdjacent) {
|
if (navigationAction == CompletionNavigationAction.retryAdjacent) {
|
||||||
_isResolvingCompletionAdjacency = true;
|
_isResolvingCompletionAdjacency = true;
|
||||||
@@ -52,10 +52,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
|
|||||||
_isResolvingCompletionAdjacency = false;
|
_isResolvingCompletionAdjacency = false;
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
navigationAction = completionNavigationAction(
|
navigationAction = completionNavigationAction(hasNext: _nextEpisode != null, adjacentStatus: _nextEpisodeStatus);
|
||||||
hasNext: _nextEpisode != null,
|
|
||||||
adjacentLoadFailed: _currentMetadata.isEpisode && _nextEpisodeStatus == QueueNavigationStatus.failed,
|
|
||||||
);
|
|
||||||
if (navigationAction == CompletionNavigationAction.retryAdjacent) {
|
if (navigationAction == CompletionNavigationAction.retryAdjacent) {
|
||||||
_completionLatch.latch();
|
_completionLatch.latch();
|
||||||
showGlobalErrorSnackBar(t.messages.errorLoadingSeries);
|
showGlobalErrorSnackBar(t.messages.errorLoadingSeries);
|
||||||
|
|||||||
@@ -342,6 +342,8 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
late MediaItem _currentMetadata;
|
late MediaItem _currentMetadata;
|
||||||
MediaItem? _nextEpisode;
|
MediaItem? _nextEpisode;
|
||||||
MediaItem? _previousEpisode;
|
MediaItem? _previousEpisode;
|
||||||
|
// Retryable sentinel until the fire-and-forget initial adjacency load
|
||||||
|
// commits found, boundary, or unavailable.
|
||||||
QueueNavigationStatus _nextEpisodeStatus = QueueNavigationStatus.failed;
|
QueueNavigationStatus _nextEpisodeStatus = QueueNavigationStatus.failed;
|
||||||
bool _isResolvingCompletionAdjacency = false;
|
bool _isResolvingCompletionAdjacency = false;
|
||||||
bool _isLoadingNext = false;
|
bool _isLoadingNext = false;
|
||||||
|
|||||||
@@ -41,17 +41,18 @@ class AdjacentEpisodes {
|
|||||||
|
|
||||||
enum _EpisodeQueueAvailability { active, unavailable, failed }
|
enum _EpisodeQueueAvailability { active, unavailable, failed }
|
||||||
|
|
||||||
/// Manages episode navigation for TV show playback.
|
/// Manages queue-based adjacency for video playback.
|
||||||
///
|
///
|
||||||
/// Handles:
|
/// 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
|
/// - Navigating between episodes while preserving track selections
|
||||||
/// - Supporting both sequential and shuffle playback modes
|
/// - Supporting both sequential and shuffle playback modes
|
||||||
///
|
///
|
||||||
/// Plex normally enters with its server-side `/playQueues` queue. If that
|
/// Plex normally enters with its server-side `/playQueues` queue. If that
|
||||||
/// setup failed, the same client-side full-series path used by Jellyfin is
|
/// setup failed for an episode, the same client-side full-series path used by
|
||||||
/// used as a fallback. Both paths publish into [PlaybackStateProvider] so
|
/// Jellyfin is used as a fallback. Playlist and collection queues may contain
|
||||||
/// the player reads previous/next from one source.
|
/// movies; once membership is verified, they use the same adjacency path.
|
||||||
class EpisodeNavigationService {
|
class EpisodeNavigationService {
|
||||||
/// Cached client-side episode lists, keyed by `seriesId`. Populated for
|
/// Cached client-side episode lists, keyed by `seriesId`. Populated for
|
||||||
/// Jellyfin and when Plex's server-side queue is unavailable. Fetched once
|
/// 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.
|
/// holding ~5–10 MB of metadata when the user wanders the library.
|
||||||
static const int _seriesCacheCapacity = 5;
|
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:
|
/// Series queue construction is episode-only. Movies can still resolve
|
||||||
/// - Not applicable (e.g., movie content)
|
/// adjacency when they are proven members of the active playlist or
|
||||||
/// - Next episode doesn't exist (end of season/series)
|
/// collection queue.
|
||||||
/// - Previous episode doesn't exist (first episode)
|
|
||||||
///
|
///
|
||||||
/// [playedPartId] is the backend part id actually being played, when
|
/// [playedPartId] is the backend part id actually being played, when
|
||||||
/// known — it lets the queue skip sibling entries of a Plex
|
/// 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.
|
/// Ensure [PlaybackStateProvider] holds a queue covering the current item.
|
||||||
/// A queue the item already belongs to (launcher-seeded shuffle, playlist,
|
/// A queue the item already belongs to (launcher-seeded shuffle, playlist,
|
||||||
/// collection, or an earlier series build) is preserved. Otherwise the
|
/// collection, or an earlier series build) is preserved for both movies and
|
||||||
/// backend's full series episode list is published, anchored at the current
|
/// episodes. Otherwise an episode's full backend series list is published,
|
||||||
/// episode. For Plex this is the fallback when `/playQueues` was
|
/// anchored at the current episode. For Plex this series build is the
|
||||||
/// unavailable; for Jellyfin it is the normal queue path.
|
/// fallback when `/playQueues` was unavailable; for Jellyfin it is the
|
||||||
|
/// normal episode path.
|
||||||
Future<_EpisodeQueueAvailability> _ensureLocalEpisodeQueue(
|
Future<_EpisodeQueueAvailability> _ensureLocalEpisodeQueue(
|
||||||
MultiServerManager serverManager,
|
MultiServerManager serverManager,
|
||||||
PlaybackStateProvider playbackState,
|
PlaybackStateProvider playbackState,
|
||||||
MediaItem metadata,
|
MediaItem metadata,
|
||||||
) async {
|
) async {
|
||||||
if (metadata.serverId == null || !metadata.isEpisode || metadata.grandparentId == null) {
|
// Queue membership and current-cursor identity are media-kind agnostic.
|
||||||
return _EpisodeQueueAvailability.unavailable;
|
// Prove them before applying the episode-only series-build prerequisites
|
||||||
}
|
// so playlist and collection movies retain their launcher-seeded queue.
|
||||||
final seriesId = metadata.grandparentId!;
|
|
||||||
// Preserve any queue this item already belongs to — a launcher-seeded
|
// Preserve any queue this item already belongs to — a launcher-seeded
|
||||||
// shuffled show queue (contextKey == seriesId), a playlist/collection
|
// shuffled show queue (contextKey == seriesId), a playlist/collection
|
||||||
// queue, or a series queue this method built earlier. setCurrentItem
|
// queue, or a series queue this method built earlier. setCurrentItem
|
||||||
@@ -154,14 +154,18 @@ class EpisodeNavigationService {
|
|||||||
playbackState.setCurrentItem(metadata);
|
playbackState.setCurrentItem(metadata);
|
||||||
return _EpisodeQueueAvailability.active;
|
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
|
// _reloadMediaInPlace a copyWith clone of the playing item, and MediaItem
|
||||||
// compares by identity, so the membership gate above misses. The cursor
|
// compares by identity, so the membership gate above misses. The cursor
|
||||||
// already points at this episode — the queue (and any shuffled order)
|
// already points at this item — the queue (and any shuffled order) must
|
||||||
// must survive.
|
// survive.
|
||||||
if (playbackState.isQueueActive && playbackState.currentQueueItem?.globalKey == metadata.globalKey) {
|
if (playbackState.isQueueActive && playbackState.currentQueueItem?.globalKey == metadata.globalKey) {
|
||||||
return _EpisodeQueueAvailability.active;
|
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
|
// 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.
|
// playlist/collection queue with a series queue: the launcher (e.g.
|
||||||
// [JellyfinSequentialLauncher]) sets contextKey to the playlist or
|
// [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.
|
/// Identifies the queue session that asynchronous enqueue work belongs to.
|
||||||
int _queueSessionRevision = 0;
|
int _queueSessionRevision = 0;
|
||||||
|
|
||||||
/// Gapless arm work is serialized, and each requested recomputation gets
|
/// Gapless arm work is serialized into one latest-request slot. The
|
||||||
/// a revision. This prevents an older resolve/setNext continuation from
|
/// generation stales continuations while the pending flag distinguishes
|
||||||
/// landing after a queue edit, repeat change, or sleep-timer change.
|
/// an explicit recomputation request from cancellation-only invalidation.
|
||||||
int _armRequestGeneration = 0;
|
int _armRequestGeneration = 0;
|
||||||
int _processedArmRequestGeneration = 0;
|
bool _armRequestPending = false;
|
||||||
Future<void>? _armDrain;
|
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;
|
int _consecutiveFailures = 0;
|
||||||
bool _resumeAfterInterruption = false;
|
bool _resumeAfterInterruption = false;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
@@ -309,62 +313,80 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
Future<void> _openCurrent(int generation, {bool play = true}) async {
|
Future<void> _openCurrent(int generation, {bool play = true}) async {
|
||||||
final track = _queue.current;
|
final track = _queue.current;
|
||||||
if (track == null) return;
|
if (track == null) return;
|
||||||
_currentTrack = track;
|
_openingGeneration = generation;
|
||||||
_currentSource = null;
|
Player? committedPlayer;
|
||||||
_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.
|
|
||||||
try {
|
try {
|
||||||
await player.setNext(null);
|
_currentTrack = track;
|
||||||
} catch (e) {
|
_currentSource = null;
|
||||||
appLogger.d('setNext(null) before open failed', error: e);
|
_armed = null;
|
||||||
}
|
_staleArm = null;
|
||||||
|
_setStatus(MusicPlaybackStatus.loading, forceNotify: true);
|
||||||
|
|
||||||
MusicSource source;
|
await _coordinator.claimMusic();
|
||||||
try {
|
if (generation != _generation) return;
|
||||||
source = await _resolver.resolve(track);
|
final player = _ensurePlayer();
|
||||||
} catch (e, st) {
|
_ensureMediaControls();
|
||||||
appLogger.w('Music source resolve failed for ${track.id}', error: e, stackTrace: st);
|
// Re-asserted per open (cheap, idempotent): the native side drops the
|
||||||
if (generation == _generation) _handlePlaybackFailure(e);
|
// background-mode opt-in when the user swipes the task away, so a
|
||||||
return;
|
// session that survives task removal heals itself here.
|
||||||
}
|
unawaited(_mediaControls?.setBackgroundMode(true));
|
||||||
if (generation != _generation || _player != player) return;
|
|
||||||
_currentSource = source;
|
|
||||||
|
|
||||||
// Claim audio focus before audio starts so other media apps pause (mpv
|
// Clear any native arm left over from the previous item before the open
|
||||||
// has no built-in focus handling; harmless no-op off Android). Result is
|
// replaces it, so a stray transition can't fire mid-switch.
|
||||||
// ignored — mirrors the video screen, playback proceeds either way.
|
try {
|
||||||
try {
|
await player.setNext(null);
|
||||||
await player.requestAudioFocus();
|
} catch (e) {
|
||||||
} catch (e) {
|
appLogger.d('setNext(null) before open failed', error: e);
|
||||||
appLogger.d('Audio focus request failed', error: e);
|
}
|
||||||
}
|
|
||||||
if (generation != _generation || _player != player) return;
|
|
||||||
|
|
||||||
try {
|
MusicSource source;
|
||||||
await player.open(Media(source.url, headers: source.headers), play: play);
|
try {
|
||||||
} catch (e, st) {
|
source = await _resolver.resolve(track);
|
||||||
appLogger.w('Music open failed for ${track.id}', error: e, stackTrace: st);
|
} catch (e, st) {
|
||||||
if (generation == _generation) _handlePlaybackFailure(e);
|
appLogger.w('Music source resolve failed for ${track.id}', error: e, stackTrace: st);
|
||||||
return;
|
if (generation == _generation) _handlePlaybackFailure(e);
|
||||||
}
|
return;
|
||||||
if (generation != _generation || _player != player) return;
|
}
|
||||||
|
if (generation != _generation || _player != player) return;
|
||||||
|
_currentSource = source;
|
||||||
|
|
||||||
_setStatus(play ? MusicPlaybackStatus.playing : MusicPlaybackStatus.paused);
|
// Claim audio focus before audio starts so other media apps pause (mpv
|
||||||
_bindTrackServices(track, source);
|
// has no built-in focus handling; harmless no-op off Android). Result is
|
||||||
_requestArmNext();
|
// 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
|
/// 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) {
|
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() {
|
void _requestArmNext() {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
_armRequestGeneration++;
|
_armRequestGeneration++;
|
||||||
|
_armRequestPending = true;
|
||||||
_ensureArmDrain();
|
_ensureArmDrain();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _invalidateArmRequests() {
|
void _invalidateArmRequests() {
|
||||||
_armRequestGeneration++;
|
_armRequestGeneration++;
|
||||||
|
_armRequestPending = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
void _ensureArmDrain() {
|
void _ensureArmDrain() {
|
||||||
if (_disposed || _armDrain != null) return;
|
if (_disposed || !_armRequestPending || _openingGeneration != null || _armDrain != null) return;
|
||||||
final drain = _drainArmRequests();
|
final drain = _drainArmRequests();
|
||||||
_armDrain = drain;
|
_armDrain = drain;
|
||||||
unawaited(
|
unawaited(
|
||||||
drain.whenComplete(() {
|
drain.whenComplete(() {
|
||||||
if (_armDrain != drain) return;
|
if (_armDrain != drain) return;
|
||||||
_armDrain = null;
|
_armDrain = null;
|
||||||
if (!_disposed && _processedArmRequestGeneration != _armRequestGeneration) {
|
if (!_disposed && _armRequestPending && _openingGeneration == null) {
|
||||||
_ensureArmDrain();
|
_ensureArmDrain();
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -457,12 +485,11 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _drainArmRequests() async {
|
Future<void> _drainArmRequests() async {
|
||||||
while (!_disposed) {
|
while (!_disposed && _armRequestPending && _openingGeneration == null) {
|
||||||
final armRequest = _armRequestGeneration;
|
final armRequest = _armRequestGeneration;
|
||||||
final generation = _generation;
|
final generation = _generation;
|
||||||
|
_armRequestPending = false;
|
||||||
await _applyArmNext(generation, armRequest);
|
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 {
|
class _PlexMediaProviderState {
|
||||||
const _PlexMediaProviderState({
|
const _PlexMediaProviderState({
|
||||||
required this.libraries,
|
required this.libraries,
|
||||||
@@ -1071,7 +1073,10 @@ class PlexClient
|
|||||||
/// Uses cache when offline or as fallback on network error
|
/// Uses cache when offline or as fallback on network error
|
||||||
/// Note: OnDeck data is not relevant for offline mode
|
/// Note: OnDeck data is not relevant for offline mode
|
||||||
/// Always fetches with chapters/markers but caches at base endpoint
|
/// 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)
|
// Cache key is always the base endpoint (no query params)
|
||||||
final cacheKey = '/library/metadata/$ratingKey';
|
final cacheKey = '/library/metadata/$ratingKey';
|
||||||
|
|
||||||
@@ -1079,6 +1084,7 @@ class PlexClient
|
|||||||
// because OnDeck is only available from network response, not cache
|
// because OnDeck is only available from network response, not cache
|
||||||
return await fetchWithCacheFallback<Map<String, dynamic>>(
|
return await fetchWithCacheFallback<Map<String, dynamic>>(
|
||||||
cacheKey: cacheKey,
|
cacheKey: cacheKey,
|
||||||
|
shouldFallback: shouldFallback,
|
||||||
networkCall: () => _http.get(
|
networkCall: () => _http.get(
|
||||||
'/library/metadata/$ratingKey',
|
'/library/metadata/$ratingKey',
|
||||||
queryParameters: {
|
queryParameters: {
|
||||||
@@ -1136,12 +1142,16 @@ class PlexClient
|
|||||||
/// Get metadata by rating key with images (includes clearLogo)
|
/// Get metadata by rating key with images (includes clearLogo)
|
||||||
/// Uses cache when offline or as fallback on network error
|
/// Uses cache when offline or as fallback on network error
|
||||||
/// Always fetches with chapters/markers but caches at base endpoint
|
/// 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)
|
// Cache key is always the base endpoint (no query params)
|
||||||
final cacheKey = '/library/metadata/$ratingKey';
|
final cacheKey = '/library/metadata/$ratingKey';
|
||||||
|
|
||||||
return fetchWithCacheFallback<PlexMetadataDto>(
|
return fetchWithCacheFallback<PlexMetadataDto>(
|
||||||
cacheKey: cacheKey,
|
cacheKey: cacheKey,
|
||||||
|
shouldFallback: shouldFallback,
|
||||||
networkCall: () => _http.get(
|
networkCall: () => _http.get(
|
||||||
'/library/metadata/$ratingKey',
|
'/library/metadata/$ratingKey',
|
||||||
queryParameters: {'includeChapters': 1, 'includeMarkers': 1, 'checkFiles': 1, 'includeStreams': 1},
|
queryParameters: {'includeChapters': 1, 'includeMarkers': 1, 'checkFiles': 1, 'includeStreams': 1},
|
||||||
@@ -2857,8 +2867,13 @@ class PlexClient
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<MediaItem?> fetchItem(String id) async {
|
Future<MediaItem?> fetchItem(String id) async {
|
||||||
final metadata = await _getMetadataWithImages(id);
|
try {
|
||||||
return metadata == null ? null : PlexMappers.mediaItem(metadata);
|
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
|
@override
|
||||||
@@ -3621,13 +3636,20 @@ class PlexClient
|
|||||||
/// Jellyfin has no analogous endpoint and returns onDeck=null there.
|
/// Jellyfin has no analogous endpoint and returns onDeck=null there.
|
||||||
@override
|
@override
|
||||||
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async {
|
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async {
|
||||||
final result = await getMetadataWithImagesAndOnDeck(id);
|
try {
|
||||||
final itemDto = result['metadata'] as PlexMetadataDto?;
|
final result = await getMetadataWithImagesAndOnDeck(id, shouldFallback: _shouldFallbackPlexItemLookup);
|
||||||
final onDeckDto = result['onDeckEpisode'] as PlexMetadataDto?;
|
final itemDto = result['metadata'] as PlexMetadataDto?;
|
||||||
return (
|
final onDeckDto = result['onDeckEpisode'] as PlexMetadataDto?;
|
||||||
item: itemDto == null ? null : PlexMappers.mediaItem(itemDto),
|
return (
|
||||||
onDeckEpisode: onDeckDto == null ? null : PlexMappers.mediaItem(onDeckDto),
|
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
|
@override
|
||||||
|
|||||||
@@ -259,30 +259,18 @@ class TrackManager {
|
|||||||
final hasAnyTracks = tracks.audio.isNotEmpty || tracks.subtitle.isNotEmpty;
|
final hasAnyTracks = tracks.audio.isNotEmpty || tracks.subtitle.isNotEmpty;
|
||||||
if (!hasAnyTracks) return false;
|
if (!hasAnyTracks) return false;
|
||||||
|
|
||||||
final info = mediaInfo;
|
final realAudioTracks = tracks.audio
|
||||||
if (info == null || info.subtitleTracks.isEmpty) return true;
|
.where((track) => track.id != AudioTrack.auto.id && track.id != AudioTrack.off.id)
|
||||||
if (tracks.subtitle.isEmpty) return false;
|
.toList(growable: false);
|
||||||
|
final realSubtitleTracks = tracks.subtitle
|
||||||
final nativeSubtitleTracks = tracks.subtitle
|
|
||||||
.where((track) => track.id != SubtitleTrack.auto.id && track.id != SubtitleTrack.off.id)
|
.where((track) => track.id != SubtitleTrack.auto.id && track.id != SubtitleTrack.off.id)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
final preferred = preferredSubtitleTrack;
|
final service = TrackSelectionService(metadata: metadata, plexMediaInfo: mediaInfo);
|
||||||
final preferredHasSemanticIdentity =
|
final selectedAudioTrack = service.selectAudioTrack(realAudioTracks, preferredAudioTrack)?.track;
|
||||||
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 serverSelectedTrack = info.subtitleTracks.where((track) => track.selected).firstOrNull;
|
// Selection owns the catalog-completeness decision. A null subtitle result
|
||||||
if (serverSelectedTrack == null) return true;
|
// is the only state in which a requested source track can still arrive.
|
||||||
return findMpvTrackForPlexSubtitle(serverSelectedTrack, nativeSubtitleTracks, allPlexTracks: info.subtitleTracks) !=
|
return service.selectSubtitleTrack(realSubtitleTracks, preferredSubtitleTrack, selectedAudioTrack) != null;
|
||||||
null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Core track selection: delegates to [TrackSelectionService]. Returns
|
/// Core track selection: delegates to [TrackSelectionService]. Returns
|
||||||
|
|||||||
@@ -78,6 +78,108 @@ int _scoreAudioMatch(AudioTrack mpvTrack, MediaAudioTrack plexTrack, {required b
|
|||||||
return score;
|
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
|
/// Find the MPV subtitle track that matches a Plex subtitle track
|
||||||
SubtitleTrack? findMpvTrackForPlexSubtitle(
|
SubtitleTrack? findMpvTrackForPlexSubtitle(
|
||||||
MediaSubtitleTrack plexTrack,
|
MediaSubtitleTrack plexTrack,
|
||||||
@@ -140,7 +242,22 @@ SubtitleTrack? findMpvTrackForPlexSubtitle(
|
|||||||
|
|
||||||
// Prefer metadata matches. Container sidecars may expose no language/title/
|
// Prefer metadata matches. Container sidecars may expose no language/title/
|
||||||
// codec at all, so their stable subtitle order is the last-resort identity.
|
// 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
|
/// 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
|
// Prefer metadata matches, with container order as the symmetric fallback
|
||||||
// needed to persist a metadata-free native track back to its Plex stream.
|
// 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
|
/// Find the MPV audio track that matches a Plex audio track
|
||||||
@@ -596,6 +726,22 @@ class TrackSelectionService {
|
|||||||
return TrackSelectionResult(selected, TrackSelectionPriority.profile);
|
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) {
|
SubtitleTrack? findBestSubtitleMatch(List<SubtitleTrack> availableTracks, SubtitleTrack preferred) {
|
||||||
// Handle special "no subtitles" case
|
// Handle special "no subtitles" case
|
||||||
if (preferred.id == 'no') {
|
if (preferred.id == 'no') {
|
||||||
@@ -603,10 +749,7 @@ class TrackSelectionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (preferred.id.startsWith('source:')) {
|
if (preferred.id.startsWith('source:')) {
|
||||||
final sourceId = int.tryParse(preferred.id.substring('source:'.length));
|
final sourceTrack = _sourceSubtitleTrack(preferred.id);
|
||||||
final sourceTrack = sourceId == null
|
|
||||||
? null
|
|
||||||
: plexMediaInfo?.subtitleTracks.where((track) => track.id == sourceId).firstOrNull;
|
|
||||||
if (sourceTrack == null) return null;
|
if (sourceTrack == null) return null;
|
||||||
return findMpvTrackForPlexSubtitle(sourceTrack, availableTracks, allPlexTracks: plexMediaInfo?.subtitleTracks);
|
return findMpvTrackForPlexSubtitle(sourceTrack, availableTracks, allPlexTracks: plexMediaInfo?.subtitleTracks);
|
||||||
}
|
}
|
||||||
@@ -763,9 +906,9 @@ class TrackSelectionService {
|
|||||||
/// Priority 4: Default track
|
/// Priority 4: Default track
|
||||||
/// Priority 5: Off
|
/// Priority 5: Off
|
||||||
///
|
///
|
||||||
/// Returns null while the source catalog advertises subtitles but the
|
/// Returns null only while the source catalog can still deliver the requested
|
||||||
/// native player has not exposed any of them yet. That transient state is
|
/// subtitle. A complete catalog with no unambiguous match proceeds through
|
||||||
/// not equivalent to an explicit server decision to turn subtitles off.
|
/// the safe default/off priorities instead of waiting indefinitely.
|
||||||
TrackSelectionResult<SubtitleTrack>? selectSubtitleTrack(
|
TrackSelectionResult<SubtitleTrack>? selectSubtitleTrack(
|
||||||
List<SubtitleTrack> availableTracks,
|
List<SubtitleTrack> availableTracks,
|
||||||
SubtitleTrack? preferredSubtitleTrack,
|
SubtitleTrack? preferredSubtitleTrack,
|
||||||
@@ -781,7 +924,10 @@ class TrackSelectionService {
|
|||||||
return TrackSelectionResult(subtitleToSelect, TrackSelectionPriority.navigation);
|
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
|
// Priority 2: Trust the server's selected track. Plex computes this from
|
||||||
@@ -800,7 +946,10 @@ class TrackSelectionService {
|
|||||||
if (matchedMpvTrack != null) {
|
if (matchedMpvTrack != null) {
|
||||||
return TrackSelectionResult(matchedMpvTrack, TrackSelectionPriority.serverSelected);
|
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) {
|
} else if (metadata.backend == MediaBackend.jellyfin) {
|
||||||
final defaultStreamIndex = info.defaultSubtitleStreamIndex;
|
final defaultStreamIndex = info.defaultSubtitleStreamIndex;
|
||||||
if (defaultStreamIndex == -1) {
|
if (defaultStreamIndex == -1) {
|
||||||
|
|||||||
@@ -73,9 +73,16 @@ class _SessionIndicator extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final semanticValue = [
|
||||||
|
'${t.watchTogether.participants}: $participantCount',
|
||||||
|
if (isHost) t.watchTogether.youAreHost,
|
||||||
|
if (isSyncing) t.watchTogether.syncing,
|
||||||
|
].join(', ');
|
||||||
|
|
||||||
return FocusableWrapper(
|
return FocusableWrapper(
|
||||||
onSelect: onTap,
|
onSelect: onTap,
|
||||||
semanticLabel: t.watchTogether.openSessionControls,
|
semanticLabel: t.watchTogether.openSessionControls,
|
||||||
|
semanticValue: semanticValue,
|
||||||
descendantsAreFocusable: false,
|
descendantsAreFocusable: false,
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
useBackgroundFocus: true,
|
useBackgroundFocus: true,
|
||||||
@@ -173,6 +180,7 @@ class _SessionMenuSheet extends StatelessWidget {
|
|||||||
FocusableWrapper(
|
FocusableWrapper(
|
||||||
onSelect: () => _copySessionCode(context, provider.sessionId!),
|
onSelect: () => _copySessionCode(context, provider.sessionId!),
|
||||||
semanticLabel: t.watchTogether.copySessionCode,
|
semanticLabel: t.watchTogether.copySessionCode,
|
||||||
|
semanticValue: provider.sessionId!,
|
||||||
descendantsAreFocusable: false,
|
descendantsAreFocusable: false,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
useBackgroundFocus: true,
|
useBackgroundFocus: true,
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ class _CollapsibleTextState extends State<CollapsibleText> {
|
|||||||
: _expanded
|
: _expanded
|
||||||
? t.accessibility.collapseText
|
? t.accessibility.collapseText
|
||||||
: t.accessibility.expandText,
|
: t.accessibility.expandText,
|
||||||
|
excludeChildSemantics: false,
|
||||||
descendantsAreFocusable: false,
|
descendantsAreFocusable: false,
|
||||||
disableScale: true,
|
disableScale: true,
|
||||||
useBackgroundFocus: true,
|
useBackgroundFocus: true,
|
||||||
@@ -147,11 +148,7 @@ class _CollapsibleTextState extends State<CollapsibleText> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return ClickableCursor(
|
return ClickableCursor(
|
||||||
child: GestureDetector(
|
child: GestureDetector(onTap: _toggleExpanded, excludeFromSemantics: true, child: result),
|
||||||
onTap: _toggleExpanded,
|
|
||||||
excludeFromSemantics: widget.suppressExpandSemantics,
|
|
||||||
child: result,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class DeviceCodeDialog extends StatelessWidget {
|
|||||||
child: FocusableWrapper(
|
child: FocusableWrapper(
|
||||||
onSelect: () => _copy(context),
|
onSelect: () => _copy(context),
|
||||||
semanticLabel: t.services.deviceCode.copyCode,
|
semanticLabel: t.services.deviceCode.copyCode,
|
||||||
|
semanticValue: code.userCode,
|
||||||
descendantsAreFocusable: false,
|
descendantsAreFocusable: false,
|
||||||
useBackgroundFocus: true,
|
useBackgroundFocus: true,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ class FocusablePopupMenuButton<T> extends StatefulWidget {
|
|||||||
final VoidCallback? onNavigateLeft;
|
final VoidCallback? onNavigateLeft;
|
||||||
final VoidCallback? onNavigateRight;
|
final VoidCallback? onNavigateRight;
|
||||||
final String? semanticLabel;
|
final String? semanticLabel;
|
||||||
|
|
||||||
|
/// Optional current value announced after the effective semantic label.
|
||||||
|
final String? semanticValue;
|
||||||
final double borderRadius;
|
final double borderRadius;
|
||||||
final bool useBackgroundFocus;
|
final bool useBackgroundFocus;
|
||||||
final bool enableLongPress;
|
final bool enableLongPress;
|
||||||
@@ -47,6 +50,7 @@ class FocusablePopupMenuButton<T> extends StatefulWidget {
|
|||||||
this.onNavigateLeft,
|
this.onNavigateLeft,
|
||||||
this.onNavigateRight,
|
this.onNavigateRight,
|
||||||
this.semanticLabel,
|
this.semanticLabel,
|
||||||
|
this.semanticValue,
|
||||||
this.borderRadius = 100,
|
this.borderRadius = 100,
|
||||||
this.useBackgroundFocus = true,
|
this.useBackgroundFocus = true,
|
||||||
this.enableLongPress = true,
|
this.enableLongPress = true,
|
||||||
@@ -73,6 +77,7 @@ class _FocusablePopupMenuButtonState<T> extends State<FocusablePopupMenuButton<T
|
|||||||
useBackgroundFocus: widget.useBackgroundFocus,
|
useBackgroundFocus: widget.useBackgroundFocus,
|
||||||
descendantsAreFocusable: false,
|
descendantsAreFocusable: false,
|
||||||
semanticLabel: widget.semanticLabel ?? widget.tooltip,
|
semanticLabel: widget.semanticLabel ?? widget.tooltip,
|
||||||
|
semanticValue: widget.semanticValue,
|
||||||
enableLongPress: widget.enableLongPress,
|
enableLongPress: widget.enableLongPress,
|
||||||
onNavigateUp: widget.onNavigateUp,
|
onNavigateUp: widget.onNavigateUp,
|
||||||
onNavigateDown: widget.onNavigateDown,
|
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.
|
/// Renders a [HotKey] as a row of styled key label chips.
|
||||||
class HotKeyVirtualView extends StatelessWidget {
|
class HotKeyVirtualView extends StatelessWidget {
|
||||||
const HotKeyVirtualView({super.key, required this.hotKey});
|
const HotKeyVirtualView({super.key, required this.hotKey});
|
||||||
@@ -89,14 +98,8 @@ class HotKeyVirtualView extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Wrap(
|
final keyLabels = _hotKeyDisplayLabels(hotKey);
|
||||||
spacing: 8,
|
return Wrap(spacing: 8, children: [for (final keyLabel in keyLabels) _VirtualKeyView(keyLabel: keyLabel)]);
|
||||||
children: [
|
|
||||||
for (final modifier in hotKey.modifiers ?? [])
|
|
||||||
_VirtualKeyView(keyLabel: physicalKeyLabel(modifier.physicalKeys.first)),
|
|
||||||
_VirtualKeyView(keyLabel: physicalKeyLabel(hotKey.key)),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix
|
|||||||
onLongPress: showContextMenu,
|
onLongPress: showContextMenu,
|
||||||
onNavigateRight: () => _transportKey.currentState?.requestFocusOnFirst(),
|
onNavigateRight: () => _transportKey.currentState?.requestFocusOnFirst(),
|
||||||
semanticLabel: widget.track.title,
|
semanticLabel: widget.track.title,
|
||||||
|
semanticValue: artist,
|
||||||
descendantsAreFocusable: false,
|
descendantsAreFocusable: false,
|
||||||
disableScale: true,
|
disableScale: true,
|
||||||
useBackgroundFocus: true,
|
useBackgroundFocus: true,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ class OAuthProxyDialog extends StatelessWidget {
|
|||||||
FocusableWrapper(
|
FocusableWrapper(
|
||||||
onSelect: () => _copyUrl(context),
|
onSelect: () => _copyUrl(context),
|
||||||
semanticLabel: t.services.oauthProxy.copyUrl,
|
semanticLabel: t.services.oauthProxy.copyUrl,
|
||||||
|
semanticValue: start.url,
|
||||||
descendantsAreFocusable: false,
|
descendantsAreFocusable: false,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
useBackgroundFocus: true,
|
useBackgroundFocus: true,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../../../media/media_version.dart';
|
|||||||
import '../../../media/media_source_info.dart';
|
import '../../../media/media_source_info.dart';
|
||||||
import '../../../models/transcode_quality_preset.dart';
|
import '../../../models/transcode_quality_preset.dart';
|
||||||
import '../../../mpv/mpv.dart';
|
import '../../../mpv/mpv.dart';
|
||||||
|
import '../../../services/playback_initialization_types.dart';
|
||||||
import '../../../services/playback_subtitle_resolver.dart';
|
import '../../../services/playback_subtitle_resolver.dart';
|
||||||
import '../../../services/shader_service.dart';
|
import '../../../services/shader_service.dart';
|
||||||
import '../helpers/track_filter_helper.dart';
|
import '../helpers/track_filter_helper.dart';
|
||||||
@@ -23,7 +24,7 @@ class TrackControlsState {
|
|||||||
final List<MediaSubtitleTrack> sourceSubtitleTracks;
|
final List<MediaSubtitleTrack> sourceSubtitleTracks;
|
||||||
final PlaybackSourceSubtitleChoice? selectedSubtitleChoice;
|
final PlaybackSourceSubtitleChoice? selectedSubtitleChoice;
|
||||||
final int? selectedSecondarySubtitleStreamId;
|
final int? selectedSecondarySubtitleStreamId;
|
||||||
final Set<int> sourceSubtitleSidecarIds;
|
final List<PlaybackSubtitleSidecar> sourceSubtitleSidecars;
|
||||||
final int? sourcePartId;
|
final int? sourcePartId;
|
||||||
|
|
||||||
/// Total media duration in milliseconds. Used by the version/quality sheet
|
/// Total media duration in milliseconds. Used by the version/quality sheet
|
||||||
@@ -88,7 +89,7 @@ class TrackControlsState {
|
|||||||
this.sourceSubtitleTracks = const [],
|
this.sourceSubtitleTracks = const [],
|
||||||
this.selectedSubtitleChoice,
|
this.selectedSubtitleChoice,
|
||||||
this.selectedSecondarySubtitleStreamId,
|
this.selectedSecondarySubtitleStreamId,
|
||||||
this.sourceSubtitleSidecarIds = const <int>{},
|
this.sourceSubtitleSidecars = const <PlaybackSubtitleSidecar>[],
|
||||||
this.sourcePartId,
|
this.sourcePartId,
|
||||||
this.sourceDurationMs,
|
this.sourceDurationMs,
|
||||||
this.boxFitMode = 0,
|
this.boxFitMode = 0,
|
||||||
@@ -141,7 +142,13 @@ class TrackControlsState {
|
|||||||
/// Direct play keeps embedded/native switching instant while still exposing
|
/// Direct play keeps embedded/native switching instant while still exposing
|
||||||
/// unloaded server sidecars that require one source reopen when selected.
|
/// unloaded server sidecars that require one source reopen when selected.
|
||||||
List<MediaSubtitleTrack> get directPlaySourceSidecars => !isTranscoding && onSwitchSubtitle != null
|
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>[];
|
: const <MediaSubtitleTrack>[];
|
||||||
|
|
||||||
/// External subtitle search needs both a searchable media item and a server
|
/// External subtitle search needs both a searchable media item and a server
|
||||||
|
|||||||
@@ -134,7 +134,9 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
|
|||||||
: const <MediaSubtitleTrack>[],
|
: const <MediaSubtitleTrack>[],
|
||||||
selectedSubtitleChoice: canSwitchSourceSubtitles ? versionQuality.selectedSubtitleChoice : null,
|
selectedSubtitleChoice: canSwitchSourceSubtitles ? versionQuality.selectedSubtitleChoice : null,
|
||||||
selectedSecondarySubtitleStreamId: canSwitchSourceSubtitles ? widget.selectedSecondarySubtitleStreamId : null,
|
selectedSecondarySubtitleStreamId: canSwitchSourceSubtitles ? widget.selectedSecondarySubtitleStreamId : null,
|
||||||
sourceSubtitleSidecarIds: canSwitchSourceSubtitles ? widget.sourceSubtitleSidecarIds : const <int>{},
|
sourceSubtitleSidecars: canSwitchSourceSubtitles
|
||||||
|
? widget.sourceSubtitleSidecars
|
||||||
|
: const <PlaybackSubtitleSidecar>[],
|
||||||
sourcePartId: canSwitchSourceSubtitles ? widget.sourcePartId : null,
|
sourcePartId: canSwitchSourceSubtitles ? widget.sourcePartId : null,
|
||||||
sourceDurationMs: widget.metadata.durationMs,
|
sourceDurationMs: widget.metadata.durationMs,
|
||||||
boxFitMode: widget.boxFitMode,
|
boxFitMode: widget.boxFitMode,
|
||||||
|
|||||||
@@ -418,12 +418,26 @@ class _SubtitleColumnState extends State<_SubtitleColumn> {
|
|||||||
final hasSecondary = widget.supportsSecondary && secondarySub != null;
|
final hasSecondary = widget.supportsSecondary && secondarySub != null;
|
||||||
final selectedSourceId = widget.trackControlsState.selectedSubtitleChoice?.sourceStreamId;
|
final selectedSourceId = widget.trackControlsState.selectedSubtitleChoice?.sourceStreamId;
|
||||||
final selectedSecondarySourceId = widget.trackControlsState.selectedSecondarySubtitleStreamId;
|
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
|
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);
|
.toList(growable: false);
|
||||||
|
|
||||||
// +1 for "Off" row. The selected direct-play sidecar is already present
|
// +1 for "Off". Source sidecars represented by native external tracks,
|
||||||
// in [tracks], so only the other server sidecars are appended.
|
// plus active source IDs awaiting native discovery, are not appended.
|
||||||
final itemCount = widget.tracks.length + unloadedSourceSidecars.length + 1;
|
final itemCount = widget.tracks.length + unloadedSourceSidecars.length + 1;
|
||||||
|
|
||||||
final selectedIndex = isOffSelected ? null : widget.tracks.indexWhere((t) => t.id == selectedSub.id) + 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/fullscreen_state_manager.dart';
|
||||||
import '../../services/macos_window_service.dart';
|
import '../../services/macos_window_service.dart';
|
||||||
import '../../services/pip_service.dart';
|
import '../../services/pip_service.dart';
|
||||||
|
import '../../services/playback_initialization_types.dart';
|
||||||
import '../../services/playback_subtitle_resolver.dart';
|
import '../../services/playback_subtitle_resolver.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
@@ -422,7 +423,7 @@ class PlexVideoControls extends StatefulWidget {
|
|||||||
final List<MediaSubtitleTrack> sourceSubtitleTracks;
|
final List<MediaSubtitleTrack> sourceSubtitleTracks;
|
||||||
final PlaybackSourceSubtitleChoice? selectedSubtitleChoice;
|
final PlaybackSourceSubtitleChoice? selectedSubtitleChoice;
|
||||||
final int? selectedSecondarySubtitleStreamId;
|
final int? selectedSecondarySubtitleStreamId;
|
||||||
final Set<int> sourceSubtitleSidecarIds;
|
final List<PlaybackSubtitleSidecar> sourceSubtitleSidecars;
|
||||||
final int? sourcePartId;
|
final int? sourcePartId;
|
||||||
final PlaybackSourceChangeCallback? onPlaybackSourceChanged;
|
final PlaybackSourceChangeCallback? onPlaybackSourceChanged;
|
||||||
final int boxFitMode;
|
final int boxFitMode;
|
||||||
@@ -540,7 +541,7 @@ class PlexVideoControls extends StatefulWidget {
|
|||||||
this.sourceSubtitleTracks = const [],
|
this.sourceSubtitleTracks = const [],
|
||||||
this.selectedSubtitleChoice,
|
this.selectedSubtitleChoice,
|
||||||
this.selectedSecondarySubtitleStreamId,
|
this.selectedSecondarySubtitleStreamId,
|
||||||
this.sourceSubtitleSidecarIds = const <int>{},
|
this.sourceSubtitleSidecars = const <PlaybackSubtitleSidecar>[],
|
||||||
this.sourcePartId,
|
this.sourcePartId,
|
||||||
this.onPlaybackSourceChanged,
|
this.onPlaybackSourceChanged,
|
||||||
this.boxFitMode = 0,
|
this.boxFitMode = 0,
|
||||||
|
|||||||
@@ -421,7 +421,6 @@ class _TimelineSliderState extends State<TimelineSlider> {
|
|||||||
autoScroll: false,
|
autoScroll: false,
|
||||||
disableScale: true,
|
disableScale: true,
|
||||||
focusColor: Colors.transparent,
|
focusColor: Colors.transparent,
|
||||||
semanticLabel: t.videoControls.timelineSlider,
|
|
||||||
descendantsAreFocusable: false,
|
descendantsAreFocusable: false,
|
||||||
child: slider,
|
child: slider,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,19 @@ import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
|||||||
|
|
||||||
import '../test_helpers/prefs.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() {
|
void main() {
|
||||||
final suite = _AppDatabaseTestSuite();
|
final suite = _AppDatabaseTestSuite();
|
||||||
suite.registerTests();
|
suite.registerTests();
|
||||||
@@ -731,7 +744,18 @@ class _AppDatabaseTestSuite {
|
|||||||
await seeded.close();
|
await seeded.close();
|
||||||
seeded = null;
|
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();
|
final owners = await reopened.select(reopened.downloadOwners).get();
|
||||||
DownloadOwnerItem owner(String profileId, String globalKey) =>
|
DownloadOwnerItem owner(String profileId, String globalKey) =>
|
||||||
owners.singleWhere((row) => row.profileId == profileId && row.globalKey == 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:plezy/media/ids.dart';
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/app_database.dart';
|
||||||
import 'package:plezy/database/download_operations.dart';
|
import 'package:plezy/database/download_operations.dart';
|
||||||
import 'package:plezy/models/download_models.dart';
|
import 'package:plezy/models/download_models.dart';
|
||||||
@@ -130,45 +131,50 @@ void main() {
|
|||||||
|
|
||||||
group('insertQueuedDownload', () {
|
group('insertQueuedDownload', () {
|
||||||
test('atomically persists media identity, scope, policy, and queue state', () async {
|
test('atomically persists media identity, scope, policy, and queue state', () async {
|
||||||
|
await db.close();
|
||||||
final tempDir = await Directory.systemTemp.createTemp('plezy_atomic_queue_');
|
final tempDir = await Directory.systemTemp.createTemp('plezy_atomic_queue_');
|
||||||
final databaseFile = File('${tempDir.path}/downloads.sqlite');
|
final databaseFile = File(path.join(tempDir.path, 'downloads.sqlite'));
|
||||||
addTearDown(() async {
|
try {
|
||||||
if (await tempDir.exists()) await tempDir.delete(recursive: true);
|
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
|
||||||
});
|
final outcome = await db.insertQueuedDownload(
|
||||||
await db.close();
|
serverId: ServerId('srv'),
|
||||||
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
|
clientScopeId: 'srv/user-a',
|
||||||
final outcome = await db.insertQueuedDownload(
|
ratingKey: 'episode-1',
|
||||||
serverId: ServerId('srv'),
|
globalKey: 'srv:episode-1',
|
||||||
clientScopeId: 'srv/user-a',
|
type: 'episode',
|
||||||
ratingKey: 'episode-1',
|
parentRatingKey: 'season-1',
|
||||||
globalKey: 'srv:episode-1',
|
grandparentRatingKey: 'show-1',
|
||||||
type: 'episode',
|
mediaIndex: 3,
|
||||||
parentRatingKey: 'season-1',
|
mediaSourceId: 'source-3',
|
||||||
grandparentRatingKey: 'show-1',
|
priority: 7,
|
||||||
mediaIndex: 3,
|
downloadSubtitles: false,
|
||||||
mediaSourceId: 'source-3',
|
downloadArtwork: true,
|
||||||
priority: 7,
|
);
|
||||||
downloadSubtitles: false,
|
expect(outcome, QueueDownloadOutcome.admitted);
|
||||||
downloadArtwork: true,
|
await db.close();
|
||||||
);
|
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
|
||||||
expect(outcome, QueueDownloadOutcome.admitted);
|
|
||||||
await db.close();
|
|
||||||
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
|
|
||||||
|
|
||||||
final media = (await db.select(db.downloadedMedia).get()).single;
|
final media = (await db.select(db.downloadedMedia).get()).single;
|
||||||
final queued = (await db.select(db.downloadQueue).get()).single;
|
final queued = (await db.select(db.downloadQueue).get()).single;
|
||||||
expect(media.serverId, 'srv');
|
expect(media.serverId, 'srv');
|
||||||
expect(media.clientScopeId, 'srv/user-a');
|
expect(media.clientScopeId, 'srv/user-a');
|
||||||
expect(media.ratingKey, 'episode-1');
|
expect(media.ratingKey, 'episode-1');
|
||||||
expect(media.parentRatingKey, 'season-1');
|
expect(media.parentRatingKey, 'season-1');
|
||||||
expect(media.grandparentRatingKey, 'show-1');
|
expect(media.grandparentRatingKey, 'show-1');
|
||||||
expect(media.status, DownloadStatus.queued.index);
|
expect(media.status, DownloadStatus.queued.index);
|
||||||
expect(media.mediaIndex, 3);
|
expect(media.mediaIndex, 3);
|
||||||
expect(media.mediaSourceId, 'source-3');
|
expect(media.mediaSourceId, 'source-3');
|
||||||
expect(queued.mediaGlobalKey, media.globalKey);
|
expect(queued.mediaGlobalKey, media.globalKey);
|
||||||
expect(queued.priority, 7);
|
expect(queued.priority, 7);
|
||||||
expect(queued.downloadSubtitles, isFalse);
|
expect(queued.downloadSubtitles, isFalse);
|
||||||
expect(queued.downloadArtwork, isTrue);
|
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 {
|
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 {
|
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();
|
await db.close();
|
||||||
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
|
final tempDir = await Directory.systemTemp.createTemp('plezy_atomic_rollback_');
|
||||||
await db.insertDownload(
|
final databaseFile = File(path.join(tempDir.path, 'downloads.sqlite'));
|
||||||
serverId: ServerId('srv'),
|
try {
|
||||||
ratingKey: 'existing',
|
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
|
||||||
globalKey: 'srv:existing',
|
await db.insertDownload(
|
||||||
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(
|
|
||||||
serverId: ServerId('srv'),
|
serverId: ServerId('srv'),
|
||||||
ratingKey: 'existing',
|
ratingKey: 'existing',
|
||||||
globalKey: 'srv:existing',
|
globalKey: 'srv:existing',
|
||||||
type: 'movie',
|
type: 'movie',
|
||||||
),
|
status: DownloadStatus.failed.index,
|
||||||
throwsA(anything),
|
);
|
||||||
);
|
await db.updateDownloadProgress('srv:existing', 41, 410, 1000);
|
||||||
await db.close();
|
await db.customStatement('''
|
||||||
db = AppDatabase.forTesting(NativeDatabase(databaseFile));
|
CREATE TRIGGER reject_download_queue_insert
|
||||||
expect(await db.getDownloadedMedia('srv:new'), isNull);
|
BEFORE INSERT ON download_queue
|
||||||
expect(await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:new'))).get(), isEmpty);
|
BEGIN
|
||||||
final preserved = await db.getDownloadedMedia('srv:existing');
|
SELECT RAISE(ABORT, 'queue insert rejected');
|
||||||
expect(preserved?.status, DownloadStatus.failed.index);
|
END
|
||||||
expect(preserved?.progress, 41);
|
''');
|
||||||
expect(preserved?.downloadedBytes, 410);
|
|
||||||
expect(
|
await expectLater(
|
||||||
await (db.select(db.downloadQueue)..where((row) => row.mediaGlobalKey.equals('srv:existing'))).get(),
|
db.insertQueuedDownload(serverId: ServerId('srv'), ratingKey: 'new', globalKey: 'srv:new', type: 'movie'),
|
||||||
isEmpty,
|
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/material.dart';
|
||||||
|
import 'package:flutter/semantics.dart' show SemanticsNode;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:plezy/focus/card_focus_scope.dart';
|
import 'package:plezy/focus/card_focus_scope.dart';
|
||||||
@@ -121,4 +124,92 @@ void main() {
|
|||||||
semanticsOwner.removeListener(countSemanticsUpdate);
|
semanticsOwner.removeListener(countSemanticsUpdate);
|
||||||
semantics.dispose();
|
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(cacheParserCalls, 0);
|
||||||
expect(responseParserCalls, 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:plezy/services/system_shelf_service.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../test_helpers/io_fakes.dart';
|
||||||
import '../test_helpers/prefs.dart';
|
import '../test_helpers/prefs.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -60,6 +61,15 @@ void main() {
|
|||||||
final trackerProviders = <TrackersProvider>[];
|
final trackerProviders = <TrackersProvider>[];
|
||||||
final companionProviders = <CompanionRemoteProvider>[];
|
final companionProviders = <CompanionRemoteProvider>[];
|
||||||
final disposedActiveIds = <String>[];
|
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 {
|
addTearDown(() async {
|
||||||
await tester.pumpWidget(const SizedBox.shrink());
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
@@ -97,6 +107,7 @@ void main() {
|
|||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
home: ProfileSessionScreen.forTesting(
|
home: ProfileSessionScreen.forTesting(
|
||||||
initialPromptHandled: true,
|
initialPromptHandled: true,
|
||||||
|
httpClientFactory: trackerHttpClientFactory,
|
||||||
profileShellBuilder: (context) => _ProfileProbeShell(
|
profileShellBuilder: (context) => _ProfileProbeShell(
|
||||||
discoverProviders: discoverProviders,
|
discoverProviders: discoverProviders,
|
||||||
hiddenProviders: hiddenProviders,
|
hiddenProviders: hiddenProviders,
|
||||||
@@ -109,6 +120,9 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
expect(trackerHttpClients, hasLength(trackerAuthClientsPerProfile));
|
||||||
|
final ownerHttpClients = List<FakeHttpClient>.of(trackerHttpClients);
|
||||||
|
_expectCloseCount(ownerHttpClients, 0);
|
||||||
|
|
||||||
expect(find.text('active:local-owner'), findsOneWidget);
|
expect(find.text('active:local-owner'), findsOneWidget);
|
||||||
expect(SystemShelfService().debugActiveOwner, owner.id);
|
expect(SystemShelfService().debugActiveOwner, owner.id);
|
||||||
@@ -131,6 +145,10 @@ void main() {
|
|||||||
|
|
||||||
expect(await activeProfile.activate(kids), isTrue);
|
expect(await activeProfile.activate(kids), isTrue);
|
||||||
await tester.pumpAndSettle();
|
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('old profile route'), findsNothing);
|
||||||
expect(find.text('active:local-kids'), findsOneWidget);
|
expect(find.text('active:local-kids'), findsOneWidget);
|
||||||
@@ -154,19 +172,27 @@ void main() {
|
|||||||
|
|
||||||
await activeProfile.clearActiveProfile();
|
await activeProfile.clearActiveProfile();
|
||||||
await tester.pumpAndSettle();
|
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(SystemShelfService().debugActiveOwner, isNull);
|
||||||
expect(discoverProviders.last.profileId, 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());
|
await tester.pumpWidget(const SizedBox.shrink());
|
||||||
for (var i = 0; i < 3; i++) {
|
await tester.pumpAndSettle();
|
||||||
await tester.pump(const Duration(milliseconds: 1));
|
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 {
|
class _ProfileProbeShell extends StatefulWidget {
|
||||||
const _ProfileProbeShell({
|
const _ProfileProbeShell({
|
||||||
required this.discoverProviders,
|
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/mal/mal_tracker.dart';
|
||||||
import 'package:plezy/services/trackers/simkl/simkl_tracker.dart';
|
import 'package:plezy/services/trackers/simkl/simkl_tracker.dart';
|
||||||
|
|
||||||
|
import '../test_helpers/io_fakes.dart';
|
||||||
import '../test_helpers/prefs.dart';
|
import '../test_helpers/prefs.dart';
|
||||||
|
|
||||||
final _malStore = trackerAccountStore(TrackerService.mal);
|
final _malStore = trackerAccountStore(TrackerService.mal);
|
||||||
@@ -62,6 +63,31 @@ void main() {
|
|||||||
p.dispose();
|
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 {
|
test('onActiveProfileChanged loads sessions from per-profile stores', () async {
|
||||||
const uuid = 'profile-1';
|
const uuid = 'profile-1';
|
||||||
await _malStore.save(uuid, _mal(username: 'alice'));
|
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/trackers/tracker_session.dart';
|
||||||
import 'package:plezy/services/trakt/trakt_sync_service.dart';
|
import 'package:plezy/services/trakt/trakt_sync_service.dart';
|
||||||
|
|
||||||
|
import '../test_helpers/io_fakes.dart';
|
||||||
import '../test_helpers/prefs.dart';
|
import '../test_helpers/prefs.dart';
|
||||||
|
|
||||||
final _store = trackerAccountStore(TrackerService.trakt);
|
final _store = trackerAccountStore(TrackerService.trakt);
|
||||||
@@ -34,6 +35,25 @@ void main() {
|
|||||||
p.dispose();
|
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 {
|
test('onActiveProfileChanged loads stored session and notifies', () async {
|
||||||
// Pre-seed the store for a specific profile uuid.
|
// Pre-seed the store for a specific profile uuid.
|
||||||
const uuid = 'profile-1';
|
const uuid = 'profile-1';
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:ui' show SemanticsAction;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@@ -202,6 +204,33 @@ void main() {
|
|||||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail');
|
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 {
|
testWidgets('source switcher exposes every catalog source with its brand logo', (tester) async {
|
||||||
final sources = await _pumpExplore(tester);
|
final sources = await _pumpExplore(tester);
|
||||||
tester.state<ExploreScreenState>(find.byType(ExploreScreen)).focusActiveTabIfReady();
|
tester.state<ExploreScreenState>(find.byType(ExploreScreen)).focusActiveTabIfReady();
|
||||||
|
|||||||
@@ -488,6 +488,7 @@ class _SettingsHarness {
|
|||||||
required this.theme,
|
required this.theme,
|
||||||
required this.trakt,
|
required this.trakt,
|
||||||
required this.trackers,
|
required this.trackers,
|
||||||
|
required this.trackerHttpClients,
|
||||||
required this.seerr,
|
required this.seerr,
|
||||||
required this.downloadManager,
|
required this.downloadManager,
|
||||||
required this.downloadProvider,
|
required this.downloadProvider,
|
||||||
@@ -501,6 +502,7 @@ class _SettingsHarness {
|
|||||||
final ThemeProvider theme;
|
final ThemeProvider theme;
|
||||||
final TraktAccountProvider trakt;
|
final TraktAccountProvider trakt;
|
||||||
final TrackersProvider trackers;
|
final TrackersProvider trackers;
|
||||||
|
final List<FakeHttpClient> trackerHttpClients;
|
||||||
final SeerrAccountProvider seerr;
|
final SeerrAccountProvider seerr;
|
||||||
final DownloadManagerService downloadManager;
|
final DownloadManagerService downloadManager;
|
||||||
final DownloadProvider downloadProvider;
|
final DownloadProvider downloadProvider;
|
||||||
@@ -519,6 +521,11 @@ class _SettingsHarness {
|
|||||||
activeProfile.dispose();
|
activeProfile.dispose();
|
||||||
await plexHome.dispose();
|
await plexHome.dispose();
|
||||||
await database.close();
|
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 activeProfile = ActiveProfileProvider(registry: profiles, plexHome: plexHome, connections: connections);
|
||||||
final libraries = LibrariesProvider();
|
final libraries = LibrariesProvider();
|
||||||
final theme = ThemeProvider();
|
final theme = ThemeProvider();
|
||||||
final trakt = TraktAccountProvider();
|
final trackerHttpClients = <FakeHttpClient>[];
|
||||||
final trackers = TrackersProvider();
|
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 seerr = SeerrAccountProvider();
|
||||||
final settingsService = SettingsService.instance;
|
final settingsService = SettingsService.instance;
|
||||||
final storageService = DownloadStorageService.instance;
|
final storageService = DownloadStorageService.instance;
|
||||||
@@ -582,6 +596,7 @@ Future<_SettingsHarness> _pumpSettingsScreen(
|
|||||||
theme: theme,
|
theme: theme,
|
||||||
trakt: trakt,
|
trakt: trakt,
|
||||||
trackers: trackers,
|
trackers: trackers,
|
||||||
|
trackerHttpClients: trackerHttpClients,
|
||||||
seerr: seerr,
|
seerr: seerr,
|
||||||
downloadManager: downloadManager,
|
downloadManager: downloadManager,
|
||||||
downloadProvider: downloadProvider,
|
downloadProvider: downloadProvider,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:plezy/providers/playback_state_provider.dart';
|
||||||
import 'package:plezy/screens/video_player/completion_latch.dart';
|
import 'package:plezy/screens/video_player/completion_latch.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
@@ -78,22 +79,41 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
group('completionNavigationAction', () {
|
group('completionNavigationAction', () {
|
||||||
test('presents a resolved next episode', () {
|
test('presents a resolved next item', () {
|
||||||
expect(
|
expect(
|
||||||
completionNavigationAction(hasNext: true, adjacentLoadFailed: false),
|
completionNavigationAction(hasNext: true, adjacentStatus: QueueNavigationStatus.found),
|
||||||
CompletionNavigationAction.presentNext,
|
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(
|
expect(
|
||||||
completionNavigationAction(hasNext: false, adjacentLoadFailed: true),
|
completionNavigationAction(hasNext: false, adjacentStatus: QueueNavigationStatus.failed),
|
||||||
CompletionNavigationAction.retryAdjacent,
|
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', () {
|
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_kind.dart';
|
||||||
import 'package:plezy/media/media_server_client.dart';
|
import 'package:plezy/media/media_server_client.dart';
|
||||||
import 'package:plezy/media/play_queue.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/multi_server_provider.dart';
|
||||||
import 'package:plezy/providers/playback_state_provider.dart';
|
import 'package:plezy/providers/playback_state_provider.dart';
|
||||||
import 'package:plezy/services/data_aggregation_service.dart';
|
import 'package:plezy/services/data_aggregation_service.dart';
|
||||||
@@ -26,6 +27,17 @@ MediaItem _jfEpisode(String id, {required String seriesId, ServerId? serverId})
|
|||||||
grandparentId: seriesId,
|
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(
|
MediaItem _plexEpisode(String id, {required String seriesId, int? viewCount}) => testMediaItem(
|
||||||
id: id,
|
id: id,
|
||||||
backend: MediaBackend.plex,
|
backend: MediaBackend.plex,
|
||||||
@@ -102,7 +114,7 @@ void main() {
|
|||||||
TestWidgetsFlutterBinding.ensureInitialized();
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
|
||||||
group('loadAdjacentEpisodes', () {
|
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();
|
final playback = PlaybackStateProvider();
|
||||||
addTearDown(playback.dispose);
|
addTearDown(playback.dispose);
|
||||||
final manager = _StubManager(null);
|
final manager = _StubManager(null);
|
||||||
@@ -116,7 +128,7 @@ void main() {
|
|||||||
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
|
ChangeNotifierProvider<PlaybackStateProvider>.value(value: playback),
|
||||||
ChangeNotifierProvider<MultiServerProvider>.value(value: serverProvider),
|
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();
|
await tester.pump();
|
||||||
@@ -124,11 +136,218 @@ void main() {
|
|||||||
|
|
||||||
expect(result, isNotNull);
|
expect(result, isNotNull);
|
||||||
expect(result!.nextStatus, QueueNavigationStatus.unavailable);
|
expect(result!.nextStatus, QueueNavigationStatus.unavailable);
|
||||||
|
expect(result!.previousStatus, QueueNavigationStatus.unavailable);
|
||||||
expect(result!.hasNext, isFalse);
|
expect(result!.hasNext, isFalse);
|
||||||
expect(result!.hasPrevious, isFalse);
|
expect(result!.hasPrevious, isFalse);
|
||||||
expect(playback.isQueueActive, 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 {
|
testWidgets('catches downstream exceptions and reports failed adjacency', (tester) async {
|
||||||
// Required providers are absent, so context.read throws. The service
|
// Required providers are absent, so context.read throws. The service
|
||||||
// converts the exception into an explicit failed result.
|
// converts the exception into an explicit failed result.
|
||||||
|
|||||||
@@ -36,6 +36,22 @@ MediaItem _track(String id) => testMediaItem(
|
|||||||
|
|
||||||
String _urlFor(MediaItem track) => 'fake://${track.id}';
|
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
|
/// In-memory audio player: records calls, exposes manual stream controllers
|
||||||
/// so tests drive transitions/completion/errors deterministically.
|
/// so tests drive transitions/completion/errors deterministically.
|
||||||
class FakePlayer implements Player {
|
class FakePlayer implements Player {
|
||||||
@@ -91,6 +107,7 @@ class FakePlayer implements Player {
|
|||||||
final List<double> volumes = [];
|
final List<double> volumes = [];
|
||||||
Completer<void>? playGate;
|
Completer<void>? playGate;
|
||||||
Completer<void>? pauseGate;
|
Completer<void>? pauseGate;
|
||||||
|
final List<_CallGate> _openGates = [];
|
||||||
|
|
||||||
/// Arming these URIs throws, simulating a native setNext failure.
|
/// Arming these URIs throws, simulating a native setNext failure.
|
||||||
final Set<String> failingSetNextUris = {};
|
final Set<String> failingSetNextUris = {};
|
||||||
@@ -104,9 +121,15 @@ class FakePlayer implements Player {
|
|||||||
/// consumed by an auto-advance ([emitTransition]).
|
/// consumed by an auto-advance ([emitTransition]).
|
||||||
Media? get armed => _armedMedia;
|
Media? get armed => _armedMedia;
|
||||||
|
|
||||||
|
_CallGate _gateNextOpen() {
|
||||||
|
final gate = _CallGate();
|
||||||
|
_openGates.add(gate);
|
||||||
|
return gate;
|
||||||
|
}
|
||||||
|
|
||||||
void emitTransition(String uri) {
|
void emitTransition(String uri) {
|
||||||
_armedMedia = null; // the backend advanced into the armed entry
|
_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);
|
trackTransitionCtrl.add(uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +138,16 @@ class FakePlayer implements Player {
|
|||||||
completedCtrl.add(true);
|
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 emitError(String message) => errorCtrl.add(PlayerError(message));
|
||||||
|
|
||||||
void setPosition(Duration position) {
|
void setPosition(Duration position) {
|
||||||
@@ -171,6 +204,11 @@ class FakePlayer implements Player {
|
|||||||
List<SubtitleTrack>? externalSubtitles,
|
List<SubtitleTrack>? externalSubtitles,
|
||||||
Duration? timelineDuration,
|
Duration? timelineDuration,
|
||||||
}) async {
|
}) 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);
|
openedUris.add(media.uri);
|
||||||
_state = _state.copyWith(playing: play, completed: false, position: Duration.zero, duration: _trackDuration);
|
_state = _state.copyWith(playing: play, completed: false, position: Duration.zero, duration: _trackDuration);
|
||||||
if (play) playingCtrl.add(true);
|
if (play) playingCtrl.add(true);
|
||||||
@@ -431,15 +469,24 @@ class FakeMusicSourceResolver implements MusicSourceResolver {
|
|||||||
final MediaServerClient? client;
|
final MediaServerClient? client;
|
||||||
final Set<String> failingIds = {};
|
final Set<String> failingIds = {};
|
||||||
final Map<String, int> resolveCounts = {};
|
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).
|
/// Per-track URL overrides (e.g. content:// shapes for offline tracks).
|
||||||
final Map<String, String> urlOverrides = {};
|
final Map<String, String> urlOverrides = {};
|
||||||
|
|
||||||
|
_CallGate _gateNextResolve(String trackId) {
|
||||||
|
final gate = _CallGate();
|
||||||
|
(_resolveGates[trackId] ??= []).add(gate);
|
||||||
|
return gate;
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<MusicSource> resolve(MediaItem track) async {
|
Future<MusicSource> resolve(MediaItem track) async {
|
||||||
resolveCounts[track.id] = (resolveCounts[track.id] ?? 0) + 1;
|
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)) {
|
if (failingIds.contains(track.id)) {
|
||||||
throw StateError('resolve failed for ${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 {
|
test('a superseded slow gapless resolve cannot overwrite the newly requested arm', () async {
|
||||||
final oldArmGate = Completer<void>();
|
final oldArmGate = h.resolver._gateNextResolve(t2.id);
|
||||||
h.resolver.resolveGates[t2.id] = oldArmGate;
|
|
||||||
|
|
||||||
await h.playTracks([t1, t2]);
|
await h.playTracks([t1, t2]);
|
||||||
|
await oldArmGate.entered;
|
||||||
expect(h.player.armed, isNull);
|
expect(h.player.armed, isNull);
|
||||||
|
|
||||||
h.service.addNext([t3]);
|
h.service.addNext([t3]);
|
||||||
oldArmGate.complete();
|
oldArmGate.release();
|
||||||
await pumpEventQueue();
|
await pumpEventQueue();
|
||||||
|
|
||||||
expect(h.player.armed?.uri, _urlFor(t3));
|
expect(h.player.armed?.uri, _urlFor(t3));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('end-of-track sleep invalidates a slow gapless resolve', () async {
|
test('end-of-track sleep invalidates a slow gapless resolve', () async {
|
||||||
final armGate = Completer<void>();
|
final armGate = h.resolver._gateNextResolve(t2.id);
|
||||||
h.resolver.resolveGates[t2.id] = armGate;
|
|
||||||
|
|
||||||
await h.playTracks([t1, t2]);
|
await h.playTracks([t1, t2]);
|
||||||
|
await armGate.entered;
|
||||||
h.service.setSleepTimer(null, endOfTrack: true);
|
h.service.setSleepTimer(null, endOfTrack: true);
|
||||||
armGate.complete();
|
armGate.release();
|
||||||
await pumpEventQueue();
|
await pumpEventQueue();
|
||||||
|
|
||||||
expect(h.service.sleepTimerEndOfTrack, isTrue);
|
expect(h.service.sleepTimerEndOfTrack, isTrue);
|
||||||
expect(h.player.armed, isNull);
|
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 {
|
test('a slow instant mix cannot replace a newer explicit queue', () async {
|
||||||
final mixGate = Completer<List<MediaItem>>();
|
final mixGate = Completer<List<MediaItem>>();
|
||||||
h.client.instantMixGate = mixGate;
|
h.client.instantMixGate = mixGate;
|
||||||
@@ -941,6 +1093,23 @@ void main() {
|
|||||||
expect(h.client.reportsFor('stopped').map((r) => r.itemId), ['t1']);
|
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 {
|
test('a late play completion cannot revive a stopped session', () async {
|
||||||
await h.playTracks([t1]);
|
await h.playTracks([t1]);
|
||||||
await h.service.pause();
|
await h.service.pause();
|
||||||
|
|||||||
@@ -34,6 +34,232 @@ void main() {
|
|||||||
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) =>
|
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) =>
|
||||||
testPlexClient(serverId: publicServerId, profileScopeId: defaultProfileScopeId, handler: 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', () {
|
group('Plex mutation result families', () {
|
||||||
test('void mutation completes on success and preserves status/transport failures', () async {
|
test('void mutation completes on success and preserves status/transport failures', () async {
|
||||||
final item = testMediaItem(
|
final item = testMediaItem(
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ void main() {
|
|||||||
|
|
||||||
final shaders = await ShaderAssetLoader.getShadersForPreset(customPreset(storedName));
|
final shaders = await ShaderAssetLoader.getShadersForPreset(customPreset(storedName));
|
||||||
expect(shaders, hasLength(1));
|
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');
|
expect(await File(shaders.single).readAsString(), 'shader');
|
||||||
|
|
||||||
await ShaderAssetLoader.deleteCustomShader(storedName);
|
await ShaderAssetLoader.deleteCustomShader(storedName);
|
||||||
@@ -185,7 +185,9 @@ void main() {
|
|||||||
final managedFile = File(path.join(customDirectory.path, storedName))..writeAsStringSync('legacy');
|
final managedFile = File(path.join(customDirectory.path, storedName))..writeAsStringSync('legacy');
|
||||||
|
|
||||||
expect(ShaderAssetLoader.isValidCustomShaderFileName(storedName), isTrue);
|
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);
|
await ShaderAssetLoader.deleteCustomShader(storedName);
|
||||||
expect(managedFile.existsSync(), isFalse);
|
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 {
|
Future<void> _drainAsync() async {
|
||||||
for (var i = 0; i < 5; i++) {
|
for (var i = 0; i < 5; i++) {
|
||||||
await Future<void>.delayed(Duration.zero);
|
await Future<void>.delayed(Duration.zero);
|
||||||
@@ -373,6 +382,58 @@ void main() {
|
|||||||
expect(player.selectedSubtitle.single.id, 'no');
|
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 {
|
test('waits through a partial catalog until the selected Plex subtitle arrives', () async {
|
||||||
await SettingsService.getInstance();
|
await SettingsService.getInstance();
|
||||||
final player = _FakePlayer(
|
final player = _FakePlayer(
|
||||||
@@ -380,7 +441,16 @@ void main() {
|
|||||||
audio: [AudioTrack(id: '1', language: 'eng')],
|
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);
|
addTearDown(mgr.dispose);
|
||||||
|
|
||||||
mgr.applyTrackSelectionWhenReady();
|
mgr.applyTrackSelectionWhenReady();
|
||||||
@@ -414,10 +484,19 @@ void main() {
|
|||||||
audio: [AudioTrack(id: '1', language: 'eng')],
|
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(
|
final mgr = _make(
|
||||||
player: player,
|
player: player,
|
||||||
mediaInfo: _mediaInfoWithSubtitles(selected: true),
|
mediaInfo: mediaInfo,
|
||||||
preferredSubtitleTrack: const SubtitleTrack(id: 'previous', language: 'fre'),
|
preferredSubtitleTrack: const SubtitleTrack(id: 'source:11', language: 'fre'),
|
||||||
);
|
);
|
||||||
addTearDown(mgr.dispose);
|
addTearDown(mgr.dispose);
|
||||||
|
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ import '../test_helpers/media_items.dart';
|
|||||||
// Plex-selected, Plex-server-explicit-no-subtitles, default fallback,
|
// Plex-selected, Plex-server-explicit-no-subtitles, default fallback,
|
||||||
// and the off-by-default branch.
|
// and the off-by-default branch.
|
||||||
//
|
//
|
||||||
// Top-level matching helpers (`findMpvTrackForPlexAudio`,
|
// Top-level subtitle matching helpers are exercised directly for complete,
|
||||||
// `findPlexTrackForMpvAudio`, `findMpvTrackForPlexSubtitle`,
|
// partial, unique, ambiguous, and container catalogs. Audio helpers are
|
||||||
// `findPlexTrackForMpvSubtitle`) are exercised indirectly through
|
// exercised through `selectAudioTrack` (Priority 2) and their focused
|
||||||
// `selectAudioTrack` (Priority 2) and `selectSubtitleTrack` (Priority 2).
|
// disambiguation tests below.
|
||||||
//
|
//
|
||||||
// What's NOT covered:
|
// What's NOT covered:
|
||||||
// - `selectAndApplyTracks` — depends on a real Player + SettingsService
|
// - `selectAndApplyTracks` — depends on a real Player + SettingsService
|
||||||
@@ -478,6 +478,86 @@ void main() {
|
|||||||
expect(result.track.language, 'fre');
|
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', () {
|
test('partial native catalog stays undetermined until the selected Plex track arrives', () {
|
||||||
final info = _info(
|
final info = _info(
|
||||||
subs: [
|
subs: [
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart' show ApplyInterceptor, QueryExecutor, QueryExecutorUser, QueryInterceptor;
|
||||||
import 'package:drift/native.dart';
|
import 'package:drift/native.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.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/main.dart';
|
||||||
import 'package:plezy/media/ids.dart';
|
import 'package:plezy/media/ids.dart';
|
||||||
import 'package:plezy/models/download_models.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() {
|
void main() {
|
||||||
testWidgets('renders a Flutter frame before starting the initialization gate', (tester) async {
|
testWidgets('renders a Flutter frame before starting the initialization gate', (tester) async {
|
||||||
@@ -112,56 +142,118 @@ void main() {
|
|||||||
expect(find.text('ready 9'), findsNothing);
|
expect(find.text('ready 9'), findsNothing);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('storage-full database open discards native work before retrying', () async {
|
test('storage-full lazy database open discards native work before retrying', () async {
|
||||||
final database = AppDatabase.forTesting(NativeDatabase.memory());
|
resetSharedPreferencesForTest();
|
||||||
addTearDown(database.close);
|
final tempDir = await Directory.systemTemp.createTemp('plezy_startup_storage_full_');
|
||||||
await database.insertDownload(
|
final file = File('${tempDir.path}/plezy_downloads.db');
|
||||||
serverId: ServerId('srv'),
|
final prefs = await BaseSharedPreferencesService.sharedCache();
|
||||||
ratingKey: 'active',
|
final failedOpen = _OpenTrackingInterceptor(
|
||||||
globalKey: 'srv:active',
|
failure: const FileSystemException('write failed: No space left on device'),
|
||||||
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,
|
|
||||||
);
|
);
|
||||||
|
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 openAttempts = 0;
|
||||||
var recoveries = 0;
|
var recoveries = 0;
|
||||||
final bootstrap = AppDatabaseBootstrap(
|
|
||||||
database: database,
|
|
||||||
recoveryOutcome: TvosDatabaseRecoveryOutcome.notApplicable,
|
|
||||||
);
|
|
||||||
|
|
||||||
final result = await openAppDatabaseWithDownloadRecovery(
|
try {
|
||||||
openDatabase: () async {
|
final open = openAppDatabaseWithDownloadRecovery(
|
||||||
openAttempts++;
|
openDatabase: () {
|
||||||
if (openAttempts == 1) {
|
return AppDatabase.open(
|
||||||
throw const FileSystemException('write failed: No space left on device');
|
isTvos: false,
|
||||||
}
|
databaseFile: file,
|
||||||
return bootstrap;
|
preferences: prefs,
|
||||||
},
|
executorFactory: (databaseFile) {
|
||||||
recoverNativeDownloads: () async {
|
openAttempts++;
|
||||||
recoveries++;
|
return NativeDatabase(databaseFile).interceptWith(failedOpen);
|
||||||
},
|
},
|
||||||
storageFullMessage: 'Storage full',
|
);
|
||||||
);
|
},
|
||||||
|
recoverNativeDownloads: () async {
|
||||||
|
recoveries++;
|
||||||
|
},
|
||||||
|
storageFullMessage: 'Storage full',
|
||||||
|
);
|
||||||
|
|
||||||
final active = await database.getDownloadedMedia('srv:active');
|
await expectLater(open, throwsA(same(error)));
|
||||||
final complete = await database.getDownloadedMedia('srv:complete');
|
expect(failedOpen.ensureOpenCalls, 1);
|
||||||
expect(result, same(bootstrap));
|
expect(failedOpen.closed, isTrue);
|
||||||
expect(openAttempts, 2);
|
expect(openAttempts, 1);
|
||||||
expect(recoveries, 1);
|
expect(recoveries, 0);
|
||||||
expect(active?.status, DownloadStatus.failed.index);
|
} finally {
|
||||||
expect(active?.bgTaskId, isNull);
|
await tempDir.delete(recursive: true);
|
||||||
expect(active?.errorMessage, 'Storage full');
|
}
|
||||||
expect(complete?.status, DownloadStatus.completed.index);
|
|
||||||
expect(await database.select(database.downloadQueue).get(), isEmpty);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,9 +36,17 @@ class FakeHttpClient extends http.BaseClient {
|
|||||||
|
|
||||||
final int statusCode;
|
final int statusCode;
|
||||||
final List<int> body;
|
final List<int> body;
|
||||||
|
int closeCount = 0;
|
||||||
|
bool get isClosed => closeCount != 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
Future<http.StreamedResponse> send(http.BaseRequest request) async {
|
||||||
return http.StreamedResponse(Stream<List<int>>.value(body), statusCode, request: request);
|
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.
|
/// When set, the next command throws this and clears the field.
|
||||||
Object? nextCommandError;
|
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
|
/// Simulates bitstream audio ignoring rate changes: setRate succeeds but
|
||||||
/// neither state nor the rate stream reflect it.
|
/// neither state nor the rate stream reflect it.
|
||||||
bool ignoreRateChanges = false;
|
bool ignoreRateChanges = false;
|
||||||
@@ -92,6 +95,9 @@ class FakeSyncPlayer implements Player {
|
|||||||
Future<void> play() async {
|
Future<void> play() async {
|
||||||
commandLog.add('play');
|
commandLog.add('play');
|
||||||
_maybeThrow();
|
_maybeThrow();
|
||||||
|
final pending = nextCommandFuture;
|
||||||
|
nextCommandFuture = null;
|
||||||
|
if (pending != null) await pending;
|
||||||
if (_state.playing) return;
|
if (_state.playing) return;
|
||||||
_state = _state.copyWith(playing: true);
|
_state = _state.copyWith(playing: true);
|
||||||
_playingController.add(true);
|
_playingController.add(true);
|
||||||
@@ -101,6 +107,9 @@ class FakeSyncPlayer implements Player {
|
|||||||
Future<void> pause() async {
|
Future<void> pause() async {
|
||||||
commandLog.add('pause');
|
commandLog.add('pause');
|
||||||
_maybeThrow();
|
_maybeThrow();
|
||||||
|
final pending = nextCommandFuture;
|
||||||
|
nextCommandFuture = null;
|
||||||
|
if (pending != null) await pending;
|
||||||
if (!_state.playing) return;
|
if (!_state.playing) return;
|
||||||
_state = _state.copyWith(playing: false);
|
_state = _state.copyWith(playing: false);
|
||||||
_playingController.add(false);
|
_playingController.add(false);
|
||||||
@@ -110,6 +119,9 @@ class FakeSyncPlayer implements Player {
|
|||||||
Future<void> seek(Duration position) async {
|
Future<void> seek(Duration position) async {
|
||||||
commandLog.add('seek:${position.inMilliseconds}');
|
commandLog.add('seek:${position.inMilliseconds}');
|
||||||
_maybeThrow();
|
_maybeThrow();
|
||||||
|
final pending = nextCommandFuture;
|
||||||
|
nextCommandFuture = null;
|
||||||
|
if (pending != null) await pending;
|
||||||
_state = _state.copyWith(position: position);
|
_state = _state.copyWith(position: position);
|
||||||
if (emitRestartOnSeek) _playbackRestartController.add(null);
|
if (emitRestartOnSeek) _playbackRestartController.add(null);
|
||||||
}
|
}
|
||||||
@@ -118,6 +130,9 @@ class FakeSyncPlayer implements Player {
|
|||||||
Future<void> setRate(double rate) async {
|
Future<void> setRate(double rate) async {
|
||||||
commandLog.add('rate:$rate');
|
commandLog.add('rate:$rate');
|
||||||
_maybeThrow();
|
_maybeThrow();
|
||||||
|
final pending = nextCommandFuture;
|
||||||
|
nextCommandFuture = null;
|
||||||
|
if (pending != null) await pending;
|
||||||
if (ignoreRateChanges || _state.rate == rate) return;
|
if (ignoreRateChanges || _state.rate == rate) return;
|
||||||
_state = _state.copyWith(rate: rate);
|
_state = _state.copyWith(rate: rate);
|
||||||
_rateController.add(rate);
|
_rateController.add(rate);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:fake_async/fake_async.dart';
|
import 'package:fake_async/fake_async.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.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) {
|
fakeAsync((async) {
|
||||||
final (attached, player, lostEvents) = build(async);
|
final (attached, player, lostEvents) = build(async);
|
||||||
|
|
||||||
player.nextCommandError = PlatformException(code: 'SOMETHING_ELSE');
|
player.nextCommandError = PlatformException(code: 'SET_PROPERTY_FAILED');
|
||||||
Object? error;
|
Object? error;
|
||||||
attached.play().catchError((Object e) {
|
attached.play().catchError((Object caught) {
|
||||||
error = e;
|
error = caught;
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
async.flushMicrotasks();
|
async.flushMicrotasks();
|
||||||
expect(error, isA<PlatformException>());
|
expect(error, isA<PlatformException>());
|
||||||
|
expect((error as PlatformException).code, 'SET_PROPERTY_FAILED');
|
||||||
expect(lostEvents, isEmpty);
|
expect(lostEvents, isEmpty);
|
||||||
attached.dispose();
|
attached.dispose();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import 'dart:ui' show SemanticsAction, Tristate;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/semantics.dart' show SemanticsNode;
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:plezy/i18n/strings.g.dart';
|
import 'package:plezy/i18n/strings.g.dart';
|
||||||
import 'package:plezy/watch_together/models/watch_session.dart';
|
import 'package:plezy/watch_together/models/watch_session.dart';
|
||||||
@@ -69,6 +72,48 @@ void main() {
|
|||||||
expect(harness.onLeaveSessionCalls, 1);
|
expect(harness.onLeaveSessionCalls, 1);
|
||||||
expect(tester.takeException(), isNull);
|
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 {
|
Future<void> _openLeaveConfirmation(WidgetTester tester, _OverlayHarness harness) async {
|
||||||
@@ -118,7 +163,8 @@ class _OverlayHarness {
|
|||||||
class _FakeWatchTogetherProvider extends WatchTogetherProvider {
|
class _FakeWatchTogetherProvider extends WatchTogetherProvider {
|
||||||
_FakeWatchTogetherProvider({required this.isHostValue, this.leaveError});
|
_FakeWatchTogetherProvider({required this.isHostValue, this.leaveError});
|
||||||
|
|
||||||
final bool isHostValue;
|
bool isHostValue;
|
||||||
|
bool isSyncingValue = false;
|
||||||
final Object? leaveError;
|
final Object? leaveError;
|
||||||
var leaveCalls = 0;
|
var leaveCalls = 0;
|
||||||
var _isDisposing = false;
|
var _isDisposing = false;
|
||||||
@@ -126,6 +172,9 @@ class _FakeWatchTogetherProvider extends WatchTogetherProvider {
|
|||||||
@override
|
@override
|
||||||
bool get isHost => isHostValue;
|
bool get isHost => isHostValue;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get isSyncing => isSyncingValue;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? get sessionId => 'ROOM42';
|
String? get sessionId => 'ROOM42';
|
||||||
|
|
||||||
@@ -140,6 +189,12 @@ class _FakeWatchTogetherProvider extends WatchTogetherProvider {
|
|||||||
@override
|
@override
|
||||||
int get participantCount => participants.length;
|
int get participantCount => participants.length;
|
||||||
|
|
||||||
|
void updateStatus({required bool isHost, required bool isSyncing}) {
|
||||||
|
isHostValue = isHost;
|
||||||
|
isSyncingValue = isSyncing;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> leaveSession() async {
|
Future<void> leaveSession() async {
|
||||||
if (!_isDisposing) leaveCalls++;
|
if (!_isDisposing) leaveCalls++;
|
||||||
@@ -153,3 +208,17 @@ class _FakeWatchTogetherProvider extends WatchTogetherProvider {
|
|||||||
super.dispose();
|
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/material.dart';
|
||||||
import 'package:flutter/semantics.dart';
|
import 'package:flutter/semantics.dart' show SemanticsNode;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:plezy/i18n/strings.g.dart';
|
||||||
import 'package:plezy/widgets/collapsible_text.dart';
|
import 'package:plezy/widgets/collapsible_text.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
setUpAll(() => LocaleSettings.setLocaleSync(AppLocale.en));
|
||||||
|
|
||||||
testWidgets('select expands overflowing focused text', (tester) async {
|
testWidgets('select expands overflowing focused text', (tester) async {
|
||||||
final focusNode = FocusNode(debugLabel: 'test_collapsible_text');
|
final focusNode = FocusNode(debugLabel: 'test_collapsible_text');
|
||||||
addTearDown(focusNode.dispose);
|
addTearDown(focusNode.dispose);
|
||||||
@@ -76,6 +81,53 @@ void main() {
|
|||||||
semantics.dispose();
|
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 {
|
testWidgets('reports whether text overflows', (tester) async {
|
||||||
bool? overflows;
|
bool? overflows;
|
||||||
|
|
||||||
@@ -103,3 +155,23 @@ String _collapsiblePlainText(WidgetTester tester) {
|
|||||||
final textFinder = find.byWidgetPredicate((widget) => widget is Text && widget.textSpan != null);
|
final textFinder = find.byWidgetPredicate((widget) => widget is Text && widget.textSpan != null);
|
||||||
return tester.widget<Text>(textFinder).textSpan!.toPlainText();
|
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/material.dart';
|
||||||
|
import 'package:flutter/semantics.dart' show SemanticsNode;
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:plezy/theme/mono_theme.dart';
|
import 'package:plezy/theme/mono_theme.dart';
|
||||||
@@ -34,4 +37,46 @@ void main() {
|
|||||||
|
|
||||||
expect(find.text('One'), findsOneWidget);
|
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]);
|
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)>[
|
for (final entry in <(String, LogicalKeyboardKey, PhysicalKeyboardKey)>[
|
||||||
('Enter', LogicalKeyboardKey.enter, PhysicalKeyboardKey.enter),
|
('Enter', LogicalKeyboardKey.enter, PhysicalKeyboardKey.enter),
|
||||||
('select', LogicalKeyboardKey.select, PhysicalKeyboardKey.select),
|
('select', LogicalKeyboardKey.select, PhysicalKeyboardKey.select),
|
||||||
|
|||||||
@@ -210,6 +210,40 @@ void main() {
|
|||||||
expect(find.byType(IconButton), findsNWidgets(2)); // play/pause + next (mobile layout)
|
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 {
|
testWidgets('progress resets immediately when the current track changes', (tester) async {
|
||||||
final nextTrack = testMediaItem(
|
final nextTrack = testMediaItem(
|
||||||
id: 'track_2',
|
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/media/media_source_info.dart';
|
||||||
import 'package:plezy/mpv/mpv.dart';
|
import 'package:plezy/mpv/mpv.dart';
|
||||||
import 'package:plezy/services/playback_subtitle_resolver.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/theme/mono_tokens.dart';
|
||||||
import 'package:plezy/widgets/overlay_sheet.dart';
|
import 'package:plezy/widgets/overlay_sheet.dart';
|
||||||
import 'package:plezy/widgets/video_controls/models/track_controls_state.dart';
|
import 'package:plezy/widgets/video_controls/models/track_controls_state.dart';
|
||||||
@@ -119,7 +120,8 @@ void main() {
|
|||||||
expect(switchedChoice, const PlaybackSourceSubtitleChoice.source(0));
|
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(
|
final player = _FakeTrackSheetPlayer(
|
||||||
tracks: const Tracks(
|
tracks: const Tracks(
|
||||||
subtitle: [SubtitleTrack(id: 's1', language: 'eng', codec: 'srt')],
|
subtitle: [SubtitleTrack(id: 's1', language: 'eng', codec: 'srt')],
|
||||||
@@ -146,7 +148,12 @@ void main() {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
|
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
|
||||||
sourceSubtitleSidecarIds: const {2},
|
sourceSubtitleSidecars: [
|
||||||
|
PlaybackSubtitleSidecar(
|
||||||
|
sourceStreamId: 2,
|
||||||
|
track: SubtitleTrack.uri(remoteUri, title: 'Remote sidecar', codec: 'ass'),
|
||||||
|
),
|
||||||
|
],
|
||||||
onSwitchSubtitle: (choice) async {
|
onSwitchSubtitle: (choice) async {
|
||||||
switchedSourceChoice = choice;
|
switchedSourceChoice = choice;
|
||||||
},
|
},
|
||||||
@@ -161,6 +168,100 @@ void main() {
|
|||||||
expect(switchedSourceChoice, const PlaybackSourceSubtitleChoice.source(2));
|
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 {
|
testWidgets('keeps a source sheet open until the async selection commits', (tester) async {
|
||||||
final player = _FakeTrackSheetPlayer(
|
final player = _FakeTrackSheetPlayer(
|
||||||
tracks: const Tracks(
|
tracks: const Tracks(
|
||||||
@@ -204,7 +305,16 @@ void main() {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
|
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,
|
onSwitchSubtitle: (_) => selectionGate.future,
|
||||||
subtitleSearchSupported: false,
|
subtitleSearchSupported: false,
|
||||||
),
|
),
|
||||||
@@ -262,18 +372,26 @@ void main() {
|
|||||||
expect(switchedSourceChoice, isNull);
|
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(
|
final player = _FakeTrackSheetPlayer(
|
||||||
supportsSecondarySubtitles: true,
|
supportsSecondarySubtitles: true,
|
||||||
tracks: const Tracks(
|
tracks: const Tracks(
|
||||||
subtitle: [
|
subtitle: [
|
||||||
SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
|
SubtitleTrack(id: 's1', title: 'Primary sidecar', codec: 'ass', isExternal: true, uri: primaryUri),
|
||||||
SubtitleTrack(id: 's2', title: 'Remote sidecar', codec: 'ass', isExternal: true),
|
SubtitleTrack(id: 's2', title: 'Secondary sidecar', codec: 'ass', isExternal: true, uri: secondaryUri),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
track: const TrackSelection(
|
track: const TrackSelection(
|
||||||
subtitle: SubtitleTrack(id: 's1', language: 'eng', codec: 'srt'),
|
subtitle: SubtitleTrack(id: 's1', title: 'Primary sidecar', codec: 'ass', isExternal: true, uri: primaryUri),
|
||||||
secondarySubtitle: SubtitleTrack(id: 's2', title: 'Remote sidecar', codec: 'ass', isExternal: true),
|
secondarySubtitle: SubtitleTrack(
|
||||||
|
id: 's2',
|
||||||
|
title: 'Secondary sidecar',
|
||||||
|
codec: 'ass',
|
||||||
|
isExternal: true,
|
||||||
|
uri: secondaryUri,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -282,9 +400,17 @@ void main() {
|
|||||||
player: player,
|
player: player,
|
||||||
trackControlsState: TrackControlsState(
|
trackControlsState: TrackControlsState(
|
||||||
sourceSubtitleTracks: [
|
sourceSubtitleTracks: [
|
||||||
|
MediaSubtitleTrack(
|
||||||
|
id: 1,
|
||||||
|
title: 'Primary sidecar',
|
||||||
|
codec: 'ass',
|
||||||
|
external: true,
|
||||||
|
selected: true,
|
||||||
|
forced: false,
|
||||||
|
),
|
||||||
MediaSubtitleTrack(
|
MediaSubtitleTrack(
|
||||||
id: 2,
|
id: 2,
|
||||||
title: 'Remote sidecar',
|
title: 'Secondary sidecar',
|
||||||
codec: 'ass',
|
codec: 'ass',
|
||||||
external: true,
|
external: true,
|
||||||
selected: false,
|
selected: false,
|
||||||
@@ -293,13 +419,23 @@ void main() {
|
|||||||
],
|
],
|
||||||
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
|
selectedSubtitleChoice: const PlaybackSourceSubtitleChoice.source(1),
|
||||||
selectedSecondarySubtitleStreamId: 2,
|
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 {},
|
onSwitchSubtitle: (_) async {},
|
||||||
subtitleSearchSupported: false,
|
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', () {
|
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 sourceSidecar = MediaSubtitleTrack(id: 1, external: true, selected: false, forced: false);
|
||||||
final state = TrackControlsState(
|
final state = TrackControlsState(
|
||||||
sourceSubtitleTracks: [sourceSidecar],
|
sourceSubtitleTracks: [sourceSidecar],
|
||||||
sourceSubtitleSidecarIds: {sourceSidecar.id},
|
sourceSubtitleSidecars: [
|
||||||
|
PlaybackSubtitleSidecar(sourceStreamId: sourceSidecar.id, track: SubtitleTrack.uri(sourceUri)),
|
||||||
|
],
|
||||||
onSwitchSubtitle: (_) async {},
|
onSwitchSubtitle: (_) async {},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1099,6 +1099,55 @@ void main() {
|
|||||||
expect(seekEvents, 0);
|
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 {
|
testWidgets('does not pass chapters to painter when timeline markers are hidden', (tester) async {
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
MaterialApp(
|
MaterialApp(
|
||||||
|
|||||||
Reference in New Issue
Block a user