feat(tv): add spotlight browse layouts
This commit is contained in:
+475
-221
@@ -40,6 +40,8 @@ import '../providers/user_profile_provider.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../widgets/settings_builder.dart';
|
||||
import '../widgets/tv_browse_rail.dart';
|
||||
import '../widgets/tv_spotlight_background.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import '../mixins/tab_visibility_aware.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
@@ -130,6 +132,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
Timer? _indicatorTimer;
|
||||
final ValueNotifier<double> _indicatorProgress = ValueNotifier(0.0);
|
||||
bool _isAutoScrollPaused = false;
|
||||
bool _heroFocusPausedAutoScroll = false;
|
||||
MediaItem? _spotlightItem;
|
||||
bool _isTabVisible = true;
|
||||
HiddenLibrariesProvider? _hiddenLibrariesProvider;
|
||||
LibrariesProvider? _librariesProvider;
|
||||
@@ -194,6 +198,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
// Hub navigation keys
|
||||
GlobalKey<HubSectionState>? _continueWatchingHubKey;
|
||||
final List<GlobalKey<HubSectionState>> _hubKeys = [];
|
||||
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
|
||||
|
||||
// Hero and app bar focus
|
||||
late FocusNode _heroFocusNode;
|
||||
@@ -234,22 +239,85 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
bool get _isHeroSectionVisible => _onDeck.isNotEmpty && context.settingsRead(SettingsService.showHeroSection);
|
||||
|
||||
MediaItem? get _defaultSpotlightItem {
|
||||
if (_onDeck.isNotEmpty) return _onDeck.first;
|
||||
for (final hub in _hubs) {
|
||||
if (hub.items.isNotEmpty) return hub.items.first;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<MediaHub> get _tvBrowseHubs {
|
||||
final hubs = <MediaHub>[];
|
||||
if (_onDeck.isNotEmpty) {
|
||||
hubs.add(
|
||||
MediaHub(
|
||||
id: 'continue_watching',
|
||||
title: t.discover.continueWatching,
|
||||
type: 'mixed',
|
||||
identifier: '_continue_watching_',
|
||||
size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0),
|
||||
more: _hasMoreContinueWatching,
|
||||
items: _onDeck,
|
||||
),
|
||||
);
|
||||
}
|
||||
hubs.addAll(_hubs.where((hub) => hub.items.isNotEmpty));
|
||||
return hubs;
|
||||
}
|
||||
|
||||
MediaItem? get _effectiveSpotlightItem {
|
||||
final current = _spotlightItem;
|
||||
if (current == null) return _defaultSpotlightItem;
|
||||
if (_onDeck.any((item) => item.globalKey == current.globalKey)) return current;
|
||||
for (final hub in _hubs) {
|
||||
if (hub.items.any((item) => item.globalKey == current.globalKey)) return current;
|
||||
}
|
||||
return _defaultSpotlightItem;
|
||||
}
|
||||
|
||||
void _setSpotlightItem(MediaItem item) {
|
||||
if (_spotlightItem?.globalKey == item.globalKey) return;
|
||||
setState(() => _spotlightItem = item);
|
||||
}
|
||||
|
||||
void _scrollToTop() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
_scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut);
|
||||
}
|
||||
|
||||
void _focusTopActions() {
|
||||
if (!(ModalRoute.of(context)?.isCurrent ?? false)) return;
|
||||
final actionBar = _actionBarKey.currentState;
|
||||
if (actionBar != null) {
|
||||
actionBar.requestFocusOnFirst();
|
||||
return;
|
||||
}
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) return;
|
||||
_actionBarKey.currentState?.requestFocusOnFirst();
|
||||
});
|
||||
}
|
||||
|
||||
void _focusTopBoundary() {
|
||||
if (!(ModalRoute.of(context)?.isCurrent ?? false)) return;
|
||||
if (_isHeroSectionVisible) {
|
||||
if (PlatformDetector.isTV()) {
|
||||
_focusTopActions();
|
||||
} else if (_isHeroSectionVisible) {
|
||||
_heroFocusNode.requestFocus();
|
||||
} else {
|
||||
_actionBarKey.currentState?.requestFocusOnFirst();
|
||||
_focusTopActions();
|
||||
}
|
||||
_scrollToTop();
|
||||
}
|
||||
|
||||
void _focusContentFromAppBar() {
|
||||
if (PlatformDetector.isTV()) {
|
||||
_tvBrowseRailKey.currentState?.requestFocus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_isHeroSectionVisible) {
|
||||
_heroFocusNode.requestFocus();
|
||||
return;
|
||||
@@ -269,6 +337,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
// UP from first hub: navigate to hero when visible, otherwise app bar
|
||||
if (isUp && hubIndex == 0) {
|
||||
if (PlatformDetector.isTV()) {
|
||||
_focusTopActions();
|
||||
return true;
|
||||
}
|
||||
_focusTopBoundary();
|
||||
return true;
|
||||
}
|
||||
@@ -301,10 +373,27 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_heroFocusNode = FocusNode(debugLabel: 'hero_section');
|
||||
_heroFocusNode.addListener(_onHeroFocusChanged);
|
||||
_loadContent();
|
||||
_startAutoScroll();
|
||||
}
|
||||
|
||||
void _onHeroFocusChanged() {
|
||||
if (!PlatformDetector.isTV()) return;
|
||||
|
||||
if (_heroFocusNode.hasFocus) {
|
||||
_heroFocusPausedAutoScroll = true;
|
||||
_autoScrollTimer?.cancel();
|
||||
_stopIndicatorProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_heroFocusPausedAutoScroll) {
|
||||
_heroFocusPausedAutoScroll = false;
|
||||
if (_isTabVisible && !_isAutoScrollPaused) _startAutoScroll();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
@@ -360,31 +449,36 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Handle key events for the hero section
|
||||
late final _handleHeroKeyEvent = dpadKeyHandler(
|
||||
onDown: () {
|
||||
final keys = _allHubKeys;
|
||||
if (keys.isNotEmpty) keys.first.currentState?.requestFocusFromMemory();
|
||||
},
|
||||
onUp: () => _actionBarKey.currentState?.requestFocusOnFirst(),
|
||||
onLeft: () {
|
||||
if (_currentHeroIndex > 0) {
|
||||
_heroController.previousPage(duration: tokens(context).slow, curve: Curves.easeInOut);
|
||||
} else {
|
||||
_navigateToSidebar();
|
||||
}
|
||||
},
|
||||
onRight: () {
|
||||
if (_currentHeroIndex < _onDeck.length - 1) {
|
||||
_heroController.nextPage(duration: tokens(context).slow, curve: Curves.easeInOut);
|
||||
}
|
||||
},
|
||||
onSelect: () {
|
||||
if (_onDeck.isNotEmpty && _currentHeroIndex < _onDeck.length) {
|
||||
navigateToVideoPlayer(context, metadata: _onDeck[_currentHeroIndex]);
|
||||
}
|
||||
},
|
||||
);
|
||||
/// Handle key events for the hero section.
|
||||
KeyEventResult _handleHeroKeyEvent(FocusNode node, KeyEvent event) {
|
||||
final backResult = handleBackKeyAction(event, _navigateToSidebar);
|
||||
if (backResult != KeyEventResult.ignored) return backResult;
|
||||
|
||||
return dpadKeyHandler(
|
||||
onDown: () {
|
||||
final keys = _allHubKeys;
|
||||
if (keys.isNotEmpty) keys.first.currentState?.requestFocusFromMemory();
|
||||
},
|
||||
onUp: _focusTopActions,
|
||||
onLeft: () {
|
||||
if (_currentHeroIndex > 0) {
|
||||
_heroController.previousPage(duration: tokens(context).slow, curve: Curves.easeInOut);
|
||||
} else {
|
||||
_navigateToSidebar();
|
||||
}
|
||||
},
|
||||
onRight: () {
|
||||
if (_currentHeroIndex < _onDeck.length - 1) {
|
||||
_heroController.nextPage(duration: tokens(context).slow, curve: Curves.easeInOut);
|
||||
}
|
||||
},
|
||||
onSelect: () {
|
||||
if (_onDeck.isNotEmpty && _currentHeroIndex < _onDeck.length) {
|
||||
navigateToVideoPlayer(context, metadata: _onDeck[_currentHeroIndex]);
|
||||
}
|
||||
},
|
||||
)(node, event);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -396,6 +490,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_indicatorProgress.dispose();
|
||||
_heroController.dispose();
|
||||
_scrollController.dispose();
|
||||
_heroFocusNode.removeListener(_onHeroFocusChanged);
|
||||
_heroFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -419,6 +514,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
void _startAutoScroll() {
|
||||
_autoScrollTimer?.cancel();
|
||||
if (PlatformDetector.isTV()) return;
|
||||
if (_isAutoScrollPaused) return;
|
||||
|
||||
_startIndicatorProgress();
|
||||
@@ -607,7 +703,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
});
|
||||
|
||||
// Focus hero section now that it's visible, but only if no modal route is on top
|
||||
if (onDeck.isNotEmpty && (ModalRoute.of(context)?.isCurrent ?? false)) {
|
||||
if (!PlatformDetector.isTV() && onDeck.isNotEmpty && (ModalRoute.of(context)?.isCurrent ?? false)) {
|
||||
_heroFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
@@ -625,7 +721,10 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
if (!_initialLoadComplete && onDeck.isNotEmpty) {
|
||||
_initialLoadComplete = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _heroFocusNode.canRequestFocus && (ModalRoute.of(context)?.isCurrent ?? false)) {
|
||||
if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) return;
|
||||
if (PlatformDetector.isTV()) {
|
||||
_tvBrowseRailKey.currentState?.requestFocus();
|
||||
} else if (_heroFocusNode.canRequestFocus) {
|
||||
_heroFocusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
@@ -659,6 +758,15 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_updateHubKeys();
|
||||
});
|
||||
|
||||
if (PlatformDetector.isTV() && !_initialLoadComplete && filteredHubs.isNotEmpty) {
|
||||
_initialLoadComplete = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && (ModalRoute.of(context)?.isCurrent ?? false)) {
|
||||
_tvBrowseRailKey.currentState?.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
appLogger.d('Discover content loaded successfully');
|
||||
} catch (e) {
|
||||
appLogger.e('Failed to load discover content', error: e);
|
||||
@@ -1226,8 +1334,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
Widget _buildContent(BuildContext context) {
|
||||
final svc = SettingsService.instanceOrNull!;
|
||||
final showServerNameOnHubs = svc.read(SettingsService.showServerNameOnHubs);
|
||||
final showHeroSection = svc.read(SettingsService.showHeroSection);
|
||||
|
||||
if (PlatformDetector.isTV()) {
|
||||
return _buildTvContent(context);
|
||||
}
|
||||
|
||||
final showServerNameOnHubs = svc.read(SettingsService.showServerNameOnHubs);
|
||||
final duplicateHubTitles = _getDuplicateHubTitles();
|
||||
|
||||
final bottomPadding = MediaQuery.paddingOf(context).bottom;
|
||||
@@ -1365,10 +1478,93 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTvContent(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final theme = Theme.of(context);
|
||||
final spotlight = _effectiveSpotlightItem;
|
||||
final hideSpoilers = SettingsService.instanceOrNull!.read(SettingsService.hideSpoilers);
|
||||
final browseHubs = _tvBrowseHubs;
|
||||
final spotlightTop = (size.height * 0.1).clamp(96.0, 150.0).toDouble();
|
||||
final spotlightBottom = (size.height * 0.53).clamp(180.0, 900.0).toDouble();
|
||||
final spotlightLeft = (24 * TvLayoutConstants.scaleForSize(size)).clamp(18.0, 40.0).toDouble();
|
||||
|
||||
return Material(
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
child: Stack(
|
||||
children: [
|
||||
TvSpotlightBackground(
|
||||
item: spotlight,
|
||||
client: _getMediaClientForItem(spotlight),
|
||||
hideSpoilers: hideSpoilers,
|
||||
contentTop: spotlightTop,
|
||||
contentBottom: spotlightBottom,
|
||||
contentLeft: spotlightLeft,
|
||||
compact: true,
|
||||
showPrimaryAction: false,
|
||||
),
|
||||
if (_isLoading || (_areHubsLoading && browseHubs.isEmpty)) const Center(child: CircularProgressIndicator()),
|
||||
if (_errorMessage != null)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const AppIcon(Symbols.error_outline_rounded, fill: 1, size: 64, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
Text(_errorMessage!),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: _loadContent, child: Text(t.common.retry)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!_isLoading && _errorMessage == null && browseHubs.isEmpty && !_areHubsLoading)
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
Text(t.discover.noContentAvailable),
|
||||
const SizedBox(height: 8),
|
||||
Text(t.discover.addMediaToLibraries, style: const TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (browseHubs.isNotEmpty)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: TvBrowseRail(
|
||||
key: _tvBrowseRailKey,
|
||||
hubs: browseHubs,
|
||||
iconForHub: (hub, _) =>
|
||||
hub.id == 'continue_watching' ? Symbols.play_circle_rounded : _getHubIcon(hub.title),
|
||||
onFocusedItemChanged: _setSpotlightItem,
|
||||
onRefresh: updateItem,
|
||||
onRemoveFromContinueWatching: _refreshContinueWatching,
|
||||
isContinueWatchingHub: (hub) => hub.id == 'continue_watching',
|
||||
loadMoreItems: (hub) =>
|
||||
hub.id == 'continue_watching' ? _loadAllContinueWatchingItems() : Future.value(hub.items),
|
||||
onNavigateUp: _focusTopActions,
|
||||
onNavigateToSidebar: _navigateToSidebar,
|
||||
),
|
||||
),
|
||||
Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: _buildOverlaidAppBar())),
|
||||
if (_switchingProfile) const ProfileSwitchingOverlay(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroSection() {
|
||||
final statusBarHeight = MediaQuery.paddingOf(context).top;
|
||||
final useSideNav = PlatformDetector.shouldUseSideNavigation(context);
|
||||
final heroHeight = useSideNav ? MediaQuery.sizeOf(context).height * 0.75 : 500 + statusBarHeight;
|
||||
final isTv = PlatformDetector.isTV();
|
||||
final heroHeight = isTv
|
||||
? MediaQuery.sizeOf(context).height * 0.82
|
||||
: useSideNav
|
||||
? MediaQuery.sizeOf(context).height * 0.75
|
||||
: 500 + statusBarHeight;
|
||||
return SliverToBoxAdapter(
|
||||
child: Focus(
|
||||
focusNode: _heroFocusNode,
|
||||
@@ -1488,6 +1684,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
final showName = heroItem.grandparentTitle ?? heroItem.displayTitle;
|
||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||
final isLargeScreen = ScreenBreakpoints.isWideTabletOrLarger(screenWidth);
|
||||
final isTv = PlatformDetector.isTV();
|
||||
final alignLeft = isTv || isLargeScreen;
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
@@ -1595,7 +1793,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor],
|
||||
stops: const [0.5, 0.85, 1.0],
|
||||
stops: isTv ? const [0.25, 0.78, 1.0] : const [0.5, 0.85, 1.0],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1606,155 +1804,185 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
// Content with responsive alignment
|
||||
Positioned(
|
||||
bottom: isLargeScreen ? 80 : 50,
|
||||
bottom: isTv
|
||||
? 88
|
||||
: isLargeScreen
|
||||
? 80
|
||||
: 50,
|
||||
left: 0,
|
||||
right: isLargeScreen ? 200 : 0,
|
||||
right: isTv
|
||||
? screenWidth * 0.36
|
||||
: isLargeScreen
|
||||
? 200
|
||||
: 0,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: isLargeScreen ? 40 : 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: isLargeScreen ? CrossAxisAlignment.start : CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Show logo or name/title
|
||||
if (heroItem.clearLogoPath != null)
|
||||
SizedBox(
|
||||
height: 120,
|
||||
width: 400,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
|
||||
final logoUrl = MediaImageHelper.getOptimizedImageUrl(
|
||||
client: heroClient,
|
||||
thumbPath: heroItem.clearLogoPath,
|
||||
maxWidth: 400,
|
||||
maxHeight: 120,
|
||||
devicePixelRatio: dpr,
|
||||
imageType: ImageType.logo,
|
||||
);
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isTv
|
||||
? TvLayoutConstants.horizontalInset
|
||||
: isLargeScreen
|
||||
? 40
|
||||
: 24,
|
||||
),
|
||||
child: Align(
|
||||
alignment: alignLeft ? Alignment.centerLeft : Alignment.center,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: isTv ? TvLayoutConstants.heroContentMaxWidth : double.infinity,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: alignLeft ? CrossAxisAlignment.start : CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Show logo or name/title
|
||||
if (heroItem.clearLogoPath != null)
|
||||
SizedBox(
|
||||
height: isTv ? TvLayoutConstants.heroLogoHeight : 120,
|
||||
width: isTv ? TvLayoutConstants.heroLogoWidth : 400,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
|
||||
final logoUrl = MediaImageHelper.getOptimizedImageUrl(
|
||||
client: heroClient,
|
||||
thumbPath: heroItem.clearLogoPath,
|
||||
maxWidth: isTv ? TvLayoutConstants.heroLogoWidth : 400,
|
||||
maxHeight: isTv ? TvLayoutConstants.heroLogoHeight : 120,
|
||||
devicePixelRatio: dpr,
|
||||
imageType: ImageType.logo,
|
||||
);
|
||||
|
||||
return blurArtwork(
|
||||
CachedNetworkImage(
|
||||
imageUrl: logoUrl,
|
||||
cacheManager: PlexImageCacheManager.instance,
|
||||
filterQuality: FilterQuality.medium,
|
||||
fit: BoxFit.contain,
|
||||
memCacheWidth: (400 * dpr).clamp(200, 800).round(),
|
||||
alignment: isLargeScreen ? Alignment.bottomLeft : Alignment.bottomCenter,
|
||||
placeholder: (context, url) => const SizedBox.shrink(),
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
// Fallback to text if logo fails to load
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
return Align(
|
||||
alignment: isLargeScreen ? Alignment.centerLeft : Alignment.center,
|
||||
child: Text(
|
||||
showName,
|
||||
style: theme.textTheme.displaySmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8),
|
||||
],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
sigma: 10,
|
||||
clip: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
showName,
|
||||
style: theme.textTheme.displaySmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8)],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
||||
),
|
||||
|
||||
// Metadata as dot-separated text with content type
|
||||
if (heroItem.year != null || heroItem.contentRating != null || heroItem.rating != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
[
|
||||
contentTypeLabel,
|
||||
if (heroItem.rating != null) '★ ${formatRating(heroItem.rating!)}',
|
||||
if (heroItem.contentRating != null) formatContentRating(heroItem.contentRating!),
|
||||
if (heroItem.year != null) heroItem.year.toString(),
|
||||
].join(' • '),
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
// On small screens: show button before summary
|
||||
if (!isLargeScreen) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
|
||||
|
||||
// Summary with episode info (Apple TV style)
|
||||
if (heroItem.summary != null && !shouldHideSpoiler) ...[
|
||||
const SizedBox(height: 12),
|
||||
RichText(
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
||||
text: TextSpan(
|
||||
style: TextStyle(
|
||||
color: isLargeScreen
|
||||
? Colors.white.withValues(alpha: 0.7)
|
||||
: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
),
|
||||
children: [
|
||||
if (isEpisode && heroItem.parentIndex != null && heroItem.index != null)
|
||||
TextSpan(
|
||||
text: 'S${heroItem.parentIndex}, E${heroItem.index}: ',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isLargeScreen ? Colors.white : colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: heroItem.summary?.isNotEmpty == true
|
||||
? heroItem.summary!
|
||||
: t.messages.noDescriptionAvailable,
|
||||
return blurArtwork(
|
||||
CachedNetworkImage(
|
||||
imageUrl: logoUrl,
|
||||
cacheManager: PlexImageCacheManager.instance,
|
||||
filterQuality: FilterQuality.medium,
|
||||
fit: BoxFit.contain,
|
||||
memCacheWidth: ((isTv ? TvLayoutConstants.heroLogoWidth : 400) * dpr)
|
||||
.clamp(200, isTv ? 1000 : 800)
|
||||
.round(),
|
||||
alignment: alignLeft ? Alignment.bottomLeft : Alignment.bottomCenter,
|
||||
placeholder: (context, url) => const SizedBox.shrink(),
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
// Fallback to text if logo fails to load
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
return Align(
|
||||
alignment: alignLeft ? Alignment.centerLeft : Alignment.center,
|
||||
child: Text(
|
||||
showName,
|
||||
style: theme.textTheme.displaySmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: isTv ? 52 : null,
|
||||
shadows: [
|
||||
Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8),
|
||||
],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
sigma: 10,
|
||||
clip: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else if (shouldHideSpoiler &&
|
||||
isEpisode &&
|
||||
heroItem.parentIndex != null &&
|
||||
heroItem.index != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'S${heroItem.parentIndex}, E${heroItem.index}: ${heroItem.title}',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: isLargeScreen
|
||||
? Colors.white.withValues(alpha: 0.7)
|
||||
: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
fontSize: 14,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Text(
|
||||
showName,
|
||||
style: theme.textTheme.displaySmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: isTv ? 52 : null,
|
||||
shadows: [Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8)],
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
|
||||
),
|
||||
|
||||
// On large screens: show button after summary
|
||||
if (isLargeScreen) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
|
||||
],
|
||||
// Metadata as dot-separated text with content type
|
||||
if (heroItem.year != null || heroItem.contentRating != null || heroItem.rating != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
[
|
||||
contentTypeLabel,
|
||||
if (heroItem.rating != null) '★ ${formatRating(heroItem.rating!)}',
|
||||
if (heroItem.contentRating != null) formatContentRating(heroItem.contentRating!),
|
||||
if (heroItem.year != null) heroItem.year.toString(),
|
||||
].join(' • '),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: isTv ? 18 : 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
|
||||
),
|
||||
],
|
||||
|
||||
// On small screens: show button before summary
|
||||
if (!alignLeft) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
|
||||
|
||||
// Summary with episode info (Apple TV style)
|
||||
if (heroItem.summary != null && !shouldHideSpoiler) ...[
|
||||
const SizedBox(height: 12),
|
||||
RichText(
|
||||
maxLines: isTv ? 3 : 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
|
||||
text: TextSpan(
|
||||
style: TextStyle(
|
||||
color: alignLeft
|
||||
? Colors.white.withValues(alpha: 0.7)
|
||||
: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
fontSize: isTv ? 18 : 14,
|
||||
height: isTv ? 1.45 : 1.4,
|
||||
),
|
||||
children: [
|
||||
if (isEpisode && heroItem.parentIndex != null && heroItem.index != null)
|
||||
TextSpan(
|
||||
text: 'S${heroItem.parentIndex}, E${heroItem.index}: ',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: alignLeft ? Colors.white : colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: heroItem.summary?.isNotEmpty == true
|
||||
? heroItem.summary!
|
||||
: t.messages.noDescriptionAvailable,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else if (shouldHideSpoiler &&
|
||||
isEpisode &&
|
||||
heroItem.parentIndex != null &&
|
||||
heroItem.index != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'S${heroItem.parentIndex}, E${heroItem.index}: ${heroItem.title}',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: alignLeft ? TextAlign.left : TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: alignLeft
|
||||
? Colors.white.withValues(alpha: 0.7)
|
||||
: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
fontSize: isTv ? 18 : 14,
|
||||
height: isTv ? 1.45 : 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// On large screens: show button after summary
|
||||
if (alignLeft) ...[SizedBox(height: isTv ? 28 : 20), _buildSmartPlayButton(heroItem)],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1766,58 +1994,84 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
|
||||
Widget _buildSmartPlayButton(MediaItem heroItem) {
|
||||
final hasProgress = heroItem.hasActiveProgress;
|
||||
final isTv = PlatformDetector.isTV();
|
||||
|
||||
final minutesLeft = hasProgress ? ((heroItem.durationMs! - heroItem.viewOffsetMs!) / 60000).round() : 0;
|
||||
|
||||
final progress = hasProgress ? heroItem.viewOffsetMs! / heroItem.durationMs! : 0.0;
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
appLogger.d('Playing: ${heroItem.title}');
|
||||
navigateToVideoPlayer(context, metadata: heroItem);
|
||||
},
|
||||
borderRadius: const BorderRadius.all(Radius.circular(24)),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.all(Radius.circular(24))),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 20, color: Colors.black),
|
||||
const SizedBox(width: 8),
|
||||
if (hasProgress) ...[
|
||||
// Progress bar
|
||||
Container(
|
||||
width: 40,
|
||||
height: 6,
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.all(Radius.circular(3)),
|
||||
),
|
||||
child: FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: progress,
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.black,
|
||||
borderRadius: BorderRadius.all(Radius.circular(2)),
|
||||
return ListenableBuilder(
|
||||
listenable: _heroFocusNode,
|
||||
builder: (context, _) {
|
||||
final showFocus = isTv && _heroFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final backgroundColor = showFocus ? colorScheme.primary : Colors.white;
|
||||
final foregroundColor = showFocus ? colorScheme.onPrimary : Colors.black;
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
appLogger.d('Playing: ${heroItem.title}');
|
||||
navigateToVideoPlayer(context, metadata: heroItem);
|
||||
},
|
||||
borderRadius: BorderRadius.all(Radius.circular(isTv ? 32 : 24)),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: EdgeInsets.symmetric(horizontal: isTv ? 34 : 24, vertical: isTv ? 16 : 12),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.all(Radius.circular(isTv ? 32 : 24)),
|
||||
boxShadow: showFocus
|
||||
? [BoxShadow(color: colorScheme.primary.withValues(alpha: 0.35), blurRadius: 28, spreadRadius: 4)]
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppIcon(Symbols.play_arrow_rounded, fill: 1, size: isTv ? 28 : 20, color: foregroundColor),
|
||||
SizedBox(width: isTv ? 12 : 8),
|
||||
if (hasProgress) ...[
|
||||
// Progress bar
|
||||
Container(
|
||||
width: isTv ? 56 : 40,
|
||||
height: isTv ? 8 : 6,
|
||||
decoration: BoxDecoration(
|
||||
color: foregroundColor.withValues(alpha: 0.25),
|
||||
borderRadius: BorderRadius.all(Radius.circular(isTv ? 4 : 3)),
|
||||
),
|
||||
child: FractionallySizedBox(
|
||||
alignment: Alignment.centerLeft,
|
||||
widthFactor: progress,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: foregroundColor,
|
||||
borderRadius: BorderRadius.all(Radius.circular(isTv ? 3 : 2)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
t.discover.minutesLeft(minutes: minutesLeft),
|
||||
style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
] else
|
||||
Text(
|
||||
t.common.play,
|
||||
style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(width: isTv ? 12 : 8),
|
||||
Text(
|
||||
t.discover.minutesLeft(minutes: minutesLeft),
|
||||
style: TextStyle(
|
||||
color: foregroundColor,
|
||||
fontSize: isTv ? 18 : 14,
|
||||
fontWeight: isTv ? FontWeight.w700 : FontWeight.w600,
|
||||
),
|
||||
),
|
||||
] else
|
||||
Text(
|
||||
t.common.play,
|
||||
style: TextStyle(
|
||||
color: foregroundColor,
|
||||
fontSize: isTv ? 18 : 14,
|
||||
fontWeight: isTv ? FontWeight.w700 : FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,6 +401,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
suppressAutoFocus: suppressAutoFocus,
|
||||
onDataLoaded: () => _handleTabDataLoaded(tabIndex),
|
||||
onBack: focusTabBar,
|
||||
onNavigateToChrome: focusTabBar,
|
||||
),
|
||||
LibraryTabType.browse => LibraryBrowseTab(
|
||||
key: _browseTabKey,
|
||||
@@ -1024,6 +1025,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
: null;
|
||||
|
||||
final showMobileTabsRow = selectedLibrary != null && !PlatformDetector.shouldUseSideNavigation(context);
|
||||
final currentTabIndex = _visibleTabs.isEmpty ? 0 : tabController.index.clamp(0, _visibleTabs.length - 1).toInt();
|
||||
final currentTabType = _visibleTabs.isEmpty ? null : _visibleTabs[currentTabIndex];
|
||||
final useTvRecommendedBackdrop = PlatformDetector.isTV() && currentTabType == LibraryTabType.recommended;
|
||||
|
||||
Widget appBar({required bool floating}) => DesktopSliverAppBar(
|
||||
title: _buildAppBarTitle(visibleLibraries, selectedLibrary, groupByServer: groupByServerSetting),
|
||||
@@ -1033,7 +1037,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
pinned: !floating,
|
||||
floating: floating,
|
||||
snap: floating,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
backgroundColor: useTvRecommendedBackdrop ? Colors.transparent : Theme.of(context).scaffoldBackgroundColor,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
scrolledUnderElevation: 0,
|
||||
@@ -1065,6 +1069,41 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildTransparentTvTopBar() {
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: AppBar(
|
||||
primary: false,
|
||||
backgroundColor: Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
title: _buildAppBarTitle(visibleLibraries, selectedLibrary, groupByServer: groupByServerSetting),
|
||||
actions: [
|
||||
FocusableActionBar(
|
||||
key: _actionBarKey,
|
||||
onNavigateLeft: () => getTabChipFocusNode(_visibleTabs.length - 1).requestFocus(),
|
||||
onNavigateDown: _focusCurrentTab,
|
||||
actions: [
|
||||
if (allLibraries.isNotEmpty)
|
||||
FocusableAction(
|
||||
icon: Symbols.edit_rounded,
|
||||
tooltip: t.libraries.manageLibraries,
|
||||
onPressed: _showLibraryManagementSheet,
|
||||
),
|
||||
FocusableAction(
|
||||
icon: Symbols.refresh_rounded,
|
||||
tooltip: t.common.refresh,
|
||||
onPressed: _refreshCurrentTab,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget body;
|
||||
if (isLoadingLibraries) {
|
||||
body = buildSimpleScroll(body: const Center(child: CircularProgressIndicator()));
|
||||
@@ -1092,40 +1131,8 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
),
|
||||
);
|
||||
} else if (selectedLibrary != null) {
|
||||
body = NestedScrollView(
|
||||
controller: _outerScrollController,
|
||||
floatHeaderSlivers: true,
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) => [
|
||||
SliverOverlapAbsorber(
|
||||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
|
||||
sliver: appBar(floating: true),
|
||||
),
|
||||
if (showMobileTabsRow)
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (int i = 0; i < _visibleTabs.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
buildTabChip(
|
||||
_getTabLabel(_visibleTabs[i]),
|
||||
i,
|
||||
onSelectWhenActive: _focusCurrentTab,
|
||||
onNavigateDown: _focusCurrentTabFromTabBar,
|
||||
onNavigateRightFromLast: () => _actionBarKey.currentState?.requestFocusOnFirst(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
body: TabBarView(
|
||||
Widget buildTabs() {
|
||||
return TabBarView(
|
||||
key: ValueKey(_selectedLibraryGlobalKey),
|
||||
controller: tabController,
|
||||
// Disable swipe on desktop - trackpad scrolling triggers accidental tab switches
|
||||
@@ -1144,15 +1151,64 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
if (useTvRecommendedBackdrop) {
|
||||
body = Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
buildTabs(),
|
||||
Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: buildTransparentTvTopBar())),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
body = NestedScrollView(
|
||||
controller: _outerScrollController,
|
||||
floatHeaderSlivers: true,
|
||||
headerSliverBuilder: (context, innerBoxIsScrolled) => [
|
||||
SliverOverlapAbsorber(
|
||||
handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
|
||||
sliver: appBar(floating: true),
|
||||
),
|
||||
if (showMobileTabsRow)
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
for (int i = 0; i < _visibleTabs.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
buildTabChip(
|
||||
_getTabLabel(_visibleTabs[i]),
|
||||
i,
|
||||
onSelectWhenActive: _focusCurrentTab,
|
||||
onNavigateDown: _focusCurrentTabFromTabBar,
|
||||
onNavigateRightFromLast: () => _actionBarKey.currentState?.requestFocusOnFirst(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
body: buildTabs(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
body = buildSimpleScroll(body: const SizedBox.shrink());
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: ScrollConfiguration(behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), child: body),
|
||||
final scrollBody = ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false),
|
||||
child: body,
|
||||
);
|
||||
|
||||
return Scaffold(body: scrollBody);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,16 +8,24 @@ import '../../../media/media_hub.dart';
|
||||
import '../../../media/media_item.dart';
|
||||
import '../../../mixins/item_updatable.dart';
|
||||
import '../../../mixins/watch_state_aware.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
import '../../../utils/global_key_utils.dart';
|
||||
import '../../../utils/layout_constants.dart';
|
||||
import '../../../utils/platform_detector.dart';
|
||||
import '../../../utils/provider_extensions.dart';
|
||||
import '../../../utils/watch_state_notifier.dart';
|
||||
import '../../../widgets/hub_section.dart';
|
||||
import '../../../widgets/settings_builder.dart';
|
||||
import '../../../widgets/tv_browse_rail.dart';
|
||||
import '../../../widgets/tv_spotlight_background.dart';
|
||||
import '../../main_screen.dart';
|
||||
import 'base_library_tab.dart';
|
||||
|
||||
/// Recommended tab for library screen
|
||||
/// Shows library-specific hubs and recommendations, including dedicated Continue Watching
|
||||
class LibraryRecommendedTab extends BaseLibraryTab<MediaHub> {
|
||||
final VoidCallback? onNavigateToChrome;
|
||||
|
||||
const LibraryRecommendedTab({
|
||||
super.key,
|
||||
required super.library,
|
||||
@@ -25,6 +33,7 @@ class LibraryRecommendedTab extends BaseLibraryTab<MediaHub> {
|
||||
super.isActive,
|
||||
super.suppressAutoFocus,
|
||||
super.onBack,
|
||||
this.onNavigateToChrome,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -35,6 +44,29 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
with ItemUpdatable, WatchStateAware {
|
||||
/// GlobalKeys for each hub section to enable vertical navigation
|
||||
final List<GlobalKey<HubSectionState>> _hubKeys = [];
|
||||
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
|
||||
MediaItem? _spotlightItem;
|
||||
|
||||
MediaItem? get _defaultSpotlightItem {
|
||||
for (final hub in items) {
|
||||
if (hub.items.isNotEmpty) return hub.items.first;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
MediaItem? get _effectiveSpotlightItem {
|
||||
final current = _spotlightItem;
|
||||
if (current == null) return _defaultSpotlightItem;
|
||||
for (final hub in items) {
|
||||
if (hub.items.any((item) => item.globalKey == current.globalKey)) return current;
|
||||
}
|
||||
return _defaultSpotlightItem;
|
||||
}
|
||||
|
||||
void _setSpotlightItem(MediaItem item) {
|
||||
if (_spotlightItem?.globalKey == item.globalKey) return;
|
||||
setState(() => _spotlightItem = item);
|
||||
}
|
||||
|
||||
@override
|
||||
String? get itemServerId => widget.library.serverId;
|
||||
@@ -202,6 +234,10 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
/// Focus the first item in the first hub (for tab activation)
|
||||
@override
|
||||
void focusFirstItem() {
|
||||
if (PlatformDetector.isTV()) {
|
||||
_tvBrowseRailKey.currentState?.requestFocus();
|
||||
return;
|
||||
}
|
||||
if (_hubKeys.isNotEmpty && items.isNotEmpty) {
|
||||
_hubKeys.first.currentState?.requestFocusAt(0);
|
||||
}
|
||||
@@ -219,6 +255,10 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
Widget buildContent(List<MediaHub> items) {
|
||||
_ensureHubKeys(items.length);
|
||||
|
||||
if (PlatformDetector.isTV()) {
|
||||
return _buildTvContent(items);
|
||||
}
|
||||
|
||||
return CustomScrollView(
|
||||
// Allow focus decoration to render outside scroll bounds
|
||||
clipBehavior: Clip.none,
|
||||
@@ -251,6 +291,56 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTvContent(List<MediaHub> items) {
|
||||
final tvHubs = items.where((hub) => hub.items.isNotEmpty).toList();
|
||||
final spotlight = _effectiveSpotlightItem;
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final theme = Theme.of(context);
|
||||
final client = context.tryGetMediaClientForServer(spotlight?.serverId ?? widget.library.serverId);
|
||||
final spotlightTop = (size.height * 0.1).clamp(96.0, 150.0).toDouble();
|
||||
final spotlightBottom = (size.height * 0.53).clamp(180.0, 900.0).toDouble();
|
||||
final spotlightLeft = (24 * TvLayoutConstants.scaleForSize(size)).clamp(18.0, 40.0).toDouble();
|
||||
|
||||
return Material(
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
child: SizedBox.expand(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
TvSpotlightBackground(
|
||||
item: spotlight,
|
||||
client: client,
|
||||
hideSpoilers: context.settingsRead(SettingsService.hideSpoilers),
|
||||
contentTop: spotlightTop,
|
||||
contentBottom: spotlightBottom,
|
||||
contentLeft: spotlightLeft,
|
||||
compact: true,
|
||||
showPrimaryAction: false,
|
||||
),
|
||||
if (tvHubs.isNotEmpty)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: TvBrowseRail(
|
||||
key: _tvBrowseRailKey,
|
||||
hubs: tvHubs,
|
||||
iconForHub: (hub, _) => _getHubIcon(hub),
|
||||
onFocusedItemChanged: _setSpotlightItem,
|
||||
onRefresh: updateItem,
|
||||
onRemoveFromContinueWatching: _refreshContinueWatching,
|
||||
isContinueWatchingHub: _isContinueWatchingHub,
|
||||
onNavigateUp: widget.onNavigateToChrome ?? widget.onBack,
|
||||
onNavigateToSidebar: _navigateToSidebar,
|
||||
onBack: widget.onBack,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Refresh the Continue Watching section
|
||||
void _refreshContinueWatching() {
|
||||
// Reload all data to refresh the continue watching section
|
||||
|
||||
@@ -2,8 +2,11 @@ part of '../media_detail_screen.dart';
|
||||
|
||||
extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
Widget _buildActionButtons(MediaItem metadata) {
|
||||
final isTv = PlatformDetector.isTV();
|
||||
final tvScale = TvLayoutConstants.scaleOf(context);
|
||||
final actionSize = isTv ? _tvDetailActionSize * tvScale : 48.0;
|
||||
final playButtonLabel = _getPlayButtonLabel(metadata);
|
||||
final playButtonIcon = AppIcon(_getPlayButtonIcon(metadata), fill: 1, size: 20);
|
||||
final playButtonIcon = AppIcon(_getPlayButtonIcon(metadata), fill: 1, size: isTv ? 22 * tvScale : 20);
|
||||
|
||||
Future<void> onPlayPressed() async {
|
||||
// For TV shows, play the OnDeck episode if available
|
||||
@@ -56,6 +59,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
final focusBg = colorScheme.inverseSurface;
|
||||
final focusFg = colorScheme.onInverseSurface;
|
||||
final tonalBg = colorScheme.secondaryContainer;
|
||||
final idleBg = isTv ? tonalBg.withValues(alpha: 0.38) : tonalBg;
|
||||
final tonalFg = colorScheme.onSecondaryContainer;
|
||||
final noOverlay = WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.focused)) return Colors.transparent;
|
||||
@@ -63,7 +67,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
});
|
||||
|
||||
ButtonStyle actionButtonStyle({Color? foregroundColor, EdgeInsetsGeometry? padding}) {
|
||||
if (!isKeyboardMode) {
|
||||
if (!isKeyboardMode && !isTv) {
|
||||
if (padding != null) {
|
||||
return FilledButton.styleFrom(padding: padding);
|
||||
}
|
||||
@@ -75,12 +79,15 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
}
|
||||
return ButtonStyle(
|
||||
padding: padding != null ? WidgetStatePropertyAll(padding) : null,
|
||||
minimumSize: padding == null ? const WidgetStatePropertyAll(Size(48, 48)) : null,
|
||||
maximumSize: padding == null ? const WidgetStatePropertyAll(Size(48, 48)) : null,
|
||||
minimumSize: WidgetStatePropertyAll(padding == null ? Size.square(actionSize) : Size(0, actionSize)),
|
||||
maximumSize: padding == null ? WidgetStatePropertyAll(Size.square(actionSize)) : null,
|
||||
fixedSize: padding == null ? WidgetStatePropertyAll(Size.square(actionSize)) : null,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
overlayColor: noOverlay,
|
||||
backgroundColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.focused)) return focusBg;
|
||||
return tonalBg;
|
||||
return idleBg;
|
||||
}),
|
||||
foregroundColor: WidgetStateProperty.resolveWith((states) {
|
||||
if (states.contains(WidgetState.focused)) return focusFg;
|
||||
@@ -95,25 +102,30 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 48,
|
||||
height: actionSize,
|
||||
child: FilledButton(
|
||||
focusNode: _playButtonFocusNode,
|
||||
autofocus: isKeyboardMode,
|
||||
onPressed: onPlayPressed,
|
||||
style: actionButtonStyle(padding: const EdgeInsets.symmetric(horizontal: 16)),
|
||||
style: actionButtonStyle(
|
||||
padding: EdgeInsets.symmetric(horizontal: isTv ? 17 * tvScale : 16, vertical: isTv ? 9 * tvScale : 0),
|
||||
),
|
||||
child: playButtonLabel.isNotEmpty
|
||||
? Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
playButtonIcon,
|
||||
const SizedBox(width: 8),
|
||||
Text(playButtonLabel, style: const TextStyle(fontSize: 16)),
|
||||
SizedBox(width: isTv ? 7 * tvScale : 8),
|
||||
Text(
|
||||
playButtonLabel,
|
||||
style: TextStyle(fontSize: isTv ? 17 * tvScale : 16, fontWeight: FontWeight.w700),
|
||||
),
|
||||
],
|
||||
)
|
||||
: playButtonIcon,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(width: isTv ? 8 * tvScale : 12),
|
||||
// Trailer button (only if trailer is available)
|
||||
if (primaryTrailer != null) ...[
|
||||
IconButton.filledTonal(
|
||||
@@ -122,10 +134,10 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
},
|
||||
icon: const AppIcon(Symbols.theaters_rounded, fill: 1),
|
||||
tooltip: t.tooltips.playTrailer,
|
||||
iconSize: 20,
|
||||
iconSize: isTv ? 21 * tvScale : 20,
|
||||
style: actionButtonStyle(),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(width: isTv ? 8 * tvScale : 12),
|
||||
],
|
||||
// Shuffle button (only for shows and seasons)
|
||||
if (metadata.isShow || metadata.isSeason) ...[
|
||||
@@ -135,19 +147,23 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
},
|
||||
icon: const AppIcon(Symbols.shuffle_rounded, fill: 1),
|
||||
tooltip: t.tooltips.shufflePlay,
|
||||
iconSize: 20,
|
||||
iconSize: isTv ? 21 * tvScale : 20,
|
||||
style: actionButtonStyle(),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
SizedBox(width: isTv ? 8 * tvScale : 12),
|
||||
],
|
||||
// Download button (hide in offline mode - already downloaded,
|
||||
// and on Apple TV where there's no user file storage).
|
||||
if (!widget.isOffline && !PlatformDetector.isAppleTV()) _buildDownloadButton(metadata, actionButtonStyle),
|
||||
const SizedBox(width: 12),
|
||||
if (!widget.isOffline && !PlatformDetector.isAppleTV())
|
||||
_buildDownloadButton(metadata, actionButtonStyle, tvScale),
|
||||
SizedBox(width: isTv ? 8 * tvScale : 12),
|
||||
// Mark as watched/unwatched toggle (works offline too)
|
||||
_buildWatchedToggleButton(metadata, actionButtonStyle),
|
||||
_buildWatchedToggleButton(metadata, actionButtonStyle, tvScale),
|
||||
// Three-dots menu button (hidden in offline mode)
|
||||
if (!widget.isOffline) ...[const SizedBox(width: 12), _buildMoreActionsButton(metadata, actionButtonStyle)],
|
||||
if (!widget.isOffline) ...[
|
||||
SizedBox(width: isTv ? 8 * tvScale : 12),
|
||||
_buildMoreActionsButton(metadata, actionButtonStyle, tvScale),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -156,6 +172,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
Widget _buildWatchedToggleButton(
|
||||
MediaItem metadata,
|
||||
ButtonStyle Function({Color? foregroundColor, EdgeInsetsGeometry? padding}) actionButtonStyle,
|
||||
double tvScale,
|
||||
) {
|
||||
return IconButton.filledTonal(
|
||||
onPressed: () async {
|
||||
@@ -201,7 +218,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
},
|
||||
icon: AppIcon(metadata.isWatched ? Symbols.remove_done_rounded : Symbols.check_rounded, fill: 1),
|
||||
tooltip: metadata.isWatched ? t.tooltips.markAsUnwatched : t.tooltips.markAsWatched,
|
||||
iconSize: 20,
|
||||
iconSize: PlatformDetector.isTV() ? 21 * tvScale : 20,
|
||||
style: actionButtonStyle(),
|
||||
);
|
||||
}
|
||||
@@ -209,6 +226,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
Widget _buildMoreActionsButton(
|
||||
MediaItem metadata,
|
||||
ButtonStyle Function({Color? foregroundColor, EdgeInsetsGeometry? padding}) actionButtonStyle,
|
||||
double tvScale,
|
||||
) {
|
||||
return MediaContextMenu(
|
||||
key: _contextMenuKey,
|
||||
@@ -224,7 +242,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
}
|
||||
},
|
||||
icon: const AppIcon(Symbols.more_vert_rounded, fill: 1),
|
||||
iconSize: 20,
|
||||
iconSize: PlatformDetector.isTV() ? 21 * tvScale : 20,
|
||||
style: actionButtonStyle(),
|
||||
),
|
||||
),
|
||||
@@ -234,9 +252,11 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
Widget _buildDownloadButton(
|
||||
MediaItem metadata,
|
||||
ButtonStyle Function({Color? foregroundColor, EdgeInsetsGeometry? padding}) actionButtonStyle,
|
||||
double tvScale,
|
||||
) {
|
||||
return Consumer<DownloadProvider>(
|
||||
builder: (context, downloadProvider, _) {
|
||||
final iconSize = PlatformDetector.isTV() ? 21.0 * tvScale : 20.0;
|
||||
final globalKey = metadata.globalKey;
|
||||
final ruleKey = _syncRuleKeyForMetadata(context, downloadProvider, metadata);
|
||||
final progress = downloadProvider.getProgress(globalKey);
|
||||
@@ -251,8 +271,8 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
if (isQueueing) {
|
||||
return IconButton.filledTonal(
|
||||
onPressed: null,
|
||||
icon: const LoadingIndicatorBox(size: 20),
|
||||
iconSize: 20,
|
||||
icon: LoadingIndicatorBox(size: iconSize),
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(),
|
||||
);
|
||||
}
|
||||
@@ -268,7 +288,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
onPressed: null,
|
||||
tooltip: tooltip,
|
||||
icon: const AppIcon(Symbols.schedule_rounded, fill: 1),
|
||||
iconSize: 20,
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(),
|
||||
);
|
||||
}
|
||||
@@ -285,7 +305,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
onPressed: null,
|
||||
tooltip: tooltip,
|
||||
icon: _buildRadialProgress(progress?.progressPercent),
|
||||
iconSize: 20,
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(),
|
||||
);
|
||||
}
|
||||
@@ -303,7 +323,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
},
|
||||
icon: const AppIcon(Symbols.pause_circle_outline_rounded, fill: 1),
|
||||
tooltip: 'Resume download',
|
||||
iconSize: 20,
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(foregroundColor: Colors.amber),
|
||||
);
|
||||
}
|
||||
@@ -333,7 +353,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
},
|
||||
icon: const AppIcon(Symbols.error_outline_rounded, fill: 1),
|
||||
tooltip: 'Retry download',
|
||||
iconSize: 20,
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(foregroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
@@ -378,7 +398,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
},
|
||||
icon: const AppIcon(Symbols.cancel_rounded, fill: 1),
|
||||
tooltip: 'Cancelled download',
|
||||
iconSize: 20,
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(foregroundColor: Colors.grey),
|
||||
);
|
||||
}
|
||||
@@ -406,7 +426,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
),
|
||||
tooltip: tooltip,
|
||||
icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1),
|
||||
iconSize: 20,
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(foregroundColor: isEnabled ? Colors.teal : Colors.grey),
|
||||
);
|
||||
}
|
||||
@@ -434,7 +454,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
},
|
||||
tooltip: tooltip,
|
||||
icon: const AppIcon(Symbols.downloading_rounded, fill: 1),
|
||||
iconSize: 20,
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(foregroundColor: Colors.orange),
|
||||
);
|
||||
}
|
||||
@@ -457,7 +477,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
),
|
||||
icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1),
|
||||
tooltip: t.downloads.keepNUnwatched(count: syncRule?.episodeCount.toString() ?? '?'),
|
||||
iconSize: 20,
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(foregroundColor: isEnabled ? Colors.teal : Colors.grey),
|
||||
);
|
||||
}
|
||||
@@ -480,7 +500,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
},
|
||||
icon: const AppIcon(Symbols.file_download_done_rounded, fill: 1),
|
||||
tooltip: t.downloads.deleteDownload,
|
||||
iconSize: 20,
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(foregroundColor: Colors.green),
|
||||
);
|
||||
}
|
||||
@@ -509,7 +529,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState {
|
||||
},
|
||||
icon: const AppIcon(Symbols.download_rounded, fill: 1),
|
||||
tooltip: t.downloads.downloadNow,
|
||||
iconSize: 20,
|
||||
iconSize: iconSize,
|
||||
style: actionButtonStyle(),
|
||||
);
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,3 +51,20 @@ class GridLayoutConstants {
|
||||
/// Standard grid padding
|
||||
static EdgeInsets get gridPadding => const EdgeInsets.only(left: 2, right: 2, bottom: 2);
|
||||
}
|
||||
|
||||
class TvLayoutConstants {
|
||||
static const double horizontalInset = 72;
|
||||
static const double shelfHorizontalInset = 56;
|
||||
static const double shelfVerticalGap = 32;
|
||||
static const double heroContentMaxWidth = 760;
|
||||
static const double heroLogoWidth = 520;
|
||||
static const double heroLogoHeight = 150;
|
||||
static const double compactHeroLogoWidth = 420;
|
||||
static const double compactHeroLogoHeight = 112;
|
||||
|
||||
static double scaleForHeight(double height) => (height / 1080).clamp(0.85, 1.35).toDouble();
|
||||
|
||||
static double scaleForSize(Size size) => scaleForHeight(size.height);
|
||||
|
||||
static double scaleOf(BuildContext context) => scaleForSize(MediaQuery.sizeOf(context));
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../focus/focusable_chip_mixin.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import 'focus_builders.dart';
|
||||
|
||||
/// A focusable tab chip that shows a color change when focused or selected.
|
||||
@@ -122,8 +123,13 @@ class _FocusableTabChipState extends State<FocusableTabChip> with FocusableChipS
|
||||
foregroundColor = colorScheme.onPrimary;
|
||||
} else {
|
||||
// Neither selected nor focused
|
||||
backgroundColor = colorScheme.surfaceContainerHighest;
|
||||
foregroundColor = colorScheme.onSurfaceVariant;
|
||||
if (PlatformDetector.isTV()) {
|
||||
backgroundColor = colorScheme.secondaryContainer.withValues(alpha: 0.38);
|
||||
foregroundColor = colorScheme.onSecondaryContainer;
|
||||
} else {
|
||||
backgroundColor = colorScheme.surfaceContainerHighest;
|
||||
foregroundColor = colorScheme.onSurfaceVariant;
|
||||
}
|
||||
}
|
||||
|
||||
final isHighlighted = showFocus || widget.isSelected;
|
||||
|
||||
+217
-169
@@ -11,6 +11,8 @@ import '../focus/key_event_utils.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import 'settings_builder.dart';
|
||||
import '../utils/grid_size_calculator.dart';
|
||||
import '../utils/layout_constants.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../theme/mono_tokens.dart';
|
||||
import '../focus/locked_hub_controller.dart';
|
||||
import '../media/media_hub.dart';
|
||||
@@ -41,6 +43,9 @@ class HubSection extends StatefulWidget {
|
||||
final bool showServerName;
|
||||
final Future<List<MediaItem>> Function()? loadMoreItems;
|
||||
|
||||
/// Reports the current focused media item. Used by TV spotlight layouts.
|
||||
final ValueChanged<MediaItem>? onFocusedItemChanged;
|
||||
|
||||
/// Callback for vertical navigation (up/down). Return true if handled.
|
||||
final bool Function(bool isUp)? onVerticalNavigation;
|
||||
|
||||
@@ -60,6 +65,9 @@ class HubSection extends StatefulWidget {
|
||||
/// Use when the parent already provides edge spacing (e.g. inside Padding(16)).
|
||||
final bool inset;
|
||||
|
||||
/// Vertical viewport alignment when this hub is focused.
|
||||
final double focusScrollAlignment;
|
||||
|
||||
const HubSection({
|
||||
super.key,
|
||||
required this.hub,
|
||||
@@ -69,11 +77,13 @@ class HubSection extends StatefulWidget {
|
||||
this.isInContinueWatching = false,
|
||||
this.showServerName = false,
|
||||
this.loadMoreItems,
|
||||
this.onFocusedItemChanged,
|
||||
this.onVerticalNavigation,
|
||||
this.onBack,
|
||||
this.onNavigateUp,
|
||||
this.onNavigateToSidebar,
|
||||
this.inset = false,
|
||||
this.focusScrollAlignment = 0.3,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -90,7 +100,12 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
int _focusedIndex = 0;
|
||||
|
||||
double _itemExtent = 0;
|
||||
double get _leadingPadding => widget.inset ? 0.0 : 12.0;
|
||||
double _leadingPaddingFor(bool isTv) => widget.inset
|
||||
? 0.0
|
||||
: isTv
|
||||
? TvLayoutConstants.shelfHorizontalInset
|
||||
: 12.0;
|
||||
double get _leadingPadding => _leadingPaddingFor(PlatformDetector.isTV());
|
||||
|
||||
Timer? _longPressTimer;
|
||||
bool _isSelectKeyDown = false;
|
||||
@@ -109,6 +124,12 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
@override
|
||||
void didUpdateWidget(HubSection oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.hub.id != oldWidget.hub.id) {
|
||||
_mediaCardKeys.clear();
|
||||
} else if (widget.hub.items.length != oldWidget.hub.items.length) {
|
||||
_mediaCardKeys.removeWhere((index, _) => index >= widget.hub.items.length);
|
||||
}
|
||||
|
||||
if (widget.hub.items.length != oldWidget.hub.items.length) {
|
||||
final maxIndex = _totalItemCount == 0 ? 0 : _totalItemCount - 1;
|
||||
if (_focusedIndex > maxIndex) {
|
||||
@@ -132,6 +153,8 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
_longPressTimer?.cancel();
|
||||
_isSelectKeyDown = false;
|
||||
_longPressTriggered = false;
|
||||
} else {
|
||||
_notifyFocusedItemChanged();
|
||||
}
|
||||
// ignore: no-empty-block - setState triggers rebuild to update focus styling
|
||||
setStateIfMounted(() {});
|
||||
@@ -141,10 +164,11 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
void requestFocusAt(int index) {
|
||||
if (_totalItemCount == 0) return;
|
||||
|
||||
final clamped = index.clamp(0, _totalItemCount - 1);
|
||||
final clamped = index.clamp(0, _totalItemCount - 1).toInt();
|
||||
_focusedIndex = clamped;
|
||||
// Remember this position for this specific hub
|
||||
HubFocusMemory.setForHub(widget.hub.id, clamped);
|
||||
_notifyFocusedItemChanged();
|
||||
_scrollToIndex(clamped);
|
||||
_hubFocusNode.requestFocus();
|
||||
// ignore: no-empty-block - setState triggers rebuild to update focus styling
|
||||
@@ -165,7 +189,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
if (!mounted) return;
|
||||
Scrollable.ensureVisible(
|
||||
context,
|
||||
alignment: 0.3, // Position hub near top third of viewport
|
||||
alignment: widget.focusScrollAlignment,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
@@ -245,6 +269,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
_focusedIndex--;
|
||||
});
|
||||
HubFocusMemory.setForHub(widget.hub.id, _focusedIndex);
|
||||
_notifyFocusedItemChanged();
|
||||
_scrollToIndex(_focusedIndex);
|
||||
} else if (widget.onNavigateToSidebar != null) {
|
||||
// At leftmost item: navigate to sidebar
|
||||
@@ -261,6 +286,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
_focusedIndex++;
|
||||
});
|
||||
HubFocusMemory.setForHub(widget.hub.id, _focusedIndex);
|
||||
_notifyFocusedItemChanged();
|
||||
_scrollToIndex(_focusedIndex);
|
||||
}
|
||||
return KeyEventResult.handled;
|
||||
@@ -296,6 +322,11 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
return _mediaCardKeys.putIfAbsent(index, () => GlobalKey<MediaCardState>());
|
||||
}
|
||||
|
||||
void _notifyFocusedItemChanged() {
|
||||
if (_focusedIndex < 0 || _focusedIndex >= widget.hub.items.length) return;
|
||||
widget.onFocusedItemChanged?.call(widget.hub.items[_focusedIndex]);
|
||||
}
|
||||
|
||||
void _activateCurrentItem() {
|
||||
if (_focusedIndex == widget.hub.items.length && widget.hub.more) {
|
||||
_navigateToHubDetail(context);
|
||||
@@ -312,7 +343,7 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
_mediaCardKeys[_focusedIndex]?.currentState?.showContextMenu();
|
||||
}
|
||||
|
||||
Future<void> _navigateToItem(dynamic item) async {
|
||||
Future<void> _navigateToItem(MediaItem item) async {
|
||||
await navigateToMediaItem(context, item, onRefresh: widget.onRefresh, playDirectly: widget.isInContinueWatching);
|
||||
}
|
||||
|
||||
@@ -330,124 +361,176 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
);
|
||||
}
|
||||
|
||||
double _getTvCardWidth(double availableWidth, int density, double leadingPadding) {
|
||||
final f = LibraryDensity.factor(density);
|
||||
final targetCards = 7.0 - (f * 2.0);
|
||||
final usableWidth = (availableWidth - (leadingPadding * 2)).clamp(1.0, double.infinity);
|
||||
return (usableWidth / targetCards).clamp(210.0, 340.0);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasFocus = _hubFocusNode.hasFocus;
|
||||
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
||||
final isTv = PlatformDetector.isTV();
|
||||
final leadingPadding = _leadingPaddingFor(isTv);
|
||||
final titleStyle = Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontSize: isTv ? 26 : null, fontWeight: isTv ? FontWeight.w700 : null);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Hub header (NOT focusable - titles should not be focusable)
|
||||
Padding(
|
||||
padding: widget.inset ? const EdgeInsets.symmetric(vertical: 2) : const EdgeInsets.fromLTRB(8, 2, 8, 2),
|
||||
child: ExcludeFocus(
|
||||
child: InkWell(
|
||||
onTap: widget.hub.more ? () => _navigateToHubDetail(context) : null,
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
child: Padding(
|
||||
padding: widget.inset
|
||||
? const EdgeInsets.symmetric(vertical: 2)
|
||||
: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppIcon(widget.icon, fill: 1),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.hub.title,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: isTv && !widget.inset ? TvLayoutConstants.shelfVerticalGap : 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Hub header (NOT focusable - titles should not be focusable)
|
||||
Padding(
|
||||
padding: widget.inset
|
||||
? EdgeInsets.symmetric(vertical: isTv ? 6 : 2)
|
||||
: EdgeInsets.fromLTRB(leadingPadding - 4, isTv ? 6 : 2, 8, isTv ? 8 : 2),
|
||||
child: ExcludeFocus(
|
||||
child: InkWell(
|
||||
onTap: widget.hub.more ? () => _navigateToHubDetail(context) : null,
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
child: Padding(
|
||||
padding: widget.inset
|
||||
? const EdgeInsets.symmetric(vertical: 2)
|
||||
: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppIcon(widget.icon, fill: 1, size: isTv ? 28 : null),
|
||||
SizedBox(width: isTv ? 12 : 8),
|
||||
Flexible(
|
||||
child: Text(widget.hub.title, style: titleStyle, overflow: TextOverflow.ellipsis, maxLines: 1),
|
||||
),
|
||||
),
|
||||
if (widget.showServerName && widget.hub.serverName != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'•',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.7),
|
||||
if (widget.showServerName && widget.hub.serverName != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'•',
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.hub.serverName!,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.7),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
widget.hub.serverName!,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (widget.hub.more && !isKeyboardMode) ...[
|
||||
const SizedBox(width: 4),
|
||||
AppIcon(Symbols.chevron_right_rounded, fill: 1, size: isTv ? 26 : 20),
|
||||
],
|
||||
],
|
||||
if (widget.hub.more && !isKeyboardMode) ...[
|
||||
const SizedBox(width: 4),
|
||||
const AppIcon(Symbols.chevron_right_rounded, fill: 1, size: 20),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (widget.hub.items.isNotEmpty)
|
||||
Focus(
|
||||
focusNode: _hubFocusNode,
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: SettingsBuilder(
|
||||
prefs: const [SettingsService.libraryDensity, SettingsService.episodePosterMode],
|
||||
builder: (context) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final svc = SettingsService.instanceOrNull!;
|
||||
final baseCardWidth = GridSizeCalculator.getCellWidth(
|
||||
constraints.maxWidth,
|
||||
context,
|
||||
svc.read(SettingsService.libraryDensity),
|
||||
);
|
||||
if (widget.hub.items.isNotEmpty)
|
||||
Focus(
|
||||
focusNode: _hubFocusNode,
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: SettingsBuilder(
|
||||
prefs: const [SettingsService.libraryDensity, SettingsService.episodePosterMode],
|
||||
builder: (context) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final svc = SettingsService.instanceOrNull;
|
||||
if (svc == null) return const SizedBox.shrink();
|
||||
final density = svc.read(SettingsService.libraryDensity);
|
||||
final baseCardWidth = isTv
|
||||
? _getTvCardWidth(constraints.maxWidth, density, leadingPadding)
|
||||
: GridSizeCalculator.getCellWidth(constraints.maxWidth, context, density);
|
||||
|
||||
final episodePosterMode = svc.read(SettingsService.episodePosterMode);
|
||||
final episodePosterMode = svc.read(SettingsService.episodePosterMode);
|
||||
|
||||
final hasEpisodes = widget.hub.items.any((item) => item.usesWideAspectRatio(episodePosterMode));
|
||||
final hasNonEpisodes = widget.hub.items.any((item) => !item.usesWideAspectRatio(episodePosterMode));
|
||||
final hasEpisodes = widget.hub.items.any((item) => item.usesWideAspectRatio(episodePosterMode));
|
||||
final hasNonEpisodes = widget.hub.items.any((item) => !item.usesWideAspectRatio(episodePosterMode));
|
||||
|
||||
final isMixedHub = hasEpisodes && hasNonEpisodes;
|
||||
final isMixedHub = hasEpisodes && hasNonEpisodes;
|
||||
|
||||
final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes;
|
||||
final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes;
|
||||
|
||||
// Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode)
|
||||
final useWideLayout =
|
||||
episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub);
|
||||
// Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode)
|
||||
final useWideLayout =
|
||||
episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub);
|
||||
|
||||
// Card dimensions based on hub type
|
||||
const wideCardMultiplier = 1.5;
|
||||
final cardWidth = useWideLayout ? baseCardWidth * wideCardMultiplier : baseCardWidth;
|
||||
final posterWidth = cardWidth - 6; // 3px padding on each side
|
||||
final posterHeight = useWideLayout
|
||||
? posterWidth *
|
||||
(9 / 16) // 16:9 for wide layout
|
||||
: posterWidth * 1.5; // 2:3 for poster layout
|
||||
// Card dimensions based on hub type
|
||||
const wideCardMultiplier = 1.5;
|
||||
final cardWidth = useWideLayout ? baseCardWidth * wideCardMultiplier : baseCardWidth;
|
||||
final posterWidth = cardWidth - 6; // 3px padding on each side
|
||||
final posterHeight = useWideLayout
|
||||
? posterWidth *
|
||||
(9 / 16) // 16:9 for wide layout
|
||||
: posterWidth * 1.5; // 2:3 for poster layout
|
||||
|
||||
final containerHeight = posterHeight + 33;
|
||||
final focusBorderWidth = FocusTheme.focusBorderWidth;
|
||||
final focusExtra = focusBorderWidth * 2; // border on both sides
|
||||
_itemExtent = cardWidth + focusExtra + 4;
|
||||
final containerHeight = posterHeight + (isTv ? 48 : 33);
|
||||
final focusBorderWidth = FocusTheme.focusBorderWidth;
|
||||
final focusExtra = focusBorderWidth * 2; // border on both sides
|
||||
_itemExtent = cardWidth + focusExtra + 4;
|
||||
|
||||
return SizedBox(
|
||||
height: containerHeight + focusExtra + 4, // extra for scale + border top/bottom
|
||||
child: HorizontalScrollWithArrows(
|
||||
controller: _scrollController,
|
||||
builder: (scrollController) => ListView.builder(
|
||||
controller: scrollController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
clipBehavior: Clip.none,
|
||||
padding: widget.inset
|
||||
? const EdgeInsets.symmetric(vertical: 2)
|
||||
: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
itemCount: isKeyboardMode ? _totalItemCount : widget.hub.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final isItemFocused = hasFocus && index == _focusedIndex;
|
||||
return SizedBox(
|
||||
height: containerHeight + focusExtra + (isTv ? 12 : 4), // extra for scale + border top/bottom
|
||||
child: HorizontalScrollWithArrows(
|
||||
controller: _scrollController,
|
||||
builder: (scrollController) => ListView.builder(
|
||||
controller: scrollController,
|
||||
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),
|
||||
itemCount: isKeyboardMode ? _totalItemCount : widget.hub.items.length,
|
||||
itemBuilder: (context, index) {
|
||||
final isItemFocused = hasFocus && index == _focusedIndex;
|
||||
|
||||
if (index == widget.hub.items.length) {
|
||||
return Padding(
|
||||
padding: widget.inset
|
||||
? const EdgeInsets.only(right: 4)
|
||||
: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: FocusBuilders.buildLockedFocusWrapper(
|
||||
context: context,
|
||||
isFocused: isItemFocused,
|
||||
onTap: () {
|
||||
_onItemTapped(index);
|
||||
_navigateToHubDetail(context);
|
||||
},
|
||||
child: SizedBox(
|
||||
width: isTv ? 118 : 80,
|
||||
height: containerHeight - 10,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.arrow_forward_rounded,
|
||||
size: isTv ? 42 : 32,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
t.common.viewAll,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
fontSize: isTv ? 16 : null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final item = widget.hub.items[index];
|
||||
|
||||
if (index == widget.hub.items.length) {
|
||||
return Padding(
|
||||
padding: widget.inset
|
||||
? const EdgeInsets.only(right: 4)
|
||||
@@ -455,88 +538,53 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
|
||||
child: FocusBuilders.buildLockedFocusWrapper(
|
||||
context: context,
|
||||
isFocused: isItemFocused,
|
||||
onTap: () {
|
||||
_onItemTapped(index);
|
||||
_navigateToHubDetail(context);
|
||||
},
|
||||
child: SizedBox(
|
||||
width: 80,
|
||||
height: containerHeight - 10,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.arrow_forward_rounded,
|
||||
size: 32,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
t.common.viewAll,
|
||||
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onTap: () => _onItemTapped(index),
|
||||
onLongPress: () => _mediaCardKeys[index]?.currentState?.showContextMenu(),
|
||||
child: MediaCard(
|
||||
key: _getMediaCardKey(index),
|
||||
item: item,
|
||||
width: cardWidth,
|
||||
height: posterHeight,
|
||||
onRefresh: widget.onRefresh,
|
||||
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
|
||||
forceGridMode: true,
|
||||
isInContinueWatching: widget.isInContinueWatching,
|
||||
mixedHubContext: isMixedHub,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final item = widget.hub.items[index];
|
||||
|
||||
return Padding(
|
||||
padding: widget.inset
|
||||
? const EdgeInsets.only(right: 4)
|
||||
: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: FocusBuilders.buildLockedFocusWrapper(
|
||||
context: context,
|
||||
isFocused: isItemFocused,
|
||||
onTap: () => _onItemTapped(index),
|
||||
onLongPress: () => _mediaCardKeys[index]?.currentState?.showContextMenu(),
|
||||
child: MediaCard(
|
||||
key: _getMediaCardKey(index),
|
||||
item: item,
|
||||
width: cardWidth,
|
||||
height: posterHeight,
|
||||
onRefresh: widget.onRefresh,
|
||||
onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching,
|
||||
forceGridMode: true,
|
||||
isInContinueWatching: widget.isInContinueWatching,
|
||||
mixedHubContext: isMixedHub,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: widget.inset
|
||||
? const EdgeInsets.symmetric(vertical: 8)
|
||||
: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
t.messages.noItemsAvailable,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: widget.inset
|
||||
? const EdgeInsets.symmetric(vertical: 8)
|
||||
: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Text(
|
||||
t.messages.noItemsAvailable,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onItemTapped(int index) {
|
||||
if (_totalItemCount == 0) return;
|
||||
final clamped = index.clamp(0, _totalItemCount - 1).toInt();
|
||||
setState(() {
|
||||
_focusedIndex = index;
|
||||
_focusedIndex = clamped;
|
||||
});
|
||||
HubFocusMemory.setForHub(widget.hub.id, index);
|
||||
HubFocusMemory.setForHub(widget.hub.id, clamped);
|
||||
_notifyFocusedItemChanged();
|
||||
_scrollToIndex(clamped);
|
||||
_hubFocusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,325 @@
|
||||
import 'package:cached_network_image_ce/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_item_types.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../services/image_cache_service.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../utils/formatters.dart';
|
||||
import '../utils/layout_constants.dart';
|
||||
import '../utils/media_image_helper.dart';
|
||||
import 'app_icon.dart';
|
||||
import 'optimized_media_image.dart' show blurArtwork;
|
||||
|
||||
class TvSpotlightBackground extends StatelessWidget {
|
||||
final MediaItem? item;
|
||||
final MediaServerClient? client;
|
||||
final bool hideSpoilers;
|
||||
final double contentBottom;
|
||||
final double? contentTop;
|
||||
final double? contentLeft;
|
||||
final VoidCallback? onPrimaryAction;
|
||||
final Widget? actions;
|
||||
final bool compact;
|
||||
final bool showPrimaryAction;
|
||||
final bool showInfo;
|
||||
|
||||
const TvSpotlightBackground({
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.client,
|
||||
this.hideSpoilers = false,
|
||||
this.contentBottom = 360,
|
||||
this.contentTop,
|
||||
this.contentLeft,
|
||||
this.onPrimaryAction,
|
||||
this.actions,
|
||||
this.compact = false,
|
||||
this.showPrimaryAction = true,
|
||||
this.showInfo = true,
|
||||
});
|
||||
|
||||
double _scale(BuildContext context) => TvLayoutConstants.scaleOf(context);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final media = item;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bgColor = Theme.of(context).scaffoldBackgroundColor;
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 280),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeOutCubic,
|
||||
child: SizedBox.expand(
|
||||
key: ValueKey(media?.globalKey ?? 'empty_spotlight'),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (media != null) _buildArtwork(context, media) else ColoredBox(color: bgColor),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [bgColor.withValues(alpha: 0.86), bgColor.withValues(alpha: 0.32), Colors.transparent],
|
||||
stops: const [0.0, 0.56, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.black.withValues(alpha: 0.45), Colors.transparent, bgColor.withValues(alpha: 0.96)],
|
||||
stops: const [0.0, 0.38, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (media != null && showInfo)
|
||||
Positioned(
|
||||
left: contentLeft ?? TvLayoutConstants.horizontalInset,
|
||||
right: MediaQuery.sizeOf(context).width * 0.43,
|
||||
top: contentTop,
|
||||
bottom: contentBottom,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
if (!constraints.hasBoundedHeight || constraints.maxHeight <= 0 || constraints.maxWidth <= 0) {
|
||||
return Align(alignment: Alignment.bottomLeft, child: _buildInfo(context, media, colorScheme));
|
||||
}
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: SizedBox(width: constraints.maxWidth, child: _buildInfo(context, media, colorScheme)),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildArtwork(BuildContext context, MediaItem media) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
|
||||
final containerAspect = size.width / size.height;
|
||||
final artPath =
|
||||
media.heroArt(containerAspectRatio: containerAspect) ??
|
||||
media.grandparentArtPath ??
|
||||
media.artPath ??
|
||||
media.backgroundSquarePath ??
|
||||
media.thumbPath;
|
||||
final imageUrl = MediaImageHelper.getOptimizedImageUrl(
|
||||
client: client,
|
||||
thumbPath: artPath,
|
||||
maxWidth: size.width,
|
||||
maxHeight: size.height,
|
||||
devicePixelRatio: dpr,
|
||||
imageType: ImageType.art,
|
||||
);
|
||||
|
||||
if (imageUrl.isEmpty) {
|
||||
return ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest);
|
||||
}
|
||||
|
||||
final (_, memHeight) = MediaImageHelper.getMemCacheDimensions(
|
||||
displayWidth: (size.width * dpr).round(),
|
||||
displayHeight: (size.height * dpr).round(),
|
||||
imageType: ImageType.art,
|
||||
);
|
||||
|
||||
return blurArtwork(
|
||||
CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
cacheManager: PlexImageCacheManager.instance,
|
||||
fit: BoxFit.cover,
|
||||
memCacheHeight: memHeight,
|
||||
placeholder: (context, url) => ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfo(BuildContext context, MediaItem media, ColorScheme colorScheme) {
|
||||
final scale = _scale(context);
|
||||
final shouldHideSpoiler = hideSpoilers && media.shouldHideSpoiler;
|
||||
final summary = shouldHideSpoiler ? null : media.summary;
|
||||
final title = media.grandparentTitle ?? media.displayTitle;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildLogoOrTitle(context, media, title),
|
||||
SizedBox(height: _sectionGap(scale)),
|
||||
_buildMetadataLine(context, media),
|
||||
if (summary != null && summary.isNotEmpty) ...[
|
||||
SizedBox(height: _sectionGap(scale)),
|
||||
Text(
|
||||
_summaryText(media, summary),
|
||||
maxLines: compact ? 2 : 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.78),
|
||||
fontSize: _summaryFontSize(scale),
|
||||
height: compact ? 1.34 : 1.45,
|
||||
),
|
||||
),
|
||||
] else if (shouldHideSpoiler && media.isEpisode) ...[
|
||||
SizedBox(height: _sectionGap(scale)),
|
||||
Text(
|
||||
_episodePrefix(media) ?? media.title ?? '',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.72),
|
||||
fontSize: _summaryFontSize(scale),
|
||||
height: compact ? 1.34 : 1.45,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (showPrimaryAction || actions != null) ...[
|
||||
SizedBox(height: (compact ? 18 : 26) * scale),
|
||||
actions ?? _buildPrimaryAction(context, colorScheme, media),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLogoOrTitle(BuildContext context, MediaItem media, String title) {
|
||||
final scale = _scale(context);
|
||||
final logoPath = media.clearLogoPath;
|
||||
if (logoPath == null || logoPath.isEmpty) return _buildTitle(context, title);
|
||||
|
||||
final dpr = MediaImageHelper.effectiveDevicePixelRatio(context);
|
||||
final logoWidth = _logoWidth(scale);
|
||||
final logoHeight = _logoHeight(scale);
|
||||
final imageUrl = MediaImageHelper.getOptimizedImageUrl(
|
||||
client: client,
|
||||
thumbPath: logoPath,
|
||||
maxWidth: logoWidth,
|
||||
maxHeight: logoHeight,
|
||||
devicePixelRatio: dpr,
|
||||
imageType: ImageType.logo,
|
||||
);
|
||||
if (imageUrl.isEmpty) return _buildTitle(context, title);
|
||||
|
||||
return SizedBox(
|
||||
width: logoWidth,
|
||||
height: logoHeight,
|
||||
child: blurArtwork(
|
||||
CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
cacheManager: PlexImageCacheManager.instance,
|
||||
fit: BoxFit.contain,
|
||||
alignment: Alignment.centerLeft,
|
||||
memCacheWidth: (logoWidth * dpr).clamp(200, 1000).round(),
|
||||
placeholder: (context, url) => const SizedBox.shrink(),
|
||||
errorBuilder: (context, error, stackTrace) => _buildTitle(context, title),
|
||||
),
|
||||
sigma: 10,
|
||||
clip: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitle(BuildContext context, String title) {
|
||||
final scale = _scale(context);
|
||||
return Text(
|
||||
title,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
color: Colors.white,
|
||||
fontSize: _titleFontSize(scale),
|
||||
fontWeight: FontWeight.w800,
|
||||
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 12)],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetadataLine(BuildContext context, MediaItem media) {
|
||||
final scale = _scale(context);
|
||||
final parts = [
|
||||
if (media.isMovie) t.discover.movie else if (media.isShow) t.discover.tvShow,
|
||||
if (media.rating != null) '★ ${formatRating(media.rating!)}',
|
||||
if (media.contentRating != null) formatContentRating(media.contentRating!),
|
||||
if (media.durationMs != null) formatDurationTextual(media.durationMs!),
|
||||
if (media.year != null) media.year.toString(),
|
||||
];
|
||||
return Text(
|
||||
parts.join(' • '),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: _metadataFontSize(scale),
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
double _sectionGap(double scale) => (compact ? 10 : 16) * scale;
|
||||
|
||||
double _logoWidth(double scale) =>
|
||||
(compact ? TvLayoutConstants.compactHeroLogoWidth : TvLayoutConstants.heroLogoWidth) * scale;
|
||||
|
||||
double _logoHeight(double scale) =>
|
||||
(compact ? TvLayoutConstants.compactHeroLogoHeight : TvLayoutConstants.heroLogoHeight) * scale;
|
||||
|
||||
double _titleFontSize(double scale) => (compact ? 44 : 54) * scale;
|
||||
|
||||
double _metadataFontSize(double scale) => (compact ? 16 : 18) * scale;
|
||||
|
||||
double _summaryFontSize(double scale) => (compact ? 18 : 20) * scale;
|
||||
|
||||
Widget _buildPrimaryAction(BuildContext context, ColorScheme colorScheme, MediaItem media) {
|
||||
final scale = _scale(context);
|
||||
final hasProgress = media.hasActiveProgress;
|
||||
final minutesLeft = hasProgress && media.durationMs != null && media.viewOffsetMs != null
|
||||
? ((media.durationMs! - media.viewOffsetMs!) / 60000).round()
|
||||
: 0;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onPrimaryAction,
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: (compact ? 24 : 30) * scale, vertical: (compact ? 12 : 15) * scale),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(32 * scale)),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppIcon(Symbols.play_arrow_rounded, fill: 1, size: (compact ? 24 : 28) * scale, color: Colors.black),
|
||||
SizedBox(width: (compact ? 10 : 12) * scale),
|
||||
Text(
|
||||
hasProgress ? t.discover.minutesLeft(minutes: minutesLeft) : t.common.play,
|
||||
style: TextStyle(color: Colors.black, fontSize: (compact ? 16 : 18) * scale, fontWeight: FontWeight.w800),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _summaryText(MediaItem media, String summary) {
|
||||
final prefix = _episodePrefix(media);
|
||||
if (prefix == null) return summary;
|
||||
return '$prefix: $summary';
|
||||
}
|
||||
|
||||
String? _episodePrefix(MediaItem media) {
|
||||
if (!media.isEpisode || media.parentIndex == null || media.index == null) return null;
|
||||
return 'S${media.parentIndex}, E${media.index}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:plezy/media/media_backend.dart';
|
||||
import 'package:plezy/media/media_hub.dart';
|
||||
import 'package:plezy/media/media_item.dart';
|
||||
import 'package:plezy/media/media_kind.dart';
|
||||
import 'package:plezy/providers/multi_server_provider.dart';
|
||||
import 'package:plezy/services/data_aggregation_service.dart';
|
||||
import 'package:plezy/services/multi_server_manager.dart';
|
||||
import 'package:plezy/services/settings_service.dart';
|
||||
import 'package:plezy/theme/mono_theme.dart';
|
||||
import 'package:plezy/widgets/tv_browse_rail.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../test_helpers/prefs.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() async {
|
||||
resetSharedPreferencesForTest();
|
||||
SettingsService.resetForTesting();
|
||||
await SettingsService.getInstance();
|
||||
});
|
||||
|
||||
testWidgets('selects preferred hub when hubs are inserted asynchronously', (tester) async {
|
||||
final activeHubIds = <String>[];
|
||||
|
||||
Widget buildRail(List<MediaHub> hubs, {String? initialHubId, String? initialItemId, bool autofocus = false}) {
|
||||
final serverManager = MultiServerManager();
|
||||
return ChangeNotifierProvider<MultiServerProvider>(
|
||||
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 1280,
|
||||
height: 720,
|
||||
child: TvBrowseRail(
|
||||
key: const ValueKey('rail'),
|
||||
hubs: hubs,
|
||||
initialHubId: initialHubId,
|
||||
initialItemId: initialItemId,
|
||||
autofocus: autofocus,
|
||||
iconForHub: (_, _) => Icons.tv_rounded,
|
||||
onActiveHubChanged: (hub, _) => activeHubIds.add(hub.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const castHub = MediaHub(id: 'detail_actors', title: 'Cast', type: 'person', items: <MediaItem>[]);
|
||||
const preferredSeason = MediaHub(id: 'detail_season_1', title: 'Season 2', type: 'episode', items: <MediaItem>[]);
|
||||
|
||||
await tester.pumpWidget(buildRail(const [castHub]));
|
||||
await tester.pump();
|
||||
|
||||
await tester.pumpWidget(buildRail(const [preferredSeason, castHub], initialHubId: preferredSeason.id));
|
||||
await tester.pump();
|
||||
|
||||
expect(activeHubIds, containsAllInOrder(['detail_actors', 'detail_season_1']));
|
||||
expect(activeHubIds.last, 'detail_season_1');
|
||||
});
|
||||
|
||||
testWidgets('selects preferred item when active hub items are populated asynchronously', (tester) async {
|
||||
final focusedItemIds = <String>[];
|
||||
|
||||
Widget buildRail(List<MediaHub> hubs, {String? initialItemId}) {
|
||||
final serverManager = MultiServerManager();
|
||||
return ChangeNotifierProvider<MultiServerProvider>(
|
||||
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 1280,
|
||||
height: 720,
|
||||
child: TvBrowseRail(
|
||||
key: const ValueKey('rail'),
|
||||
hubs: hubs,
|
||||
initialItemId: initialItemId,
|
||||
iconForHub: (_, _) => Icons.tv_rounded,
|
||||
onFocusedItemChanged: (item) => focusedItemIds.add(item.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final episode1 = MediaItem(
|
||||
id: 'episode_1',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.episode,
|
||||
title: 'Episode 1',
|
||||
);
|
||||
final episode2 = MediaItem(
|
||||
id: 'episode_2',
|
||||
backend: MediaBackend.plex,
|
||||
kind: MediaKind.episode,
|
||||
title: 'Episode 2',
|
||||
);
|
||||
const emptySeason = MediaHub(id: 'detail_season_0', title: 'Season 1', type: 'episode', items: <MediaItem>[]);
|
||||
final loadedSeason = MediaHub(
|
||||
id: emptySeason.id,
|
||||
title: emptySeason.title,
|
||||
type: emptySeason.type,
|
||||
items: [episode1, episode2],
|
||||
size: 2,
|
||||
);
|
||||
|
||||
await tester.pumpWidget(buildRail(const [emptySeason], initialItemId: episode2.id));
|
||||
await tester.pump();
|
||||
|
||||
await tester.pumpWidget(buildRail([loadedSeason], initialItemId: episode2.id));
|
||||
await tester.pump();
|
||||
|
||||
expect(focusedItemIds.last, episode2.id);
|
||||
});
|
||||
|
||||
testWidgets('does not autofocus unless requested', (tester) async {
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
|
||||
Widget buildRail({required bool autofocus}) {
|
||||
final serverManager = MultiServerManager();
|
||||
final item = MediaItem(id: 'item_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
|
||||
final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1);
|
||||
return ChangeNotifierProvider<MultiServerProvider>(
|
||||
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
|
||||
child: MaterialApp(
|
||||
theme: monoTheme(dark: true),
|
||||
home: Scaffold(
|
||||
body: SizedBox(
|
||||
width: 1280,
|
||||
height: 720,
|
||||
child: TvBrowseRail(hubs: [hub], autofocus: autofocus, iconForHub: (_, _) => Icons.tv_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await tester.pumpWidget(buildRail(autofocus: false));
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, isNot('tv_browse_rail'));
|
||||
|
||||
await tester.pumpWidget(buildRail(autofocus: true));
|
||||
await tester.pump();
|
||||
expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user