fix(tv): smooth sidebar expansion behavior

This commit is contained in:
edde746
2026-05-22 03:54:34 +02:00
parent 487c7b87f6
commit fea973a72a
17 changed files with 277 additions and 99 deletions
+1 -1
View File
@@ -84,7 +84,7 @@ mixin TabNavigationMixin<T extends StatefulWidget> on State<T>, TickerProviderSt
}
void onTabBarBack() {
MainScreenFocusScope.of(context)?.focusSidebar();
MainScreenFocusScope.of(context, listen: false)?.focusSidebar();
}
/// Shared tab chip builder — eliminates duplication between screens.
+33 -21
View File
@@ -316,7 +316,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
void _focusContentFromAppBar() {
if (PlatformDetector.isTV()) {
_focusTvBrowseRailWhenReady();
_focusTvBrowseRailWhenReady(immediate: true);
return;
}
@@ -331,7 +331,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
}
void _focusTvBrowseRailWhenReady() {
void _focusTvBrowseRailWhenReady({bool immediate = false}) {
if (!PlatformDetector.isTV()) return;
if (!_isTabVisible || !(ModalRoute.of(context)?.isCurrent ?? false)) {
_pendingTvBrowseRailFocus = false;
@@ -339,6 +339,15 @@ class _DiscoverScreenState extends State<DiscoverScreen>
}
_pendingTvBrowseRailFocus = true;
if (immediate && _tvBrowseHubs.isNotEmpty) {
final rail = _tvBrowseRailKey.currentState;
if (rail != null) {
_pendingTvBrowseRailFocus = false;
rail.requestFocus();
return;
}
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (!_isTabVisible || !(ModalRoute.of(context)?.isCurrent ?? false)) {
@@ -393,7 +402,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
/// Navigate focus to the sidebar
void _navigateToSidebar() {
MainScreenFocusScope.of(context)?.focusSidebar();
MainScreenFocusScope.of(context, listen: false)?.focusSidebar();
}
@override
@@ -1552,20 +1561,23 @@ class _DiscoverScreenState extends State<DiscoverScreen>
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
top: 0,
bottom: 0,
left: -sidebarBleed,
width: fullBleedWidth,
child: TvSpotlightBackground(
item: spotlight,
client: _getMediaClientForItem(spotlight),
hideSpoilers: hideSpoilers,
contentTop: spotlightTop,
contentBottom: spotlightBottom,
contentLeft: spotlightLeft + sidebarBleed,
compact: true,
showPrimaryAction: false,
SideNavigationBleedBuilder(
targetBleed: sidebarBleed,
builder: (context, animatedBleed, _) => Positioned(
top: 0,
bottom: 0,
left: -animatedBleed,
width: fullBleedWidth,
child: TvSpotlightBackground(
item: spotlight,
client: _getMediaClientForItem(spotlight),
hideSpoilers: hideSpoilers,
contentTop: spotlightTop,
contentBottom: spotlightBottom,
contentLeft: spotlightLeft + animatedBleed,
compact: true,
showPrimaryAction: false,
),
),
),
if (_isLoading || (_areHubsLoading && browseHubs.isEmpty)) const Center(child: CircularProgressIndicator()),
@@ -1617,11 +1629,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
backgroundBleedLeft: sidebarBleed,
),
),
Positioned(
top: 0,
left: -sidebarBleed,
width: fullBleedWidth,
SideNavigationBleedBuilder(
targetBleed: sidebarBleed,
child: ExcludeFocusTraversal(child: _buildOverlaidAppBar()),
builder: (context, animatedBleed, child) =>
Positioned(top: 0, left: -animatedBleed, width: fullBleedWidth, child: child!),
),
if (_switchingProfile) const ProfileSwitchingOverlay(),
],
+2 -2
View File
@@ -233,7 +233,7 @@ class DownloadsScreenState extends State<DownloadsScreen>
},
onCancel: downloadProvider.cancelDownload,
onDelete: downloadProvider.deleteDownload,
onNavigateLeft: () => MainScreenFocusScope.of(context)?.focusSidebar(),
onNavigateLeft: () => MainScreenFocusScope.of(context, listen: false)?.focusSidebar(),
onBack: focusTabBar,
suppressAutoFocus: suppressAutoFocus,
);
@@ -299,7 +299,7 @@ class _DownloadsGridContentState extends State<_DownloadsGridContent> {
/// Navigate focus to the sidebar
void _navigateToSidebar() {
MainScreenFocusScope.of(context)?.focusSidebar();
MainScreenFocusScope.of(context, listen: false)?.focusSidebar();
}
@override
@@ -954,7 +954,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
/// Navigate focus to the sidebar
void _navigateToSidebar() {
MainScreenFocusScope.of(context)?.focusSidebar();
MainScreenFocusScope.of(context, listen: false)?.focusSidebar();
}
/// Navigate focus to the alpha jump bar
@@ -176,7 +176,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
}
void _navigateToSidebar() {
MainScreenFocusScope.of(context)?.focusSidebar();
MainScreenFocusScope.of(context, listen: false)?.focusSidebar();
}
@override
@@ -178,7 +178,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
}
void _navigateToSidebar() {
MainScreenFocusScope.of(context)?.focusSidebar();
MainScreenFocusScope.of(context, listen: false)?.focusSidebar();
}
@override
@@ -245,7 +245,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
/// Navigate focus to the sidebar
void _navigateToSidebar() {
MainScreenFocusScope.of(context)?.focusSidebar();
MainScreenFocusScope.of(context, listen: false)?.focusSidebar();
}
// Extra top padding for focus decoration (scale + border extends beyond item bounds)
@@ -334,20 +334,23 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
Positioned(
top: 0,
bottom: 0,
left: -sidebarBleed,
width: fullBleedWidth,
child: TvSpotlightBackground(
item: spotlight,
client: client,
hideSpoilers: svc.read(SettingsService.hideSpoilers),
contentTop: spotlightTop,
contentBottom: spotlightBottom,
contentLeft: spotlightLeft + sidebarBleed,
compact: true,
showPrimaryAction: false,
SideNavigationBleedBuilder(
targetBleed: sidebarBleed,
builder: (context, animatedBleed, _) => Positioned(
top: 0,
bottom: 0,
left: -animatedBleed,
width: fullBleedWidth,
child: TvSpotlightBackground(
item: spotlight,
client: client,
hideSpoilers: svc.read(SettingsService.hideSpoilers),
contentTop: spotlightTop,
contentBottom: spotlightBottom,
contentLeft: spotlightLeft + animatedBleed,
compact: true,
showPrimaryAction: false,
),
),
),
if (tvHubs.isNotEmpty)
+23 -3
View File
@@ -79,8 +79,9 @@ class MainScreenFocusScope extends InheritedWidget {
required super.child,
});
static MainScreenFocusScope? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<MainScreenFocusScope>();
static MainScreenFocusScope? of(BuildContext context, {bool listen = true}) {
if (listen) return context.dependOnInheritedWidgetOfExactType<MainScreenFocusScope>();
return context.getElementForInheritedWidgetOfExactType<MainScreenFocusScope>()?.widget as MainScreenFocusScope?;
}
static double sideNavigationBleedOf(BuildContext context, {required bool alwaysKeepSidebarOpen}) {
@@ -116,6 +117,25 @@ class MainScreenFocusScope extends InheritedWidget {
}
}
class SideNavigationBleedBuilder extends StatelessWidget {
final double targetBleed;
final Widget? child;
final Widget Function(BuildContext context, double bleed, Widget? child) builder;
const SideNavigationBleedBuilder({super.key, required this.targetBleed, this.child, required this.builder});
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
tween: Tween(end: targetBleed),
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
child: child,
builder: builder,
);
}
}
@visibleForTesting
({double left, double width}) mainScreenSideNavigationContentLayout({
required double viewportWidth,
@@ -1312,7 +1332,7 @@ class _MainScreenState extends State<MainScreen>
focusSidebar: _focusSidebar,
focusContent: _focusContent,
isSidebarFocused: _isSidebarFocused,
sideNavigationWidth: contentLeftPadding,
sideNavigationWidth: targetContentOffset,
reservedSideNavigationWidth: reservedContentOffset,
foregroundWidth: contentLayout.width,
viewportWidth: viewportWidth,
+36 -34
View File
@@ -113,6 +113,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
bool _isLoadingEpisodes = false;
bool _isLoadingAllEpisodes = false;
int _episodesLoadGeneration = 0;
int _tvSeasonEpisodeCacheWarmGeneration = 0;
bool _showEpisodesDirectly = false;
MediaItem? _fullMetadata;
MediaItem? _onDeckEpisode;
@@ -729,7 +730,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
if (_showEpisodesDirectly) return _hasLoadedEpisodes && !_isLoadingEpisodes;
if (_seasons.isEmpty) return true;
if (_selectedSeasonIndex < 0 || _selectedSeasonIndex >= _seasons.length) return false;
return !_isLoadingSeasonEpisodes && _seasons.every((season) => _episodeCache.containsKey(season.id));
return !_isLoadingSeasonEpisodes && _episodeCache.containsKey(_seasons[_selectedSeasonIndex].id);
}
if (metadata.isSeason) {
@@ -1435,7 +1436,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
await _fetchAllEpisodes();
} else if (seasonsWithServerId.isNotEmpty) {
if (PlatformDetector.isTV()) {
await _fetchTvSeasonEpisodeCaches(onDeckSeasonIndex);
await _fetchSeasonEpisodes(onDeckSeasonIndex);
unawaited(_warmTvSeasonEpisodeCaches(onDeckSeasonIndex));
} else {
// Fetch episodes for the auto-selected season
unawaited(_fetchSeasonEpisodes(onDeckSeasonIndex));
@@ -1640,40 +1642,42 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
_seasons[seasonIndex].id == seasonId;
}
Future<void> _fetchTvSeasonEpisodeCaches(int selectedSeasonIndex) async {
Future<void> _warmTvSeasonEpisodeCaches(int selectedSeasonIndex) async {
if (widget.isOffline || _showEpisodesDirectly || _seasons.isEmpty) return;
final generation = ++_episodesLoadGeneration;
setStateIfMounted(() {
_isLoadingSeasonEpisodes = true;
});
final generation = ++_tvSeasonEpisodeCacheWarmGeneration;
final seasons = List<MediaItem>.of(_seasons);
final seasonIdsToWarm = {
for (var i = 0; i < seasons.length; i++)
if (i != selectedSeasonIndex && !_episodeCache.containsKey(seasons[i].id)) seasons[i].id,
};
if (seasonIdsToWarm.isEmpty) return;
final serverId = _metadata.serverId;
final client = serverId == null ? null : context.tryGetMediaClientForServer(serverId);
if (serverId == null || client == null) {
_completeTvSeasonEpisodeCaches(selectedSeasonIndex, const <String, List<MediaItem>>{}, generation);
return;
}
if (serverId == null || client == null) return;
final seasonsById = {for (final season in _seasons) season.id: season};
final seasonsById = {for (final season in seasons) season.id: season};
final seasonsByIndex = <int, MediaItem>{
for (final season in _seasons)
for (final season in seasons)
if (season.index != null) season.index!: season,
};
final episodesBySeasonId = {for (final season in _seasons) season.id: <MediaItem>[]};
final episodesBySeasonId = {
for (final season in seasons)
if (seasonIdsToWarm.contains(season.id)) season.id: <MediaItem>[],
};
try {
var offset = 0;
var total = 0;
do {
final page = await client.fetchPlayableDescendantsPage(_metadata.id, start: offset, size: _episodesPageSize);
if (!mounted || generation != _episodesLoadGeneration) return;
if (!mounted || generation != _tvSeasonEpisodeCacheWarmGeneration) 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;
if (seasonId == null || !seasonIdsToWarm.contains(seasonId)) continue;
episodesBySeasonId[seasonId]?.add(episode);
}
@@ -1681,27 +1685,28 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
total = page.totalCount;
} while (offset < total && total > 0);
_completeTvSeasonEpisodeCaches(selectedSeasonIndex, episodesBySeasonId, generation);
_completeWarmedTvSeasonEpisodeCaches(seasons, episodesBySeasonId, generation);
} catch (e, st) {
appLogger.w('Failed to load TV season episode caches', error: e, stackTrace: st);
await _fetchTvSeasonEpisodeCachesBySeason(selectedSeasonIndex, client, serverId, generation);
await _warmTvSeasonEpisodeCachesBySeason(seasons, seasonIdsToWarm, client, serverId, generation);
}
}
Future<void> _fetchTvSeasonEpisodeCachesBySeason(
int selectedSeasonIndex,
Future<void> _warmTvSeasonEpisodeCachesBySeason(
List<MediaItem> seasons,
Set<String> seasonIdsToWarm,
MediaServerClient client,
String serverId,
int generation,
) async {
final seasons = List<MediaItem>.of(_seasons);
final episodesBySeasonId = <String, List<MediaItem>>{};
for (final season in seasons) {
if (!mounted || generation != _episodesLoadGeneration) return;
if (!seasonIdsToWarm.contains(season.id) || _episodeCache.containsKey(season.id)) continue;
if (!mounted || generation != _tvSeasonEpisodeCacheWarmGeneration) return;
try {
final episodes = await client.fetchChildren(season.id);
if (!mounted || generation != _episodesLoadGeneration) return;
if (!mounted || generation != _tvSeasonEpisodeCacheWarmGeneration) return;
episodesBySeasonId[season.id] = episodes
.map(
(episode) => _withFallbackLibrary(
@@ -1724,7 +1729,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
}
}
_completeTvSeasonEpisodeCaches(selectedSeasonIndex, episodesBySeasonId, generation);
_completeWarmedTvSeasonEpisodeCaches(seasons, episodesBySeasonId, generation);
}
String? _seasonIdForEpisode(
@@ -1741,21 +1746,18 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return null;
}
void _completeTvSeasonEpisodeCaches(
int selectedSeasonIndex,
void _completeWarmedTvSeasonEpisodeCaches(
List<MediaItem> seasons,
Map<String, List<MediaItem>> episodesBySeasonId,
int generation,
) {
if (!mounted || generation != _episodesLoadGeneration) return;
if (!mounted || generation != _tvSeasonEpisodeCacheWarmGeneration) return;
final currentSeasonIds = _seasons.map((season) => season.id).toSet();
setStateIfMounted(() {
for (final season in _seasons) {
for (final season in seasons) {
if (!currentSeasonIds.contains(season.id) || _episodeCache.containsKey(season.id)) continue;
_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;
});
}
+1 -1
View File
@@ -163,7 +163,7 @@ class _SearchScreenState extends State<SearchScreen>
/// Navigate focus to the sidebar
void _navigateToSidebar() {
MainScreenFocusScope.of(context)?.focusSidebar();
MainScreenFocusScope.of(context, listen: false)?.focusSidebar();
}
Widget _buildResultsList(BuildContext context) {
+1 -1
View File
@@ -121,7 +121,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
}
void _navigateToSidebar() {
MainScreenFocusScope.of(context)?.focusSidebar();
MainScreenFocusScope.of(context, listen: false)?.focusSidebar();
}
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
+1 -1
View File
@@ -73,7 +73,7 @@ Future<MediaNavigationResult> navigateToMediaItem(
final sectionKey = mi.librarySectionKey;
if (sectionKey != null && mi.serverId != null) {
final libraryGlobalKey = buildGlobalKey(mi.serverId!, sectionKey);
MainScreenFocusScope.of(context)?.selectLibrary?.call(libraryGlobalKey);
MainScreenFocusScope.of(context, listen: false)?.selectLibrary?.call(libraryGlobalKey);
return MediaNavigationResult.librarySelected;
}
return MediaNavigationResult.unsupported;
+62 -15
View File
@@ -335,6 +335,19 @@ class TvBrowseRailState extends State<TvBrowseRail> {
@override
void didUpdateWidget(covariant TvBrowseRail oldWidget) {
super.didUpdateWidget(oldWidget);
final hubStateChanged = !_hasSameHubState(oldWidget.hubs, widget.hubs);
final initialSelectionChanged =
oldWidget.initialHubId != widget.initialHubId || oldWidget.initialItemId != widget.initialItemId;
if (!hubStateChanged && !initialSelectionChanged) {
if (!oldWidget.autofocus && widget.autofocus) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _focusNode.requestFocus();
});
}
return;
}
final oldActiveHubId = oldWidget.hubs.isEmpty
? null
: oldWidget.hubs[_hubIndex.clamp(0, oldWidget.hubs.length - 1)].id;
@@ -373,6 +386,21 @@ class TvBrowseRailState extends State<TvBrowseRail> {
});
}
bool _hasSameHubState(List<MediaHub> oldHubs, List<MediaHub> newHubs) {
if (oldHubs.length != newHubs.length) return false;
for (var i = 0; i < oldHubs.length; i++) {
final oldHub = oldHubs[i];
final newHub = newHubs[i];
if (oldHub.id != newHub.id || oldHub.more != newHub.more || oldHub.items.length != newHub.items.length) {
return false;
}
for (var j = 0; j < oldHub.items.length; j++) {
if (oldHub.items[j].globalKey != newHub.items[j].globalKey) return false;
}
}
return true;
}
@override
void dispose() {
_longPressTimer?.cancel();
@@ -726,7 +754,6 @@ class TvBrowseRailState extends State<TvBrowseRail> {
scale,
).clamp(0.0, horizontalInset).toDouble();
final width = constraints.maxWidth.isFinite ? constraints.maxWidth : MediaQuery.sizeOf(context).width;
final backgroundWidth = math.max(width + widget.backgroundBleedLeft, MediaQuery.sizeOf(context).width);
final availableWidth = (width - horizontalInset).clamp(1.0, double.infinity).toDouble();
final railViewportWidth = (availableWidth + interactionExpansion).clamp(1.0, double.infinity).toDouble();
final density = svc.read(SettingsService.libraryDensity);
@@ -788,20 +815,10 @@ class TvBrowseRailState extends State<TvBrowseRail> {
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
top: 0,
bottom: 0,
left: -widget.backgroundBleedLeft,
width: backgroundWidth,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, theme.scaffoldBackgroundColor.withValues(alpha: 0.7)],
),
),
),
_RailBackgroundBleed(
width: width,
targetBleedLeft: widget.backgroundBleedLeft,
backgroundColor: theme.scaffoldBackgroundColor,
),
Padding(
padding: EdgeInsets.fromLTRB(
@@ -1125,6 +1142,36 @@ class TvBrowseRailState extends State<TvBrowseRail> {
}
}
class _RailBackgroundBleed extends StatelessWidget {
final double width;
final double targetBleedLeft;
final Color backgroundColor;
const _RailBackgroundBleed({required this.width, required this.targetBleedLeft, required this.backgroundColor});
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
tween: Tween(end: targetBleedLeft),
duration: FocusTheme.getAnimationDuration(context),
curve: Curves.easeOutCubic,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, backgroundColor.withValues(alpha: 0.7)],
),
),
),
builder: (context, bleedLeft, child) {
final backgroundWidth = math.max(width + bleedLeft, MediaQuery.sizeOf(context).width);
return Positioned(top: 0, bottom: 0, left: -bleedLeft, width: backgroundWidth, child: child!);
},
);
}
}
class _RailClipper extends CustomClipper<Rect> {
final double leftOverflow;
final double rightOverflow;
+10
View File
@@ -1,5 +1,6 @@
import 'package:drift/native.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/connection/connection.dart';
import 'package:plezy/connection/connection_registry.dart';
@@ -189,6 +190,15 @@ void main() {
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]');
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail');
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
tester.state<FocusableActionBarState>(find.byType(FocusableActionBar)).requestFocusOnFirst();
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]');
(discoverKey.currentState! as FocusableTab).focusActiveTabIfReady();
(discoverKey.currentState! as TabVisibilityAware).onTabHidden();
await tester.pump();
+39
View File
@@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/screens/main_screen.dart';
import 'package:plezy/widgets/side_navigation_rail.dart';
@@ -39,4 +40,42 @@ void main() {
expect(expanded.width, viewportWidth - SideNavigationRailState.expandedWidth);
expect(expanded.left + expanded.width, viewportWidth);
});
testWidgets('side navigation bleed animates from the previous value', (tester) async {
Widget build(double targetBleed) {
return Directionality(
textDirection: TextDirection.ltr,
child: Stack(
children: [
SideNavigationBleedBuilder(
targetBleed: targetBleed,
builder: (context, bleed, _) => Positioned(
key: const ValueKey('bleed-position'),
top: 0,
left: -bleed,
width: 1280,
height: 10,
child: const SizedBox.shrink(),
),
),
],
),
);
}
double left() => tester.widget<Positioned>(find.byKey(const ValueKey('bleed-position'))).left!;
await tester.pumpWidget(build(SideNavigationRailState.tvCollapsedWidth));
expect(left(), -SideNavigationRailState.tvCollapsedWidth);
await tester.pumpWidget(build(SideNavigationRailState.expandedWidth));
expect(left(), closeTo(-SideNavigationRailState.tvCollapsedWidth, 0.001));
await tester.pump(const Duration(milliseconds: 100));
expect(left(), lessThan(-SideNavigationRailState.tvCollapsedWidth));
expect(left(), greaterThan(-SideNavigationRailState.expandedWidth));
await tester.pumpAndSettle();
expect(left(), closeTo(-SideNavigationRailState.expandedWidth, 0.001));
});
}
+2 -2
View File
@@ -35,7 +35,7 @@ void main() {
TvDetectionService.debugSetAppleTVOverride(null);
});
testWidgets('TV detail reveals season hubs together after all episode caches load', (tester) async {
testWidgets('TV detail reveals selected season before remaining episode caches load', (tester) async {
await SettingsService.getInstance();
final show = MediaItem(
@@ -120,7 +120,7 @@ void main() {
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
expect(find.text('Season 1'), findsNothing);
expect(find.text('Season 1'), findsOneWidget);
expect(find.text('Season 2'), findsNothing);
descendantsCompleter.complete([episode1, episode2]);
+45
View File
@@ -696,6 +696,51 @@ void main() {
expect(backgroundPosition.left, -SideNavigationRailState.expandedWidth);
expect(backgroundPosition.width, 1280);
});
testWidgets('background bleed updates do not renotify rail focus', (tester) async {
final serverManager = MultiServerManager();
final multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager));
addTearDown(multiServerProvider.dispose);
final focusedItemIds = <String>[];
final activeHubIds = <String>[];
final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1);
Widget buildRail(double backgroundBleedLeft) {
return ChangeNotifierProvider<MultiServerProvider>.value(
value: multiServerProvider,
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
key: const ValueKey('rail'),
hubs: [hub],
iconForHub: (_, _) => Icons.movie_rounded,
backgroundBleedLeft: backgroundBleedLeft,
onFocusedItemChanged: (focused) => focusedItemIds.add(focused.id),
onActiveHubChanged: (active, _) => activeHubIds.add(active.id),
),
),
),
),
);
}
await tester.pumpWidget(buildRail(0));
await tester.pump();
focusedItemIds.clear();
activeHubIds.clear();
await tester.pumpWidget(buildRail(SideNavigationRailState.expandedWidth));
await tester.pump();
expect(focusedItemIds, isEmpty);
expect(activeHubIds, isEmpty);
});
}
ScrollPosition _activeRailPosition(WidgetTester tester) {