fix(tv): displace content beside sidebar

This commit is contained in:
edde746
2026-05-22 00:14:16 +02:00
parent 0dd7d7c522
commit 2ebd730201
13 changed files with 407 additions and 113 deletions
+28 -21
View File
@@ -333,11 +333,18 @@ class _DiscoverScreenState extends State<DiscoverScreen>
void _focusTvBrowseRailWhenReady() {
if (!PlatformDetector.isTV()) return;
if (!(ModalRoute.of(context)?.isCurrent ?? false)) return;
if (!_isTabVisible || !(ModalRoute.of(context)?.isCurrent ?? false)) {
_pendingTvBrowseRailFocus = false;
return;
}
_pendingTvBrowseRailFocus = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) return;
if (!mounted) return;
if (!_isTabVisible || !(ModalRoute.of(context)?.isCurrent ?? false)) {
_pendingTvBrowseRailFocus = false;
return;
}
if (_tvBrowseHubs.isEmpty) return;
final rail = _tvBrowseRailKey.currentState;
if (rail == null) return;
@@ -606,6 +613,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
@override
void onTabHidden() {
_isTabVisible = false;
_pendingTvBrowseRailFocus = false;
_autoScrollTimer?.cancel();
_stopIndicatorProgress();
}
@@ -746,14 +754,16 @@ class _DiscoverScreenState extends State<DiscoverScreen>
// On initial load, focus the hero so the user starts on content (not the toolbar)
if (!_initialLoadComplete && onDeck.isNotEmpty) {
_initialLoadComplete = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) return;
if (PlatformDetector.isTV()) {
_tvBrowseRailKey.currentState?.requestFocus();
} else if (_heroFocusNode.canRequestFocus) {
_heroFocusNode.requestFocus();
}
});
if (PlatformDetector.isTV()) {
_focusTvBrowseRailWhenReady();
} else {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) return;
if (_heroFocusNode.canRequestFocus) {
_heroFocusNode.requestFocus();
}
});
}
}
// Wait for global hubs
@@ -787,11 +797,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (PlatformDetector.isTV() && !_initialLoadComplete && filteredHubs.isNotEmpty) {
_initialLoadComplete = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && (ModalRoute.of(context)?.isCurrent ?? false)) {
_tvBrowseRailKey.currentState?.requestFocus();
}
});
_focusTvBrowseRailWhenReady();
}
appLogger.d('Discover content loaded successfully');
@@ -1516,6 +1522,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final hideSpoilers = svc.read(SettingsService.hideSpoilers);
final browseHubs = _tvBrowseHubs;
final scale = TvLayoutConstants.scaleForSize(size);
final sidebarBleed = MainScreenFocusScope.sideNavigationBleedOf(
context,
alwaysKeepSidebarOpen: svc.read(SettingsService.alwaysKeepSidebarOpen),
);
final railHeight = browseHubs.isEmpty
? 0.0
: TvBrowseRailLayout.estimateHeight(
@@ -1534,10 +1544,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final maxSpotlightBottom = (size.height - spotlightTop - (96 * scale)).clamp(0.0, double.infinity).toDouble();
final spotlightBottom = desiredSpotlightBottom > maxSpotlightBottom ? maxSpotlightBottom : desiredSpotlightBottom;
final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble();
final sidebarBleed = MainScreenFocusScope.sideNavigationBleedOf(
context,
alwaysKeepSidebarOpen: svc.read(SettingsService.alwaysKeepSidebarOpen),
);
return Material(
color: theme.scaffoldBackgroundColor,
@@ -1548,7 +1554,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
top: 0,
bottom: 0,
left: -sidebarBleed,
right: 0,
width: size.width,
child: TvSpotlightBackground(
item: spotlight,
client: _getMediaClientForItem(spotlight),
@@ -1607,12 +1613,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
onNavigateToSidebar: _navigateToSidebar,
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
backgroundBleedLeft: sidebarBleed,
visibleRightInset: sidebarBleed,
),
),
Positioned(
top: 0,
left: -sidebarBleed,
right: 0,
width: size.width,
child: ExcludeFocusTraversal(child: _buildOverlaidAppBar()),
),
if (_switchingProfile) const ProfileSwitchingOverlay(),
@@ -302,6 +302,10 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
final svc = SettingsService.instanceOrNull!;
final client = context.tryGetMediaClientForServer(spotlight?.serverId ?? widget.library.serverId);
final scale = TvLayoutConstants.scaleForSize(size);
final sidebarBleed = MainScreenFocusScope.sideNavigationBleedOf(
context,
alwaysKeepSidebarOpen: svc.read(SettingsService.alwaysKeepSidebarOpen),
);
final railHeight = tvHubs.isEmpty
? 0.0
: TvBrowseRailLayout.estimateHeight(
@@ -320,10 +324,6 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
final maxSpotlightBottom = (size.height - spotlightTop - (96 * scale)).clamp(0.0, double.infinity).toDouble();
final spotlightBottom = desiredSpotlightBottom > maxSpotlightBottom ? maxSpotlightBottom : desiredSpotlightBottom;
final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble();
final sidebarBleed = MainScreenFocusScope.sideNavigationBleedOf(
context,
alwaysKeepSidebarOpen: svc.read(SettingsService.alwaysKeepSidebarOpen),
);
return Material(
color: theme.scaffoldBackgroundColor,
@@ -336,7 +336,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
top: 0,
bottom: 0,
left: -sidebarBleed,
right: 0,
width: size.width,
child: TvSpotlightBackground(
item: spotlight,
client: client,
@@ -366,6 +366,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
onBack: widget.onBack,
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
backgroundBleedLeft: sidebarBleed,
visibleRightInset: sidebarBleed,
),
),
],
+50 -39
View File
@@ -80,7 +80,11 @@ class MainScreenFocusScope extends InheritedWidget {
static double sideNavigationBleedOf(BuildContext context, {required bool alwaysKeepSidebarOpen}) {
final width = of(context)?.sideNavigationWidth;
if (width != null) return width;
return alwaysKeepSidebarOpen ? 0.0 : SideNavigationRailState.collapsedWidthForContext(context);
return 0.0;
}
static double clippedContentRightInsetOf(BuildContext context) {
return of(context)?.sideNavigationWidth ?? 0.0;
}
@override
@@ -1232,7 +1236,7 @@ class _MainScreenState extends State<MainScreen>
return SettingValueBuilder<bool>(
pref: SettingsService.alwaysKeepSidebarOpen,
builder: (context, alwaysExpanded, _) {
final targetContentLeftPadding = _sideNavigationWidth(context, alwaysExpanded: alwaysExpanded);
final targetContentOffset = _sideNavigationWidth(context, alwaysExpanded: alwaysExpanded);
return OverlaySheetHost(
child: PopScope(
@@ -1250,7 +1254,7 @@ class _MainScreenState extends State<MainScreen>
child: TweenAnimationBuilder<double>(
duration: const Duration(milliseconds: 200),
curve: Curves.easeOutCubic,
tween: Tween<double>(end: targetContentLeftPadding),
tween: Tween<double>(end: targetContentOffset),
child: FocusScope(
node: _contentFocusScope,
// No autofocus - we control focus programmatically to prevent
@@ -1265,43 +1269,50 @@ class _MainScreenState extends State<MainScreen>
sideNavigationWidth: contentLeftPadding,
selectLibrary: _selectLibrary,
child: SideNavigationScope(
child: Stack(
children: [
Positioned.fill(
child: Padding(
padding: EdgeInsets.only(left: contentLeftPadding),
child: contentChild!,
),
),
Positioned(
top: 0,
bottom: 0,
left: 0,
child: FocusScope(
node: _sidebarFocusScope,
child: SideNavigationRail(
key: _sideNavKey,
selectedTab: _currentTab,
selectedLibraryKey: _selectedLibraryGlobalKey,
isOfflineMode: _isOffline,
isSidebarFocused: _isSidebarFocused,
alwaysExpanded: alwaysExpanded,
isReconnecting: _isReconnecting,
onInteractionExpandedChanged: _handleSidebarInteractionExpandedChanged,
onDestinationSelected: (tab) {
_selectTab(tab);
_focusContent();
},
onLibrarySelected: (key) {
_selectLibrary(key);
_focusContent();
},
onNavigateToContent: _focusContent,
onReconnect: _triggerReconnect,
child: LayoutBuilder(
builder: (context, constraints) {
final contentWidth = constraints.maxWidth;
return Stack(
clipBehavior: Clip.hardEdge,
children: [
Positioned(
top: 0,
bottom: 0,
left: contentLeftPadding,
width: contentWidth,
child: contentChild!,
),
),
),
],
Positioned(
top: 0,
bottom: 0,
left: 0,
child: FocusScope(
node: _sidebarFocusScope,
child: SideNavigationRail(
key: _sideNavKey,
selectedTab: _currentTab,
selectedLibraryKey: _selectedLibraryGlobalKey,
isOfflineMode: _isOffline,
isSidebarFocused: _isSidebarFocused,
alwaysExpanded: alwaysExpanded,
isReconnecting: _isReconnecting,
onInteractionExpandedChanged: _handleSidebarInteractionExpandedChanged,
onDestinationSelected: (tab) {
_selectTab(tab);
_focusContent();
},
onLibrarySelected: (key) {
_selectLibrary(key);
_focusContent();
},
onNavigateToContent: _focusContent,
onReconnect: _triggerReconnect,
),
),
),
],
);
},
),
),
);
+50 -5
View File
@@ -1642,6 +1642,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
Future<void> _fetchTvSeasonEpisodeCaches(int selectedSeasonIndex) async {
if (widget.isOffline || _showEpisodesDirectly || _seasons.isEmpty) return;
final generation = ++_episodesLoadGeneration;
setStateIfMounted(() {
_isLoadingSeasonEpisodes = true;
@@ -1650,7 +1651,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final serverId = _metadata.serverId;
final client = serverId == null ? null : context.tryGetMediaClientForServer(serverId);
if (serverId == null || client == null) {
_completeTvSeasonEpisodeCaches(selectedSeasonIndex, const <String, List<MediaItem>>{});
_completeTvSeasonEpisodeCaches(selectedSeasonIndex, const <String, List<MediaItem>>{}, generation);
return;
}
@@ -1666,7 +1667,7 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
var total = 0;
do {
final page = await client.fetchPlayableDescendantsPage(_metadata.id, start: offset, size: _episodesPageSize);
if (!mounted) return;
if (!mounted || generation != _episodesLoadGeneration) return;
if (page.items.isEmpty) break;
final enriched = _enrichPlayableEpisodes(page.items, serverId);
@@ -1680,13 +1681,52 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
total = page.totalCount;
} while (offset < total && total > 0);
_completeTvSeasonEpisodeCaches(selectedSeasonIndex, episodesBySeasonId);
_completeTvSeasonEpisodeCaches(selectedSeasonIndex, episodesBySeasonId, generation);
} catch (e, st) {
appLogger.w('Failed to load TV season episode caches', error: e, stackTrace: st);
_completeTvSeasonEpisodeCaches(selectedSeasonIndex, const <String, List<MediaItem>>{});
await _fetchTvSeasonEpisodeCachesBySeason(selectedSeasonIndex, client, serverId, generation);
}
}
Future<void> _fetchTvSeasonEpisodeCachesBySeason(
int selectedSeasonIndex,
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;
try {
final episodes = await client.fetchChildren(season.id);
if (!mounted || generation != _episodesLoadGeneration) return;
episodesBySeasonId[season.id] = episodes
.map(
(episode) => _withFallbackLibrary(
episode.copyWith(
serverId: serverId,
serverName: _metadata.serverName ?? episode.serverName,
grandparentId: _metadata.id,
grandparentTitle: _metadata.title ?? episode.grandparentTitle,
parentId: season.id,
parentIndex: season.index ?? episode.parentIndex,
),
season.libraryId != null ? season : _metadata,
),
)
.map(_applyLocalProgress)
.toList();
} catch (e, st) {
appLogger.w('Failed to load TV season episodes for ${season.id}', error: e, stackTrace: st);
episodesBySeasonId[season.id] = const <MediaItem>[];
}
}
_completeTvSeasonEpisodeCaches(selectedSeasonIndex, episodesBySeasonId, generation);
}
String? _seasonIdForEpisode(
MediaItem episode, {
required Map<String, MediaItem> seasonsById,
@@ -1701,7 +1741,12 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
return null;
}
void _completeTvSeasonEpisodeCaches(int selectedSeasonIndex, Map<String, List<MediaItem>> episodesBySeasonId) {
void _completeTvSeasonEpisodeCaches(
int selectedSeasonIndex,
Map<String, List<MediaItem>> episodesBySeasonId,
int generation,
) {
if (!mounted || generation != _episodesLoadGeneration) return;
setStateIfMounted(() {
for (final season in _seasons) {
_episodeCache[season.id] = List.of(episodesBySeasonId[season.id] ?? const <MediaItem>[]);
+26 -22
View File
@@ -136,6 +136,7 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
@override
Widget build(BuildContext context) {
final rightInset = MainScreenFocusScope.clippedContentRightInsetOf(context);
return Scaffold(
body: Focus(
onKeyEvent: _handleKeyEvent,
@@ -143,39 +144,42 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
primary: false,
slivers: [
ExcludeFocus(child: CustomAppBar(title: Text(t.settings.title), pinned: true)),
SliverList(
delegate: SliverChildListDelegate([
if (DonationService.isEnabled) _buildDonateTile(),
SliverPadding(
padding: EdgeInsets.only(right: rightInset),
sliver: SliverList(
delegate: SliverChildListDelegate([
if (DonationService.isEnabled) _buildDonateTile(),
_buildAppearanceTile(),
_buildAppearanceTile(),
_buildPlaybackTile(),
_buildPlaybackTile(),
_buildTrackersTile(),
_buildTrackersTile(),
_buildConnectionsSection(),
_buildConnectionsSection(),
_buildProfilesSection(),
_buildProfilesSection(),
if (!PlatformDetector.isAppleTV()) _buildDownloadsSection(),
if (!PlatformDetector.isAppleTV()) _buildDownloadsSection(),
if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection()],
if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection()],
_buildAdvancedSection(),
_buildAdvancedSection(),
if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection()],
if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection()],
if (!PlatformDetector.isTV()) _buildBackupSection(),
if (!PlatformDetector.isTV()) _buildBackupSection(),
SettingNavigationTile(
focusNode: _focusTracker.get(_kAbout),
icon: Symbols.info_rounded,
title: t.settings.about,
subtitle: t.settings.aboutDescription,
destinationBuilder: (context) => const AboutScreen(),
),
const SizedBox(height: 24),
]),
SettingNavigationTile(
focusNode: _focusTracker.get(_kAbout),
icon: Symbols.info_rounded,
title: t.settings.about,
subtitle: t.settings.aboutDescription,
destinationBuilder: (context) => const AboutScreen(),
),
const SizedBox(height: 24),
]),
),
),
],
),
+3 -1
View File
@@ -59,6 +59,7 @@ void scrollListToIndex(
int index, {
required double itemExtent,
double leadingPadding = 12.0,
double visibleTrailingInset = 0.0,
bool animate = true,
}) {
if (controller.positions.length != 1 || itemExtent <= 0) return;
@@ -67,7 +68,8 @@ void scrollListToIndex(
final maxExtent = controller.position.maxScrollExtent;
if (!viewport.isFinite || !maxExtent.isFinite) return;
final targetCenter = leadingPadding + (index * itemExtent) + (itemExtent / 2);
final desiredOffset = (targetCenter - (viewport / 2)).clamp(0.0, maxExtent);
final visibleViewport = (viewport - visibleTrailingInset).clamp(1.0, double.infinity).toDouble();
final desiredOffset = (targetCenter - (visibleViewport / 2)).clamp(0.0, maxExtent);
if (animate) {
controller.animateTo(desiredOffset, duration: const Duration(milliseconds: 150), curve: Curves.easeOut);
+10 -2
View File
@@ -19,6 +19,7 @@ import '../media/media_hub.dart';
import '../media/media_item.dart';
import '../mixins/mounted_set_state_mixin.dart';
import '../screens/hub_detail_screen.dart';
import '../screens/main_screen.dart';
import '../utils/media_navigation_helper.dart';
import 'focus_builders.dart';
import 'media_card.dart';
@@ -106,6 +107,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
? TvLayoutConstants.shelfHorizontalInset
: 12.0;
double get _leadingPadding => _leadingPaddingFor(PlatformDetector.isTV());
double get _visibleRightInset => MainScreenFocusScope.clippedContentRightInsetOf(context);
Timer? _longPressTimer;
bool _isSelectKeyDown = false;
@@ -209,6 +211,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
index,
itemExtent: _itemExtent,
leadingPadding: _leadingPadding,
visibleTrailingInset: _visibleRightInset,
animate: animate,
);
}
@@ -484,8 +487,13 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
padding: widget.inset
? EdgeInsets.symmetric(vertical: isTv ? 6 : 2)
: EdgeInsets.symmetric(horizontal: isTv ? leadingPadding : 8, vertical: isTv ? 6 : 2),
? EdgeInsets.fromLTRB(0, isTv ? 6 : 2, _visibleRightInset + 4, isTv ? 6 : 2)
: EdgeInsets.fromLTRB(
isTv ? leadingPadding : 8,
isTv ? 6 : 2,
(isTv ? leadingPadding : 8) + _visibleRightInset,
isTv ? 6 : 2,
),
itemCount: isKeyboardMode ? _totalItemCount : widget.hub.items.length,
itemBuilder: (context, index) {
final isItemFocused = hasFocus && index == _focusedIndex;
+12 -1
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../screens/main_screen.dart';
import 'focused_scroll_scaffold.dart';
/// Standard scaffold for settings pages made of ordinary list rows.
@@ -38,13 +39,23 @@ class SettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final pageSlivers = slivers ?? [_buildListSliver()];
final rightInset = MainScreenFocusScope.clippedContentRightInsetOf(context);
final insetSlivers = rightInset == 0
? pageSlivers
: [
for (final sliver in pageSlivers)
SliverPadding(
padding: EdgeInsets.only(right: rightInset),
sliver: sliver,
),
];
return FocusedScrollScaffold(
title: title,
actions: actions,
pinned: pinned,
automaticallyImplyLeading: automaticallyImplyLeading,
onBackPressed: onBackPressed,
slivers: pageSlivers,
slivers: insetSlivers,
);
}
+5 -3
View File
@@ -248,6 +248,8 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
bool get _interactionExpanded => _isHovered || _isTouchExpanded;
bool get _showDownloads => !PlatformDetector.isAppleTV();
/// macOS has the system green button; mobile/TV have no OS fullscreen toggle.
bool get _showFullscreenToggle => Platform.isWindows || Platform.isLinux;
@@ -366,7 +368,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
_kHome,
_kLibraries,
_kSearch,
_kDownloads,
if (_showDownloads) _kDownloads,
_kSettings,
_kReconnect,
if (hasHiddenLibraries) _kHiddenLibraries,
@@ -453,7 +455,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
if (hasLiveTv) 'liveTv',
_kSearch,
],
_kDownloads,
if (_showDownloads) _kDownloads,
_kSettings,
if (_showFullscreenToggle) _kFullscreen,
];
@@ -711,7 +713,7 @@ class SideNavigationRailState extends State<SideNavigationRail> with MountedSetS
],
// Downloads (hidden on Apple TV — no user
// file storage)
if (!PlatformDetector.isAppleTV()) ...[
if (_showDownloads) ...[
_buildNavItem(
icon: Symbols.download_rounded,
selectedIcon: Symbols.download_rounded,
+17 -4
View File
@@ -181,10 +181,11 @@ class TvBrowseRailLayout {
required TvBrowseRailLayoutMetrics metrics,
required double viewportWidth,
required double scale,
double visibleTrailingInset = 0.0,
}) {
final itemContentWidth = hub.items.length * (metrics.cardWidth + metrics.itemGap);
final moreContentWidth = hub.more ? (132 * scale) + metrics.itemGap : 0.0;
final contentWidth = (metrics.railEdgePadding * 2) + itemContentWidth + moreContentWidth;
final contentWidth = (metrics.railEdgePadding * 2) + itemContentWidth + moreContentWidth + visibleTrailingInset;
return (contentWidth - viewportWidth).clamp(0.0, double.infinity).toDouble();
}
@@ -193,10 +194,12 @@ class TvBrowseRailLayout {
required TvBrowseRailLayoutMetrics metrics,
required double viewportWidth,
required double maxScrollExtent,
double visibleTrailingInset = 0.0,
}) {
final itemExtent = metrics.cardWidth + metrics.itemGap;
final targetCenter = metrics.railEdgePadding + (index * itemExtent) + (itemExtent / 2);
return (targetCenter - (viewportWidth / 2)).clamp(0.0, maxScrollExtent).toDouble();
final visibleViewport = (viewportWidth - visibleTrailingInset).clamp(1.0, double.infinity).toDouble();
return (targetCenter - (visibleViewport / 2)).clamp(0.0, maxScrollExtent).toDouble();
}
static double estimateHeight({
@@ -257,6 +260,7 @@ class TvBrowseRail extends StatefulWidget {
final EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub;
final double Function(MediaHub hub)? widePosterScaleForHub;
final double backgroundBleedLeft;
final double visibleRightInset;
const TvBrowseRail({
super.key,
@@ -281,6 +285,7 @@ class TvBrowseRail extends StatefulWidget {
this.episodePosterModeForHub,
this.widePosterScaleForHub,
this.backgroundBleedLeft = 0,
this.visibleRightInset = 0,
});
@override
@@ -625,6 +630,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
_itemIndex,
itemExtent: _itemExtent,
leadingPadding: _railLeadingPadding,
visibleTrailingInset: widget.visibleRightInset,
animate: animate,
);
}
@@ -649,12 +655,14 @@ class TvBrowseRailState extends State<TvBrowseRail> {
metrics: metrics,
viewportWidth: viewportWidth,
scale: scale,
visibleTrailingInset: widget.visibleRightInset,
);
final initialScrollOffset = TvBrowseRailLayout.scrollOffsetForIndex(
index: initialItemIndex,
metrics: metrics,
viewportWidth: viewportWidth,
maxScrollExtent: maxScrollExtent,
visibleTrailingInset: widget.visibleRightInset,
);
return ScrollController(initialScrollOffset: initialScrollOffset);
});
@@ -790,7 +798,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
top: 0,
bottom: 0,
left: -widget.backgroundBleedLeft,
right: 0,
width: width,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
@@ -979,7 +987,12 @@ class TvBrowseRailState extends State<TvBrowseRail> {
controller: scrollController,
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
padding: EdgeInsets.fromLTRB(metrics.railEdgePadding, 2 * scale, metrics.railEdgePadding, 6 * scale),
padding: EdgeInsets.fromLTRB(
metrics.railEdgePadding,
2 * scale,
metrics.railEdgePadding + widget.visibleRightInset,
6 * scale,
),
itemCount: totalCount,
itemBuilder: (context, itemIndex) {
final isFocused = hasFocus && isActiveHub && itemIndex == _itemIndex;
+45 -7
View File
@@ -13,6 +13,7 @@ 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/mixins/tab_visibility_aware.dart';
import 'package:plezy/profiles/active_profile_provider.dart';
import 'package:plezy/profiles/plex_home_service.dart';
import 'package:plezy/profiles/profile.dart';
@@ -55,7 +56,14 @@ void main() {
});
testWidgets('TV tab focus returns to discover browse rail instead of reload action', (tester) async {
await SettingsService.getInstance();
final settings = await SettingsService.getInstance();
await settings.write(SettingsService.libraryDensity, LibraryDensity.max);
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final item = MediaItem(
id: 'movie_1',
@@ -92,6 +100,7 @@ void main() {
storage: storage,
);
final discoverKey = GlobalKey<State<DiscoverScreen>>();
const sidebarOffset = SideNavigationRailState.expandedWidth;
addTearDown(() async {
activeProfileProvider.dispose();
@@ -121,7 +130,7 @@ void main() {
focusSidebar: () {},
focusContent: () {},
isSidebarFocused: false,
sideNavigationWidth: SideNavigationRailState.expandedWidth,
sideNavigationWidth: sidebarOffset,
child: SizedBox(width: 1280, height: 720, child: DiscoverScreen(key: discoverKey)),
),
),
@@ -135,21 +144,50 @@ void main() {
final scale = TvLayoutConstants.scaleForSize(const Size(1280, 720));
final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble();
final spotlightBackground = tester.widget<TvSpotlightBackground>(find.byType(TvSpotlightBackground));
expect(spotlightBackground.contentLeft, closeTo(spotlightLeft + SideNavigationRailState.expandedWidth, 0.001));
expect(
tester.widget<TvBrowseRail>(find.byType(TvBrowseRail)).backgroundBleedLeft,
SideNavigationRailState.expandedWidth,
expect(spotlightBackground.contentLeft, closeTo(spotlightLeft + sidebarOffset, 0.001));
final railHeight = TvBrowseRailLayout.estimateHeight(
size: const Size(1280, 720),
hubs: [hub],
density: LibraryDensity.max,
episodePosterMode: settings.read(SettingsService.episodePosterMode),
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
);
final minimumSpotlightBottom = railHeight + (8 * scale);
final baseSpotlightBottom = (720 * 0.48).clamp(160.0, 820.0).toDouble();
final desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom
? minimumSpotlightBottom
: baseSpotlightBottom;
final maxSpotlightBottom = (720 - ((720 * 0.075).clamp(64.0 * scale, 120.0 * scale)) - (96 * scale))
.clamp(0.0, double.infinity)
.toDouble();
final expectedSpotlightBottom = desiredSpotlightBottom > maxSpotlightBottom
? maxSpotlightBottom
: desiredSpotlightBottom;
expect(spotlightBackground.contentBottom, closeTo(expectedSpotlightBottom, 0.001));
final browseRail = tester.widget<TvBrowseRail>(find.byType(TvBrowseRail));
expect(browseRail.backgroundBleedLeft, sidebarOffset);
expect(browseRail.visibleRightInset, sidebarOffset);
final backgroundPosition = tester.widget<Positioned>(
find.ancestor(of: find.byType(TvSpotlightBackground), matching: find.byType(Positioned)).first,
);
expect(backgroundPosition.left, -SideNavigationRailState.expandedWidth);
expect(backgroundPosition.left, -sidebarOffset);
expect(backgroundPosition.width, 1280);
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();
await tester.pump();
expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]');
(discoverKey.currentState! as TabVisibilityAware).onTabShown();
(discoverKey.currentState! as FocusableTab).focusActiveTabIfReady();
await tester.pump();
await tester.pump();
+97 -3
View File
@@ -130,17 +130,109 @@ void main() {
expect(find.text('Season 1'), findsOneWidget);
expect(find.text('Season 2'), findsOneWidget);
});
testWidgets('TV detail falls back to per-season episodes when descendant cache fails', (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 client = _FakeMediaServerClient(
show: show,
childrenByParent: {
show.id: [season1, season2],
season1.id: [episode1],
season2.id: [episode2],
},
playableDescendantsError: Exception('descendant cache failed'),
);
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'), 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;
final Future<List<MediaItem>>? pendingPlayableDescendants;
final Object? playableDescendantsError;
_FakeMediaServerClient({
required this.show,
required this.childrenByParent,
required this.pendingPlayableDescendants,
this.pendingPlayableDescendants,
this.playableDescendantsError,
});
@override
@@ -169,7 +261,9 @@ class _FakeMediaServerClient implements MediaServerClient {
int? size,
AbortController? abort,
}) async {
final items = await pendingPlayableDescendants;
final error = playableDescendantsError;
if (error != null) throw error;
final items = await pendingPlayableDescendants!;
return LibraryPage(items: items, totalCount: items.length, offset: start ?? 0);
}
@@ -239,6 +239,64 @@ void main() {
expect(reports.last, isFalse);
});
testWidgets('Apple TV D-pad focus skips hidden downloads item', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null));
await SettingsService.getInstance();
final librariesProvider = LibrariesProvider();
addTearDown(librariesProvider.dispose);
final hiddenLibrariesProvider = HiddenLibrariesProvider();
await hiddenLibrariesProvider.ensureInitialized();
addTearDown(hiddenLibrariesProvider.dispose);
final manager = MultiServerManager();
final aggregation = DataAggregationService(manager);
final multiServerProvider = MultiServerProvider(manager, aggregation);
addTearDown(multiServerProvider.dispose);
final sideNavKey = GlobalKey<SideNavigationRailState>();
NavigationTabId? selectedTab;
await tester.pumpWidget(
TranslationProvider(
child: MultiProvider(
providers: [
ChangeNotifierProvider<LibrariesProvider>.value(value: librariesProvider),
ChangeNotifierProvider<HiddenLibrariesProvider>.value(value: hiddenLibrariesProvider),
ChangeNotifierProvider<MultiServerProvider>.value(value: multiServerProvider),
],
child: MaterialApp(
theme: ThemeData(extensions: const [_testTokens]),
home: Scaffold(
body: SideNavigationRail(
key: sideNavKey,
selectedTab: NavigationTabId.discover,
isSidebarFocused: true,
alwaysExpanded: true,
onDestinationSelected: (tab) => selectedTab = tab,
onLibrarySelected: (_) {},
),
),
),
),
),
);
await tester.pumpAndSettle();
sideNavKey.currentState!.focusActiveItem();
await tester.pumpAndSettle();
// Home -> Libraries -> Search -> Settings. Downloads is hidden on Apple TV.
await _press(tester, LogicalKeyboardKey.arrowDown);
await _press(tester, LogicalKeyboardKey.arrowDown);
await _press(tester, LogicalKeyboardKey.arrowDown);
await _press(tester, LogicalKeyboardKey.enter);
expect(selectedTab, NavigationTabId.settings);
});
testWidgets('D-pad down from a hidden server header focuses that hidden server library', (tester) async {
await SettingsService.getInstance();