fix(tv): stabilize browse rail focus and loading
This commit is contained in:
@@ -196,6 +196,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
// Track initial load so we can focus hero when content first appears
|
||||
bool _initialLoadComplete = false;
|
||||
bool _pendingTvBrowseRailFocus = false;
|
||||
|
||||
// Hub navigation keys
|
||||
GlobalKey<HubSectionState>? _continueWatchingHubKey;
|
||||
@@ -316,7 +317,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
void _focusContentFromAppBar() {
|
||||
if (PlatformDetector.isTV()) {
|
||||
_tvBrowseRailKey.currentState?.requestFocus();
|
||||
_focusTvBrowseRailWhenReady();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -331,6 +332,25 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
}
|
||||
|
||||
void _focusTvBrowseRailWhenReady() {
|
||||
if (!PlatformDetector.isTV()) return;
|
||||
if (!(ModalRoute.of(context)?.isCurrent ?? false)) return;
|
||||
|
||||
_pendingTvBrowseRailFocus = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) return;
|
||||
if (_tvBrowseHubs.isEmpty) return;
|
||||
final rail = _tvBrowseRailKey.currentState;
|
||||
if (rail == null) return;
|
||||
_pendingTvBrowseRailFocus = false;
|
||||
rail.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
void _applyPendingTvBrowseRailFocus() {
|
||||
if (_pendingTvBrowseRailFocus) _focusTvBrowseRailWhenReady();
|
||||
}
|
||||
|
||||
/// Handle vertical navigation between hubs
|
||||
/// Returns true if the navigation was handled
|
||||
bool _handleVerticalNavigation(int hubIndex, bool isUp) {
|
||||
@@ -601,6 +621,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
@override
|
||||
void focusActiveTabIfReady() {
|
||||
if (PlatformDetector.isTV()) {
|
||||
_focusTvBrowseRailWhenReady();
|
||||
return;
|
||||
}
|
||||
_focusTopBoundary();
|
||||
}
|
||||
|
||||
@@ -703,6 +727,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_continueWatchingHubKey ??= GlobalKey<HubSectionState>();
|
||||
}
|
||||
});
|
||||
_applyPendingTvBrowseRailFocus();
|
||||
|
||||
// Focus hero section now that it's visible, but only if no modal route is on top
|
||||
if (!PlatformDetector.isTV() && onDeck.isNotEmpty && (ModalRoute.of(context)?.isCurrent ?? false)) {
|
||||
@@ -759,6 +784,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_areHubsLoading = false;
|
||||
_updateHubKeys();
|
||||
});
|
||||
_applyPendingTvBrowseRailFocus();
|
||||
|
||||
if (PlatformDetector.isTV() && !_initialLoadComplete && filteredHubs.isNotEmpty) {
|
||||
_initialLoadComplete = true;
|
||||
|
||||
@@ -140,6 +140,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
// Inline season tabs
|
||||
int _selectedSeasonIndex = 0;
|
||||
final Map<String, List<MediaItem>> _episodeCache = {};
|
||||
final Set<String> _seasonEpisodeLoadsInFlight = {};
|
||||
bool _isLoadingSeasonEpisodes = false;
|
||||
List<FocusNode> _seasonTabFocusNodes = [];
|
||||
|
||||
@@ -728,8 +729,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
if (_showEpisodesDirectly) return _hasLoadedEpisodes && !_isLoadingEpisodes;
|
||||
if (_seasons.isEmpty) return true;
|
||||
if (_selectedSeasonIndex < 0 || _selectedSeasonIndex >= _seasons.length) return false;
|
||||
final selectedSeason = _seasons[_selectedSeasonIndex];
|
||||
return !_isLoadingSeasonEpisodes && _episodeCache.containsKey(selectedSeason.id);
|
||||
return !_isLoadingSeasonEpisodes && _seasons.every((season) => _episodeCache.containsKey(season.id));
|
||||
}
|
||||
|
||||
if (metadata.isSeason) {
|
||||
@@ -1434,8 +1434,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
if (shouldShowEpisodesDirectly) {
|
||||
await _fetchAllEpisodes();
|
||||
} else if (seasonsWithServerId.isNotEmpty) {
|
||||
// Fetch episodes for the auto-selected season
|
||||
unawaited(_fetchSeasonEpisodes(onDeckSeasonIndex));
|
||||
if (PlatformDetector.isTV()) {
|
||||
await _fetchTvSeasonEpisodeCaches(onDeckSeasonIndex);
|
||||
} else {
|
||||
// Fetch episodes for the auto-selected season
|
||||
unawaited(_fetchSeasonEpisodes(onDeckSeasonIndex));
|
||||
}
|
||||
}
|
||||
} catch (e, st) {
|
||||
appLogger.w('Seasons load failed', error: e, stackTrace: st);
|
||||
@@ -1573,6 +1577,13 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_seasonEpisodeLoadsInFlight.add(seasonId)) {
|
||||
setStateIfMounted(() {
|
||||
if (_isSelectedSeason(seasonIndex, seasonId)) _isLoadingSeasonEpisodes = true;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setStateIfMounted(() {
|
||||
if (_isSelectedSeason(seasonIndex, seasonId)) _isLoadingSeasonEpisodes = true;
|
||||
});
|
||||
@@ -1617,6 +1628,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
}
|
||||
} catch (e) {
|
||||
_completeSeasonEpisodesLoad(seasonIndex: seasonIndex, seasonId: seasonId, episodes: const <MediaItem>[]);
|
||||
} finally {
|
||||
_seasonEpisodeLoadsInFlight.remove(seasonId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1627,6 +1640,80 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
_seasons[seasonIndex].id == seasonId;
|
||||
}
|
||||
|
||||
Future<void> _fetchTvSeasonEpisodeCaches(int selectedSeasonIndex) async {
|
||||
if (widget.isOffline || _showEpisodesDirectly || _seasons.isEmpty) return;
|
||||
|
||||
setStateIfMounted(() {
|
||||
_isLoadingSeasonEpisodes = true;
|
||||
});
|
||||
|
||||
final serverId = _metadata.serverId;
|
||||
final client = serverId == null ? null : context.tryGetMediaClientForServer(serverId);
|
||||
if (serverId == null || client == null) {
|
||||
_completeTvSeasonEpisodeCaches(selectedSeasonIndex, const <String, List<MediaItem>>{});
|
||||
return;
|
||||
}
|
||||
|
||||
final seasonsById = {for (final season in _seasons) season.id: season};
|
||||
final seasonsByIndex = <int, MediaItem>{
|
||||
for (final season in _seasons)
|
||||
if (season.index != null) season.index!: season,
|
||||
};
|
||||
final episodesBySeasonId = {for (final season in _seasons) season.id: <MediaItem>[]};
|
||||
|
||||
try {
|
||||
var offset = 0;
|
||||
var total = 0;
|
||||
do {
|
||||
final page = await client.fetchPlayableDescendantsPage(_metadata.id, start: offset, size: _episodesPageSize);
|
||||
if (!mounted) return;
|
||||
if (page.items.isEmpty) break;
|
||||
|
||||
final enriched = _enrichPlayableEpisodes(page.items, serverId);
|
||||
for (final episode in enriched) {
|
||||
final seasonId = _seasonIdForEpisode(episode, seasonsById: seasonsById, seasonsByIndex: seasonsByIndex);
|
||||
if (seasonId == null) continue;
|
||||
episodesBySeasonId[seasonId]?.add(episode);
|
||||
}
|
||||
|
||||
offset += page.items.length;
|
||||
total = page.totalCount;
|
||||
} while (offset < total && total > 0);
|
||||
|
||||
_completeTvSeasonEpisodeCaches(selectedSeasonIndex, episodesBySeasonId);
|
||||
} catch (e, st) {
|
||||
appLogger.w('Failed to load TV season episode caches', error: e, stackTrace: st);
|
||||
_completeTvSeasonEpisodeCaches(selectedSeasonIndex, const <String, List<MediaItem>>{});
|
||||
}
|
||||
}
|
||||
|
||||
String? _seasonIdForEpisode(
|
||||
MediaItem episode, {
|
||||
required Map<String, MediaItem> seasonsById,
|
||||
required Map<int, MediaItem> seasonsByIndex,
|
||||
}) {
|
||||
final parentId = episode.parentId;
|
||||
if (parentId != null && seasonsById.containsKey(parentId)) return parentId;
|
||||
|
||||
final parentIndex = episode.parentIndex;
|
||||
if (parentIndex != null) return seasonsByIndex[parentIndex]?.id;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
void _completeTvSeasonEpisodeCaches(int selectedSeasonIndex, Map<String, List<MediaItem>> episodesBySeasonId) {
|
||||
setStateIfMounted(() {
|
||||
for (final season in _seasons) {
|
||||
_episodeCache[season.id] = List.of(episodesBySeasonId[season.id] ?? const <MediaItem>[]);
|
||||
}
|
||||
|
||||
if (selectedSeasonIndex >= 0 && selectedSeasonIndex < _seasons.length) {
|
||||
_episodes = List.of(_episodeCache[_seasons[selectedSeasonIndex].id] ?? const <MediaItem>[]);
|
||||
}
|
||||
_isLoadingSeasonEpisodes = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _completeSeasonEpisodesLoad({
|
||||
required int seasonIndex,
|
||||
required String seasonId,
|
||||
@@ -3187,7 +3274,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||
if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty) {
|
||||
for (var i = 0; i < _seasons.length; i++) {
|
||||
final season = _seasons[i];
|
||||
final episodes = i == _selectedSeasonIndex ? _episodes : (_episodeCache[season.id] ?? const <MediaItem>[]);
|
||||
final cachedEpisodes = _episodeCache[season.id];
|
||||
if (cachedEpisodes == null) continue;
|
||||
final episodes = i == _selectedSeasonIndex ? _episodes : cachedEpisodes;
|
||||
hubs.add(
|
||||
MediaHub(
|
||||
id: '$_tvDetailSeasonHubIdPrefix$i',
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import 'package:drift/native.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/connection/connection.dart';
|
||||
import 'package:plezy/connection/connection_registry.dart';
|
||||
import 'package:plezy/database/app_database.dart';
|
||||
import 'package:plezy/focus/focusable_action_bar.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_hub.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/media/server_capabilities.dart';
|
||||
import 'package:plezy/mixins/refreshable.dart';
|
||||
import 'package:plezy/profiles/active_profile_provider.dart';
|
||||
import 'package:plezy/profiles/plex_home_service.dart';
|
||||
import 'package:plezy/profiles/profile.dart';
|
||||
import 'package:plezy/profiles/profile_connection.dart';
|
||||
import 'package:plezy/profiles/profile_connection_registry.dart';
|
||||
import 'package:plezy/profiles/profile_registry.dart';
|
||||
import 'package:plezy/providers/companion_remote_provider.dart';
|
||||
import 'package:plezy/providers/hidden_libraries_provider.dart';
|
||||
import 'package:plezy/providers/libraries_provider.dart';
|
||||
import 'package:plezy/providers/multi_server_provider.dart';
|
||||
import 'package:plezy/screens/discover_screen.dart';
|
||||
import 'package:plezy/services/data_aggregation_service.dart';
|
||||
import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/services/storage_service.dart';
|
||||
import 'package:plezy/theme/mono_theme.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:plezy/watch_together/watch_together.dart';
|
||||
import 'package:plezy/widgets/tv_browse_rail.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
});
|
||||
|
||||
testWidgets('TV tab focus returns to discover browse rail instead of reload action', (tester) async {
|
||||
await SettingsService.getInstance();
|
||||
|
||||
final item = MediaItem(
|
||||
id: 'movie_1',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.movie,
|
||||
title: 'Movie 1',
|
||||
serverId: 'server_1',
|
||||
serverName: 'Server',
|
||||
);
|
||||
final hub = MediaHub(id: 'hub_1', title: 'Recommended', type: 'movie', items: [item], size: 1);
|
||||
final client = _FakeMediaServerClient(hubs: [hub]);
|
||||
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
|
||||
final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager));
|
||||
final hiddenLibrariesProvider = HiddenLibrariesProvider();
|
||||
final librariesProvider = LibrariesProvider();
|
||||
final watchTogetherProvider = WatchTogetherProvider();
|
||||
final companionRemoteProvider = CompanionRemoteProvider();
|
||||
|
||||
final db = AppDatabase.forTesting(NativeDatabase.memory());
|
||||
final profileRegistry = _FakeProfileRegistry(db);
|
||||
final connectionRegistry = _FakeConnectionRegistry(db);
|
||||
final profileConnectionRegistry = _FakeProfileConnectionRegistry(db);
|
||||
final storage = await StorageService.getInstance();
|
||||
final plexHome = PlexHomeService(
|
||||
connections: connectionRegistry,
|
||||
profileConnections: profileConnectionRegistry,
|
||||
storage: storage,
|
||||
plexHomeUserFetcher: (_) async => const [],
|
||||
);
|
||||
final activeProfileProvider = ActiveProfileProvider(
|
||||
registry: profileRegistry,
|
||||
plexHome: plexHome,
|
||||
connections: connectionRegistry,
|
||||
storage: storage,
|
||||
);
|
||||
final discoverKey = GlobalKey<State<DiscoverScreen>>();
|
||||
|
||||
addTearDown(() async {
|
||||
activeProfileProvider.dispose();
|
||||
companionRemoteProvider.dispose();
|
||||
watchTogetherProvider.dispose();
|
||||
librariesProvider.dispose();
|
||||
hiddenLibrariesProvider.dispose();
|
||||
multiServerProvider.dispose();
|
||||
await plexHome.dispose();
|
||||
await db.close();
|
||||
});
|
||||
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
|
||||
ChangeNotifierProvider<HiddenLibrariesProvider>.value(value: hiddenLibrariesProvider),
|
||||
ChangeNotifierProvider<LibrariesProvider>.value(value: librariesProvider),
|
||||
ChangeNotifierProvider<WatchTogetherProvider>.value(value: watchTogetherProvider),
|
||||
ChangeNotifierProvider<CompanionRemoteProvider>.value(value: companionRemoteProvider),
|
||||
ChangeNotifierProvider<ActiveProfileProvider>.value(value: activeProfileProvider),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: SizedBox(width: 1280, height: 720, child: DiscoverScreen(key: discoverKey)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byType(TvBrowseRail), findsOneWidget);
|
||||
|
||||
tester.state<FocusableActionBarState>(find.byType(FocusableActionBar)).requestFocusOnFirst();
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]');
|
||||
|
||||
(discoverKey.currentState! as FocusableTab).focusActiveTabIfReady();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail');
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeMediaServerClient implements MediaServerClient {
|
||||
final List<MediaHub> hubs;
|
||||
|
||||
_FakeMediaServerClient({required this.hubs});
|
||||
|
||||
@override
|
||||
String get serverId => 'server_1';
|
||||
|
||||
@override
|
||||
String? get serverName => 'Server';
|
||||
|
||||
@override
|
||||
MediaBackend get backend => MediaBackend.plex;
|
||||
|
||||
@override
|
||||
ServerCapabilities get capabilities => ServerCapabilities.plex;
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchContinueWatching({int? count = 20}) async => const [];
|
||||
|
||||
@override
|
||||
Future<List<MediaHub>> fetchGlobalHubs({int limit = 10, bool includePlaybackHubs = true}) async => hubs;
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
class _FakeProfileRegistry extends ProfileRegistry {
|
||||
_FakeProfileRegistry(super.db);
|
||||
|
||||
@override
|
||||
Stream<List<Profile>> watchProfiles() => Stream.value(const []);
|
||||
|
||||
@override
|
||||
Future<List<Profile>> list() async => const [];
|
||||
}
|
||||
|
||||
class _FakeConnectionRegistry extends ConnectionRegistry {
|
||||
_FakeConnectionRegistry(super.db);
|
||||
|
||||
@override
|
||||
Stream<List<Connection>> watchConnections() => Stream.value(const []);
|
||||
|
||||
@override
|
||||
Future<List<Connection>> list() async => const [];
|
||||
}
|
||||
|
||||
class _FakeProfileConnectionRegistry extends ProfileConnectionRegistry {
|
||||
_FakeProfileConnectionRegistry(super.db);
|
||||
|
||||
@override
|
||||
Stream<List<ProfileConnection>> watchAll() => Stream.value(const []);
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/i18n/strings.g.dart';
|
||||
import 'package:plezy/media/library_query.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_hub.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/media/media_server_client.dart';
|
||||
import 'package:plezy/providers/multi_server_provider.dart';
|
||||
import 'package:plezy/screens/media_detail_screen.dart';
|
||||
import 'package:plezy/services/data_aggregation_service.dart';
|
||||
import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/theme/mono_theme.dart';
|
||||
import 'package:plezy/utils/media_server_http_client.dart';
|
||||
import 'package:plezy/utils/platform_detector.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
TvDetectionService.debugSetAppleTVOverride(true);
|
||||
LocaleSettings.setLocaleSync(AppLocale.en);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
TvDetectionService.debugSetAppleTVOverride(null);
|
||||
});
|
||||
|
||||
testWidgets('TV detail reveals season hubs together after all episode caches load', (tester) async {
|
||||
await SettingsService.getInstance();
|
||||
|
||||
final show = MediaItem(
|
||||
id: 'show_1',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.show,
|
||||
title: 'The Show',
|
||||
serverId: 'server_1',
|
||||
serverName: 'Server',
|
||||
);
|
||||
final season1 = MediaItem(
|
||||
id: 'season_1',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.season,
|
||||
title: 'Season 1',
|
||||
index: 1,
|
||||
parentId: show.id,
|
||||
serverId: show.serverId,
|
||||
serverName: show.serverName,
|
||||
);
|
||||
final season2 = MediaItem(
|
||||
id: 'season_2',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.season,
|
||||
title: 'Season 2',
|
||||
index: 2,
|
||||
parentId: show.id,
|
||||
serverId: show.serverId,
|
||||
serverName: show.serverName,
|
||||
);
|
||||
final episode1 = MediaItem(
|
||||
id: 'episode_1',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.episode,
|
||||
title: 'Episode 1',
|
||||
index: 1,
|
||||
parentId: season1.id,
|
||||
parentIndex: season1.index,
|
||||
grandparentId: show.id,
|
||||
serverId: show.serverId,
|
||||
serverName: show.serverName,
|
||||
);
|
||||
final episode2 = MediaItem(
|
||||
id: 'episode_2',
|
||||
backend: MediaBackend.jellyfin,
|
||||
kind: MediaKind.episode,
|
||||
title: 'Episode 2',
|
||||
index: 1,
|
||||
parentId: season2.id,
|
||||
parentIndex: season2.index,
|
||||
grandparentId: show.id,
|
||||
serverId: show.serverId,
|
||||
serverName: show.serverName,
|
||||
);
|
||||
|
||||
final descendantsCompleter = Completer<List<MediaItem>>();
|
||||
final client = _FakeMediaServerClient(
|
||||
show: show,
|
||||
childrenByParent: {
|
||||
show.id: [season1, season2],
|
||||
},
|
||||
pendingPlayableDescendants: descendantsCompleter.future,
|
||||
);
|
||||
final manager = MultiServerManager()..debugRegisterClientForTesting(client);
|
||||
final provider = MultiServerProvider(manager, DataAggregationService(manager));
|
||||
addTearDown(provider.dispose);
|
||||
|
||||
await tester.pumpWidget(
|
||||
TranslationProvider(
|
||||
child: ChangeNotifierProvider<MultiServerProvider>.value(
|
||||
value: provider,
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: SizedBox(width: 1280, height: 720, child: MediaDetailScreen(metadata: show)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(find.text('Season 1'), findsNothing);
|
||||
expect(find.text('Season 2'), findsNothing);
|
||||
|
||||
descendantsCompleter.complete([episode1, episode2]);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
|
||||
expect(find.text('Season 1'), findsOneWidget);
|
||||
expect(find.text('Season 2'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeMediaServerClient implements MediaServerClient {
|
||||
final MediaItem show;
|
||||
final Map<String, List<MediaItem>> childrenByParent;
|
||||
final Future<List<MediaItem>> pendingPlayableDescendants;
|
||||
|
||||
_FakeMediaServerClient({
|
||||
required this.show,
|
||||
required this.childrenByParent,
|
||||
required this.pendingPlayableDescendants,
|
||||
});
|
||||
|
||||
@override
|
||||
String get serverId => 'server_1';
|
||||
|
||||
@override
|
||||
String? get serverName => 'Server';
|
||||
|
||||
@override
|
||||
MediaBackend get backend => MediaBackend.jellyfin;
|
||||
|
||||
@override
|
||||
Future<({MediaItem? item, MediaItem? onDeckEpisode})> fetchItemWithOnDeck(String id) async {
|
||||
return (item: show, onDeckEpisode: null);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchChildren(String parentId) async {
|
||||
return childrenByParent[parentId] ?? const [];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchPlayableDescendantsPage(
|
||||
String parentId, {
|
||||
int? start,
|
||||
int? size,
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final items = await pendingPlayableDescendants;
|
||||
return LibraryPage(items: items, totalCount: items.length, offset: start ?? 0);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaHub>> fetchRelatedHubs(String id, {int count = 10}) async => const [];
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
Reference in New Issue
Block a user