fix(watch): resolve watch state per item at cards and playback open
Cards consult the hierarchy-aware store so container marks flip episode rows instantly and a stale per-episode patch can no longer shadow a newer show/season mark; playback opens with the session-fresh resume offset.
This commit is contained in:
@@ -49,9 +49,7 @@ class PlaylistItemCard extends StatefulWidget {
|
|||||||
class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTapMixin<PlaylistItemCard> {
|
class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTapMixin<PlaylistItemCard> {
|
||||||
MediaItem _effectiveItem(BuildContext context) {
|
MediaItem _effectiveItem(BuildContext context) {
|
||||||
try {
|
try {
|
||||||
final patch = context.select<WatchStateStore, WatchStatePatch?>(
|
final patch = context.select<WatchStateStore, WatchStatePatch?>((store) => store.patchForItem(widget.item));
|
||||||
(provider) => provider.patchForGlobalKey(widget.item.globalKey),
|
|
||||||
);
|
|
||||||
return WatchStateStore.applyPatch(widget.item, patch);
|
return WatchStateStore.applyPatch(widget.item, patch);
|
||||||
} on ProviderNotFoundException {
|
} on ProviderNotFoundException {
|
||||||
return widget.item;
|
return widget.item;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../mpv/mpv.dart';
|
|||||||
import '../models/transcode_quality_preset.dart';
|
import '../models/transcode_quality_preset.dart';
|
||||||
import '../providers/download_provider.dart';
|
import '../providers/download_provider.dart';
|
||||||
import '../providers/multi_server_provider.dart';
|
import '../providers/multi_server_provider.dart';
|
||||||
|
import '../providers/watch_state_store.dart';
|
||||||
import '../screens/video_player_screen.dart';
|
import '../screens/video_player_screen.dart';
|
||||||
import '../services/external_player_service.dart';
|
import '../services/external_player_service.dart';
|
||||||
import '../services/offline_watch_sync_service.dart';
|
import '../services/offline_watch_sync_service.dart';
|
||||||
@@ -127,6 +128,9 @@ Future<void> saveMediaVersionIndexFor(MediaItem metadata, int index) async {
|
|||||||
/// - [usePushReplacement]: If true, replaces current route instead of pushing;
|
/// - [usePushReplacement]: If true, replaces current route instead of pushing;
|
||||||
/// useful for episode-to-episode navigation. Defaults to false.
|
/// useful for episode-to-episode navigation. Defaults to false.
|
||||||
/// - [isOffline]: If true, plays from downloaded content without requiring server connection.
|
/// - [isOffline]: If true, plays from downloaded content without requiring server connection.
|
||||||
|
/// - [resolveWatchState]: Resolve [metadata] through [WatchStateStore] so the
|
||||||
|
/// resume offset/watched flag are session-fresh even when the caller holds a
|
||||||
|
/// stale list snapshot. Pass false for explicit intents like play-from-start.
|
||||||
///
|
///
|
||||||
/// Returns a Future that completes with a boolean indicating whether the content
|
/// Returns a Future that completes with a boolean indicating whether the content
|
||||||
/// was watched, or null if navigation was cancelled.
|
/// was watched, or null if navigation was cancelled.
|
||||||
@@ -141,7 +145,15 @@ Future<bool?> navigateToVideoPlayer(
|
|||||||
TranscodeQualityPreset? selectedQualityPreset,
|
TranscodeQualityPreset? selectedQualityPreset,
|
||||||
bool usePushReplacement = false,
|
bool usePushReplacement = false,
|
||||||
bool isOffline = false,
|
bool isOffline = false,
|
||||||
|
bool resolveWatchState = true,
|
||||||
}) async {
|
}) async {
|
||||||
|
if (resolveWatchState) {
|
||||||
|
try {
|
||||||
|
metadata = context.read<WatchStateStore>().apply(metadata);
|
||||||
|
} on ProviderNotFoundException {
|
||||||
|
// Tests or trees without the store play the snapshot as-is.
|
||||||
|
}
|
||||||
|
}
|
||||||
final navigator = Navigator.of(context);
|
final navigator = Navigator.of(context);
|
||||||
final downloadProvider = context.read<DownloadProvider>();
|
final downloadProvider = context.read<DownloadProvider>();
|
||||||
// Use the manager-routed lookup so Jellyfin items don't trip the
|
// Use the manager-routed lookup so Jellyfin items don't trip the
|
||||||
|
|||||||
@@ -60,9 +60,7 @@ class EpisodeCard extends StatefulWidget {
|
|||||||
class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<EpisodeCard> {
|
class _EpisodeCardState extends State<EpisodeCard> with ContextMenuTapMixin<EpisodeCard> {
|
||||||
MediaItem _effectiveEpisode(BuildContext context) {
|
MediaItem _effectiveEpisode(BuildContext context) {
|
||||||
try {
|
try {
|
||||||
final patch = context.select<WatchStateStore, WatchStatePatch?>(
|
final patch = context.select<WatchStateStore, WatchStatePatch?>((store) => store.patchForItem(widget.episode));
|
||||||
(provider) => provider.patchForGlobalKey(widget.episode.globalKey),
|
|
||||||
);
|
|
||||||
return WatchStateStore.applyPatch(widget.episode, patch);
|
return WatchStateStore.applyPatch(widget.episode, patch);
|
||||||
} on ProviderNotFoundException {
|
} on ProviderNotFoundException {
|
||||||
return widget.episode;
|
return widget.episode;
|
||||||
|
|||||||
@@ -97,9 +97,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
|||||||
final item = widget.item;
|
final item = widget.item;
|
||||||
if (item is! MediaItem) return item;
|
if (item is! MediaItem) return item;
|
||||||
try {
|
try {
|
||||||
final patch = context.select<WatchStateStore, WatchStatePatch?>(
|
final patch = context.select<WatchStateStore, WatchStatePatch?>((store) => store.patchForItem(item));
|
||||||
(provider) => provider.patchForGlobalKey(item.globalKey),
|
|
||||||
);
|
|
||||||
return WatchStateStore.applyPatch(item, patch);
|
return WatchStateStore.applyPatch(item, patch);
|
||||||
} on ProviderNotFoundException {
|
} on ProviderNotFoundException {
|
||||||
return item;
|
return item;
|
||||||
|
|||||||
@@ -526,7 +526,11 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
|||||||
case 'play_from_beginning':
|
case 'play_from_beginning':
|
||||||
didNavigate = true;
|
didNavigate = true;
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
await navigateToVideoPlayer(context, metadata: mediaItem!.copyWith(viewOffsetMs: 0));
|
await navigateToVideoPlayer(
|
||||||
|
context,
|
||||||
|
metadata: mediaItem!.copyWith(viewOffsetMs: 0),
|
||||||
|
resolveWatchState: false,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: plezy
|
name: plezy
|
||||||
description: "A beautiful Plex and Jellyfin client for Flutter"
|
description: "A beautiful Plex and Jellyfin client for Flutter"
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 2.5.0+110
|
version: 2.5.1+111
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=3.12.0 <4.0.0"
|
sdk: ">=3.12.0 <4.0.0"
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'package:drift/native.dart';
|
||||||
import 'package:plezy/media/ids.dart';
|
import 'package:plezy/media/ids.dart';
|
||||||
|
|
||||||
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';
|
||||||
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import 'package:plezy/database/app_database.dart';
|
||||||
import 'package:plezy/i18n/strings.g.dart';
|
import 'package:plezy/i18n/strings.g.dart';
|
||||||
import 'package:plezy/media/library_query.dart';
|
import 'package:plezy/media/library_query.dart';
|
||||||
import 'package:plezy/media/media_backend.dart';
|
import 'package:plezy/media/media_backend.dart';
|
||||||
@@ -11,15 +14,24 @@ import 'package:plezy/media/media_hub.dart';
|
|||||||
import 'package:plezy/media/media_item.dart';
|
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/server_capabilities.dart';
|
||||||
|
import 'package:plezy/providers/download_provider.dart';
|
||||||
import 'package:plezy/providers/multi_server_provider.dart';
|
import 'package:plezy/providers/multi_server_provider.dart';
|
||||||
|
import 'package:plezy/providers/watch_state_store.dart';
|
||||||
import 'package:plezy/screens/media_detail_screen.dart';
|
import 'package:plezy/screens/media_detail_screen.dart';
|
||||||
import 'package:plezy/services/data_aggregation_service.dart';
|
import 'package:plezy/services/data_aggregation_service.dart';
|
||||||
|
import 'package:plezy/services/download_manager_service.dart';
|
||||||
|
import 'package:plezy/services/download_storage_service.dart';
|
||||||
|
import 'package:plezy/services/jellyfin_api_cache.dart';
|
||||||
import 'package:plezy/services/multi_server_manager.dart';
|
import 'package:plezy/services/multi_server_manager.dart';
|
||||||
|
import 'package:plezy/services/plex_api_cache.dart';
|
||||||
import 'package:plezy/services/settings_service.dart';
|
import 'package:plezy/services/settings_service.dart';
|
||||||
import 'package:plezy/theme/mono_theme.dart';
|
import 'package:plezy/theme/mono_theme.dart';
|
||||||
import 'package:plezy/utils/layout_constants.dart';
|
import 'package:plezy/utils/layout_constants.dart';
|
||||||
import 'package:plezy/utils/media_server_http_client.dart';
|
import 'package:plezy/utils/media_server_http_client.dart';
|
||||||
import 'package:plezy/utils/platform_detector.dart';
|
import 'package:plezy/utils/platform_detector.dart';
|
||||||
|
import 'package:plezy/utils/watch_state_notifier.dart';
|
||||||
|
import 'package:plezy/widgets/episode_card.dart';
|
||||||
import 'package:plezy/widgets/tv_browse_rail.dart';
|
import 'package:plezy/widgets/tv_browse_rail.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
@@ -514,6 +526,227 @@ void main() {
|
|||||||
|
|
||||||
expect(find.text('Episode 2'), findsOneWidget);
|
expect(find.text('Episode 2'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('watch state freshness (phone layout)', () {
|
||||||
|
MediaItem buildShow() => MediaItem(
|
||||||
|
id: 'show_1',
|
||||||
|
backend: MediaBackend.jellyfin,
|
||||||
|
kind: MediaKind.show,
|
||||||
|
title: 'The Show',
|
||||||
|
leafCount: 4,
|
||||||
|
viewedLeafCount: 0,
|
||||||
|
serverId: 'server_1',
|
||||||
|
serverName: 'Server',
|
||||||
|
);
|
||||||
|
|
||||||
|
MediaItem buildSeason(MediaItem show, int index) => MediaItem(
|
||||||
|
id: 'season_$index',
|
||||||
|
backend: MediaBackend.jellyfin,
|
||||||
|
kind: MediaKind.season,
|
||||||
|
title: 'Season $index',
|
||||||
|
index: index,
|
||||||
|
leafCount: 2,
|
||||||
|
viewedLeafCount: 0,
|
||||||
|
parentId: show.id,
|
||||||
|
serverId: show.serverId,
|
||||||
|
serverName: show.serverName,
|
||||||
|
);
|
||||||
|
|
||||||
|
MediaItem buildEpisode(MediaItem show, MediaItem season, int index) => MediaItem(
|
||||||
|
id: '${season.id}_episode_$index',
|
||||||
|
backend: MediaBackend.jellyfin,
|
||||||
|
kind: MediaKind.episode,
|
||||||
|
title: 'Episode S${season.index}E$index',
|
||||||
|
index: index,
|
||||||
|
durationMs: 30 * 60 * 1000,
|
||||||
|
parentId: season.id,
|
||||||
|
parentIndex: season.index,
|
||||||
|
grandparentId: show.id,
|
||||||
|
serverId: show.serverId,
|
||||||
|
serverName: show.serverName,
|
||||||
|
);
|
||||||
|
|
||||||
|
Future<void> pumpPhoneDetail(WidgetTester tester, _FakeMediaServerClient client, MediaItem show) async {
|
||||||
|
TvDetectionService.debugSetAppleTVOverride(false);
|
||||||
|
await SettingsService.getInstance();
|
||||||
|
tester.view.physicalSize = const Size(1100, 2400);
|
||||||
|
tester.view.devicePixelRatio = 1;
|
||||||
|
addTearDown(tester.view.resetPhysicalSize);
|
||||||
|
addTearDown(tester.view.resetDevicePixelRatio);
|
||||||
|
|
||||||
|
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||||
|
PlexApiCache.initialize(db);
|
||||||
|
JellyfinApiCache.initialize(db);
|
||||||
|
final downloadManager = DownloadManagerService(database: db, storageService: DownloadStorageService.instance);
|
||||||
|
downloadManager.recoveryFuture = Future<void>.value();
|
||||||
|
final downloadProvider = DownloadProvider.forTesting(downloadManager: downloadManager, database: db);
|
||||||
|
await downloadProvider.ensureInitialized();
|
||||||
|
|
||||||
|
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
|
||||||
|
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
|
||||||
|
final watchStateOverlay = WatchStateStore();
|
||||||
|
|
||||||
|
addTearDown(() async {
|
||||||
|
watchStateOverlay.dispose();
|
||||||
|
downloadProvider.dispose();
|
||||||
|
downloadManager.dispose();
|
||||||
|
multiServerProvider.dispose();
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
TranslationProvider(
|
||||||
|
child: MultiProvider(
|
||||||
|
providers: [
|
||||||
|
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
|
||||||
|
ChangeNotifierProvider<DownloadProvider>.value(value: downloadProvider),
|
||||||
|
ChangeNotifierProvider<WatchStateStore>.value(value: watchStateOverlay),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
theme: monoTheme(dark: true),
|
||||||
|
home: MediaDetailScreen(metadata: show),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
Finder episodeCardFor(String title) => find.ancestor(of: find.text(title), matching: find.byType(EpisodeCard));
|
||||||
|
|
||||||
|
bool episodeRowWatched(WidgetTester tester, String title) {
|
||||||
|
final card = episodeCardFor(title);
|
||||||
|
expect(card, findsOneWidget, reason: 'episode row "$title" should be visible');
|
||||||
|
return tester.any(find.descendant(of: card, matching: find.byIcon(Symbols.check_rounded)));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool episodeRowHasProgress(WidgetTester tester, String title) {
|
||||||
|
final card = episodeCardFor(title);
|
||||||
|
expect(card, findsOneWidget, reason: 'episode row "$title" should be visible');
|
||||||
|
return tester.any(find.descendant(of: card, matching: find.byType(LinearProgressIndicator)));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> emit(WidgetTester tester, void Function() send) async {
|
||||||
|
send();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
testWidgets('marking the show watched flips every visible episode row', (tester) async {
|
||||||
|
final show = buildShow();
|
||||||
|
final season1 = buildSeason(show, 1);
|
||||||
|
final season2 = buildSeason(show, 2);
|
||||||
|
final episodes = [buildEpisode(show, season1, 1), buildEpisode(show, season1, 2)];
|
||||||
|
final client = _FakeMediaServerClient(
|
||||||
|
show: show,
|
||||||
|
childrenByParent: {
|
||||||
|
show.id: [season1, season2],
|
||||||
|
season1.id: episodes,
|
||||||
|
season2.id: [buildEpisode(show, season2, 1), buildEpisode(show, season2, 2)],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await pumpPhoneDetail(tester, client, show);
|
||||||
|
expect(episodeRowWatched(tester, 'Episode S1E1'), isFalse);
|
||||||
|
expect(episodeRowWatched(tester, 'Episode S1E2'), isFalse);
|
||||||
|
|
||||||
|
await emit(tester, () => WatchStateNotifier().notifyWatched(item: show, isNowWatched: true));
|
||||||
|
|
||||||
|
expect(episodeRowWatched(tester, 'Episode S1E1'), isTrue);
|
||||||
|
expect(episodeRowWatched(tester, 'Episode S1E2'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('container mark overrides an older per-episode patch', (tester) async {
|
||||||
|
final show = buildShow();
|
||||||
|
final season1 = buildSeason(show, 1);
|
||||||
|
final episode1 = buildEpisode(show, season1, 1);
|
||||||
|
final episode2 = buildEpisode(show, season1, 2);
|
||||||
|
final client = _FakeMediaServerClient(
|
||||||
|
show: show,
|
||||||
|
childrenByParent: {
|
||||||
|
show.id: [season1, buildSeason(show, 2)],
|
||||||
|
season1.id: [episode1, episode2],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await pumpPhoneDetail(tester, client, show);
|
||||||
|
|
||||||
|
// Seed a session patch for one episode (e.g. user toggled it earlier).
|
||||||
|
await emit(tester, () => WatchStateNotifier().notifyWatched(item: episode1, isNowWatched: false));
|
||||||
|
expect(episodeRowWatched(tester, 'Episode S1E1'), isFalse);
|
||||||
|
|
||||||
|
await emit(tester, () => WatchStateNotifier().notifyWatched(item: show, isNowWatched: true));
|
||||||
|
|
||||||
|
expect(episodeRowWatched(tester, 'Episode S1E1'), isTrue);
|
||||||
|
expect(episodeRowWatched(tester, 'Episode S1E2'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('marking a season watched flips its episode rows', (tester) async {
|
||||||
|
final show = buildShow();
|
||||||
|
final season1 = buildSeason(show, 1);
|
||||||
|
final season2 = buildSeason(show, 2);
|
||||||
|
final client = _FakeMediaServerClient(
|
||||||
|
show: show,
|
||||||
|
childrenByParent: {
|
||||||
|
show.id: [season1, season2],
|
||||||
|
season1.id: [buildEpisode(show, season1, 1), buildEpisode(show, season1, 2)],
|
||||||
|
season2.id: [buildEpisode(show, season2, 1)],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await pumpPhoneDetail(tester, client, show);
|
||||||
|
|
||||||
|
await emit(tester, () => WatchStateNotifier().notifyWatched(item: season1, isNowWatched: true));
|
||||||
|
|
||||||
|
expect(episodeRowWatched(tester, 'Episode S1E1'), isTrue);
|
||||||
|
expect(episodeRowWatched(tester, 'Episode S1E2'), isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('container mark clears progress, including after a season tab round-trip', (tester) async {
|
||||||
|
final show = buildShow();
|
||||||
|
final season1 = buildSeason(show, 1);
|
||||||
|
final season2 = buildSeason(show, 2);
|
||||||
|
final episode1 = buildEpisode(show, season1, 1);
|
||||||
|
final client = _FakeMediaServerClient(
|
||||||
|
show: show,
|
||||||
|
childrenByParent: {
|
||||||
|
show.id: [season1, season2],
|
||||||
|
season1.id: [episode1, buildEpisode(show, season1, 2)],
|
||||||
|
season2.id: [buildEpisode(show, season2, 1)],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
await pumpPhoneDetail(tester, client, show);
|
||||||
|
|
||||||
|
// Played partway earlier in the session.
|
||||||
|
await emit(
|
||||||
|
tester,
|
||||||
|
() => WatchStateNotifier().notifyProgress(item: episode1, viewOffset: 600000, duration: 1800000),
|
||||||
|
);
|
||||||
|
expect(episodeRowHasProgress(tester, 'Episode S1E1'), isTrue);
|
||||||
|
|
||||||
|
await emit(tester, () => WatchStateNotifier().notifyWatched(item: show, isNowWatched: true));
|
||||||
|
expect(episodeRowHasProgress(tester, 'Episode S1E1'), isFalse);
|
||||||
|
expect(episodeRowWatched(tester, 'Episode S1E1'), isTrue);
|
||||||
|
|
||||||
|
// Round-trip through another season tab; the cached page restore must not
|
||||||
|
// resurrect the dead progress offset.
|
||||||
|
await tester.tap(find.text('Season 2'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
await tester.tap(find.text('Season 1'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
|
||||||
|
expect(episodeRowHasProgress(tester, 'Episode S1E1'), isFalse);
|
||||||
|
expect(episodeRowWatched(tester, 'Episode S1E1'), isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeMediaServerClient implements MediaServerClient {
|
class _FakeMediaServerClient implements MediaServerClient {
|
||||||
@@ -541,6 +774,9 @@ class _FakeMediaServerClient implements MediaServerClient {
|
|||||||
@override
|
@override
|
||||||
MediaBackend get backend => MediaBackend.jellyfin;
|
MediaBackend get backend => MediaBackend.jellyfin;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ServerCapabilities get capabilities => ServerCapabilities.jellyfin;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async {
|
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async {
|
||||||
return (item: show, onDeckEpisode: null);
|
return (item: show, onDeckEpisode: null);
|
||||||
|
|||||||
Reference in New Issue
Block a user