feat(tv): add spotlight browse layouts

This commit is contained in:
edde746
2026-05-16 11:56:12 +02:00
parent 91298294b0
commit 7690a16efe
11 changed files with 3220 additions and 617 deletions
+475 -221
View File
@@ -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,
),
),
],
),
),
);
},
);
}
}
+95 -39
View File
@@ -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
+52 -32
View File
@@ -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
+17
View File
@@ -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));
}
+8 -2
View File
@@ -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
View File
@@ -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
+325
View File
@@ -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}';
}
}
+153
View File
@@ -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');
});
}