fix(tv): smooth sidebar expansion behavior
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user