fix(tv): displace content beside sidebar
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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>[]);
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user