refactor: hero section design improvements
|
Before Width: | Height: | Size: 2.9 MiB After Width: | Height: | Size: 3.8 MiB |
|
Before Width: | Height: | Size: 4.2 MiB After Width: | Height: | Size: 4.4 MiB |
|
Before Width: | Height: | Size: 3.4 MiB After Width: | Height: | Size: 3.4 MiB |
|
Before Width: | Height: | Size: 3.2 MiB After Width: | Height: | Size: 3.0 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.2 MiB |
@@ -774,272 +774,302 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
Widget _buildOverlaidAppBar() {
|
||||||
Widget build(BuildContext context) {
|
final statusBarHeight = MediaQuery.of(context).padding.top;
|
||||||
return Scaffold(
|
return Container(
|
||||||
body: SafeArea(
|
decoration: BoxDecoration(
|
||||||
child: CustomScrollView(
|
gradient: LinearGradient(
|
||||||
controller: _scrollController,
|
begin: Alignment.topCenter,
|
||||||
slivers: [
|
end: Alignment.bottomCenter,
|
||||||
DesktopSliverAppBar(
|
colors: [
|
||||||
title: Text(t.discover.title),
|
Colors.black.withValues(alpha: 0.7),
|
||||||
floating: true,
|
Colors.black.withValues(alpha: 0.5),
|
||||||
pinned: true,
|
Colors.black.withValues(alpha: 0.3),
|
||||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
Colors.transparent,
|
||||||
surfaceTintColor: Colors.transparent,
|
],
|
||||||
shadowColor: Colors.transparent,
|
stops: const [0.0, 0.3, 0.6, 1.0],
|
||||||
scrolledUnderElevation: 0,
|
),
|
||||||
actions: [
|
),
|
||||||
Focus(
|
child: Padding(
|
||||||
focusNode: _refreshButtonFocusNode,
|
padding: EdgeInsets.only(top: statusBarHeight, left: 16, right: 16, bottom: 8),
|
||||||
onKeyEvent: _handleRefreshKeyEvent,
|
child: Padding(
|
||||||
child: Container(
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
decoration: BoxDecoration(
|
child: Row(
|
||||||
color: _isRefreshFocused
|
children: [
|
||||||
? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08)
|
Text(
|
||||||
: Colors.transparent,
|
t.discover.title,
|
||||||
borderRadius: BorderRadius.circular(20),
|
style: Theme.of(
|
||||||
),
|
context,
|
||||||
child: IconButton(icon: const AppIcon(Symbols.refresh_rounded, fill: 1), onPressed: _loadContent),
|
).textTheme.titleLarge?.copyWith(color: Colors.white, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Focus(
|
||||||
|
focusNode: _refreshButtonFocusNode,
|
||||||
|
onKeyEvent: _handleRefreshKeyEvent,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
icon: const AppIcon(Symbols.refresh_rounded, fill: 1, color: Colors.white),
|
||||||
|
onPressed: _loadContent,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Watch Together button
|
),
|
||||||
Consumer<WatchTogetherProvider>(
|
// Watch Together button
|
||||||
builder: (context, watchTogether, child) {
|
Consumer<WatchTogetherProvider>(
|
||||||
return Focus(
|
builder: (context, watchTogether, child) {
|
||||||
focusNode: _watchTogetherButtonFocusNode,
|
return Focus(
|
||||||
onKeyEvent: _handleWatchTogetherKeyEvent,
|
focusNode: _watchTogetherButtonFocusNode,
|
||||||
child: Container(
|
onKeyEvent: _handleWatchTogetherKeyEvent,
|
||||||
decoration: BoxDecoration(
|
child: Container(
|
||||||
color: _isWatchTogetherFocused
|
decoration: BoxDecoration(
|
||||||
? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08)
|
color: _isWatchTogetherFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
|
||||||
: Colors.transparent,
|
borderRadius: BorderRadius.circular(20),
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: AppIcon(
|
|
||||||
Symbols.group_rounded,
|
|
||||||
fill: watchTogether.isInSession ? 1 : 0,
|
|
||||||
color: watchTogether.isInSession ? Theme.of(context).colorScheme.primary : null,
|
|
||||||
),
|
|
||||||
onPressed: () => Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (_) => const WatchTogetherScreen()),
|
|
||||||
),
|
|
||||||
tooltip: 'Watch Together',
|
|
||||||
),
|
|
||||||
// Badge showing participant count when in session
|
|
||||||
if (watchTogether.isInSession && watchTogether.participantCount > 1)
|
|
||||||
Positioned(
|
|
||||||
top: 6,
|
|
||||||
right: 6,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'${watchTogether.participantCount}',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context).colorScheme.onPrimary,
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
child: Stack(
|
||||||
},
|
children: [
|
||||||
),
|
IconButton(
|
||||||
Consumer<UserProfileProvider>(
|
icon: AppIcon(
|
||||||
builder: (context, userProvider, child) {
|
Symbols.group_rounded,
|
||||||
return Focus(
|
fill: watchTogether.isInSession ? 1 : 0,
|
||||||
focusNode: _userButtonFocusNode,
|
color: watchTogether.isInSession ? Theme.of(context).colorScheme.primary : Colors.white,
|
||||||
onKeyEvent: _handleUserKeyEvent,
|
),
|
||||||
child: DecoratedBox(
|
onPressed: () =>
|
||||||
decoration: BoxDecoration(
|
Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())),
|
||||||
color: _isUserFocused
|
tooltip: 'Watch Together',
|
||||||
? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08)
|
),
|
||||||
: Colors.transparent,
|
// Badge showing participant count when in session
|
||||||
borderRadius: BorderRadius.circular(20),
|
if (watchTogether.isInSession && watchTogether.participantCount > 1)
|
||||||
),
|
Positioned(
|
||||||
child: PopupMenuButton<String>(
|
top: 6,
|
||||||
icon: userProvider.currentUser?.thumb != null
|
right: 6,
|
||||||
? UserAvatarWidget(user: userProvider.currentUser!, size: 32, showIndicators: false)
|
child: Container(
|
||||||
: const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32),
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||||
onSelected: (value) {
|
decoration: BoxDecoration(
|
||||||
if (value == 'switch_profile') {
|
color: Theme.of(context).colorScheme.primary,
|
||||||
_handleSwitchProfile(context);
|
borderRadius: BorderRadius.circular(8),
|
||||||
} else if (value == 'logout') {
|
),
|
||||||
_handleLogout();
|
child: Text(
|
||||||
}
|
'${watchTogether.participantCount}',
|
||||||
},
|
style: TextStyle(
|
||||||
itemBuilder: (context) => [
|
color: Theme.of(context).colorScheme.onPrimary,
|
||||||
// Only show Switch Profile if multiple users available
|
fontSize: 10,
|
||||||
if (userProvider.hasMultipleUsers)
|
fontWeight: FontWeight.bold,
|
||||||
PopupMenuItem(
|
),
|
||||||
value: 'switch_profile',
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
AppIcon(Symbols.people_rounded, fill: 1),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text(t.discover.switchProfile),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Consumer<UserProfileProvider>(
|
||||||
|
builder: (context, userProvider, child) {
|
||||||
|
return Focus(
|
||||||
|
focusNode: _userButtonFocusNode,
|
||||||
|
onKeyEvent: _handleUserKeyEvent,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _isUserFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: PopupMenuButton<String>(
|
||||||
|
icon: userProvider.currentUser?.thumb != null
|
||||||
|
? UserAvatarWidget(user: userProvider.currentUser!, size: 32, showIndicators: false)
|
||||||
|
: const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32, color: Colors.white),
|
||||||
|
onSelected: (value) {
|
||||||
|
if (value == 'switch_profile') {
|
||||||
|
_handleSwitchProfile(context);
|
||||||
|
} else if (value == 'logout') {
|
||||||
|
_handleLogout();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
// Only show Switch Profile if multiple users available
|
||||||
|
if (userProvider.hasMultipleUsers)
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: 'logout',
|
value: 'switch_profile',
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
AppIcon(Symbols.logout_rounded, fill: 1),
|
AppIcon(Symbols.people_rounded, fill: 1),
|
||||||
SizedBox(width: 8),
|
SizedBox(width: 8),
|
||||||
Text(t.discover.logout),
|
Text(t.discover.switchProfile),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
PopupMenuItem(
|
||||||
),
|
value: 'logout',
|
||||||
),
|
child: Row(
|
||||||
);
|
children: [
|
||||||
},
|
AppIcon(Symbols.logout_rounded, fill: 1),
|
||||||
),
|
SizedBox(width: 8),
|
||||||
],
|
Text(t.discover.logout),
|
||||||
),
|
],
|
||||||
if (_isLoading) const SliverFillRemaining(child: Center(child: CircularProgressIndicator())),
|
|
||||||
if (_errorMessage != null)
|
|
||||||
SliverFillRemaining(
|
|
||||||
child: ErrorStateWidget(
|
|
||||||
message: _errorMessage!,
|
|
||||||
icon: Symbols.error_outline_rounded,
|
|
||||||
onRetry: _loadContent,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!_isLoading && _errorMessage == null) ...[
|
|
||||||
// Hero Section (Continue Watching)
|
|
||||||
Consumer<SettingsProvider>(
|
|
||||||
builder: (context, settingsProvider, child) {
|
|
||||||
if (_onDeck.isNotEmpty && settingsProvider.showHeroSection) {
|
|
||||||
return _buildHeroSection();
|
|
||||||
}
|
|
||||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
// On Deck / Continue Watching
|
|
||||||
if (_onDeck.isNotEmpty)
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: HubSection(
|
|
||||||
key: _continueWatchingHubKey,
|
|
||||||
hub: PlexHub(
|
|
||||||
hubKey: 'continue_watching',
|
|
||||||
title: t.discover.continueWatching,
|
|
||||||
type: 'mixed',
|
|
||||||
hubIdentifier: '_continue_watching_',
|
|
||||||
size: _onDeck.length,
|
|
||||||
more: false,
|
|
||||||
items: _onDeck,
|
|
||||||
),
|
|
||||||
icon: Symbols.play_circle_rounded,
|
|
||||||
onRefresh: updateItem,
|
|
||||||
onRemoveFromContinueWatching: _refreshContinueWatching,
|
|
||||||
isInContinueWatching: true,
|
|
||||||
onVerticalNavigation: (isUp) => _handleVerticalNavigation(0, isUp),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Recommendation Hubs (Trending, Top in Genre, etc.)
|
|
||||||
for (int i = 0; i < _hubs.length; i++)
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: HubSection(
|
|
||||||
key: i < _hubKeys.length ? _hubKeys[i] : null,
|
|
||||||
hub: _hubs[i],
|
|
||||||
icon: _getHubIcon(_hubs[i].title),
|
|
||||||
onRefresh: updateItem,
|
|
||||||
// Hub index is i + 1 if continue watching exists, otherwise i
|
|
||||||
onVerticalNavigation: (isUp) => _handleVerticalNavigation(_onDeck.isNotEmpty ? i + 1 : i, isUp),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// Show loading skeleton for hubs while they're loading
|
|
||||||
if (_areHubsLoading && _hubs.isEmpty)
|
|
||||||
for (int i = 0; i < 3; i++)
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// Hub title skeleton
|
|
||||||
Container(
|
|
||||||
width: 200,
|
|
||||||
height: 24,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
// Hub items skeleton
|
|
||||||
SizedBox(
|
|
||||||
height: 200,
|
|
||||||
child: ListView.builder(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
itemCount: 5,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(right: 12),
|
|
||||||
width: 140,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
if (_onDeck.isEmpty && _hubs.isEmpty && !_areHubsLoading)
|
),
|
||||||
SliverFillRemaining(
|
|
||||||
child: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: Colors.grey),
|
|
||||||
SizedBox(height: 16),
|
|
||||||
Text(t.discover.noContentAvailable),
|
|
||||||
SizedBox(height: 8),
|
|
||||||
Text(t.discover.addMediaToLibraries, style: TextStyle(color: Colors.grey)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
body: Stack(
|
||||||
|
children: [
|
||||||
|
SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
|
slivers: [
|
||||||
|
// Hero Section (Continue Watching) - at top of screen
|
||||||
|
Consumer<SettingsProvider>(
|
||||||
|
builder: (context, settingsProvider, child) {
|
||||||
|
if (_onDeck.isNotEmpty && settingsProvider.showHeroSection) {
|
||||||
|
return _buildHeroSection();
|
||||||
|
}
|
||||||
|
// Add top padding when hero is not shown
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: SizedBox(height: kToolbarHeight + MediaQuery.of(context).padding.top + 16),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_isLoading) const SliverFillRemaining(child: Center(child: CircularProgressIndicator())),
|
||||||
|
if (_errorMessage != null)
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: ErrorStateWidget(
|
||||||
|
message: _errorMessage!,
|
||||||
|
icon: Symbols.error_outline_rounded,
|
||||||
|
onRetry: _loadContent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!_isLoading && _errorMessage == null) ...[
|
||||||
|
// On Deck / Continue Watching
|
||||||
|
if (_onDeck.isNotEmpty)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: HubSection(
|
||||||
|
key: _continueWatchingHubKey,
|
||||||
|
hub: PlexHub(
|
||||||
|
hubKey: 'continue_watching',
|
||||||
|
title: t.discover.continueWatching,
|
||||||
|
type: 'mixed',
|
||||||
|
hubIdentifier: '_continue_watching_',
|
||||||
|
size: _onDeck.length,
|
||||||
|
more: false,
|
||||||
|
items: _onDeck,
|
||||||
|
),
|
||||||
|
icon: Symbols.play_circle_rounded,
|
||||||
|
onRefresh: updateItem,
|
||||||
|
onRemoveFromContinueWatching: _refreshContinueWatching,
|
||||||
|
isInContinueWatching: true,
|
||||||
|
onVerticalNavigation: (isUp) => _handleVerticalNavigation(0, isUp),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Recommendation Hubs (Trending, Top in Genre, etc.)
|
||||||
|
for (int i = 0; i < _hubs.length; i++)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: HubSection(
|
||||||
|
key: i < _hubKeys.length ? _hubKeys[i] : null,
|
||||||
|
hub: _hubs[i],
|
||||||
|
icon: _getHubIcon(_hubs[i].title),
|
||||||
|
onRefresh: updateItem,
|
||||||
|
// Hub index is i + 1 if continue watching exists, otherwise i
|
||||||
|
onVerticalNavigation: (isUp) => _handleVerticalNavigation(_onDeck.isNotEmpty ? i + 1 : i, isUp),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Show loading skeleton for hubs while they're loading
|
||||||
|
if (_areHubsLoading && _hubs.isEmpty)
|
||||||
|
for (int i = 0; i < 3; i++)
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Hub title skeleton
|
||||||
|
Container(
|
||||||
|
width: 200,
|
||||||
|
height: 24,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Hub items skeleton
|
||||||
|
SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: ListView.builder(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: 5,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(right: 12),
|
||||||
|
width: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (_onDeck.isEmpty && _hubs.isEmpty && !_areHubsLoading)
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: Colors.grey),
|
||||||
|
SizedBox(height: 16),
|
||||||
|
Text(t.discover.noContentAvailable),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text(t.discover.addMediaToLibraries, style: TextStyle(color: Colors.grey)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Overlaid app bar
|
||||||
|
Positioned(top: 0, left: 0, right: 0, child: _buildOverlaidAppBar()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildHeroSection() {
|
Widget _buildHeroSection() {
|
||||||
|
final statusBarHeight = MediaQuery.of(context).padding.top;
|
||||||
return SliverToBoxAdapter(
|
return SliverToBoxAdapter(
|
||||||
child: Focus(
|
child: Focus(
|
||||||
focusNode: _heroFocusNode,
|
focusNode: _heroFocusNode,
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
onKeyEvent: _handleHeroKeyEvent,
|
onKeyEvent: _handleHeroKeyEvent,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 500,
|
height: 500 + statusBarHeight,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
PageView.builder(
|
PageView.builder(
|
||||||
@@ -1078,7 +1108,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
child: AppIcon(
|
child: AppIcon(
|
||||||
_isAutoScrollPaused ? Symbols.play_arrow_rounded : Symbols.pause_rounded,
|
_isAutoScrollPaused ? Symbols.play_arrow_rounded : Symbols.pause_rounded,
|
||||||
fill: 1,
|
fill: 1,
|
||||||
color: Colors.white,
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
size: 18,
|
size: 18,
|
||||||
semanticLabel: '${_isAutoScrollPaused ? t.discover.play : t.discover.pause} auto-scroll',
|
semanticLabel: '${_isAutoScrollPaused ? t.discover.play : t.discover.pause} auto-scroll',
|
||||||
),
|
),
|
||||||
@@ -1101,6 +1131,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
// Fill width animates based on dot size
|
// Fill width animates based on dot size
|
||||||
final maxWidth = dotSize * 3; // 24px for normal, 15px for small
|
final maxWidth = dotSize * 3; // 24px for normal, 15px for small
|
||||||
final fillWidth = dotSize + ((maxWidth - dotSize) * _indicatorAnimationController.value);
|
final fillWidth = dotSize + ((maxWidth - dotSize) * _indicatorAnimationController.value);
|
||||||
|
final onSurface = Theme.of(context).colorScheme.onSurface;
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: tokens(context).slow,
|
duration: tokens(context).slow,
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
@@ -1108,7 +1139,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
width: maxWidth,
|
width: maxWidth,
|
||||||
height: dotSize,
|
height: dotSize,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha: 0.4),
|
color: onSurface.withValues(alpha: 0.4),
|
||||||
borderRadius: BorderRadius.circular(dotSize / 2),
|
borderRadius: BorderRadius.circular(dotSize / 2),
|
||||||
),
|
),
|
||||||
child: Align(
|
child: Align(
|
||||||
@@ -1117,7 +1148,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
width: fillWidth,
|
width: fillWidth,
|
||||||
height: dotSize,
|
height: dotSize,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: onSurface,
|
||||||
borderRadius: BorderRadius.circular(dotSize / 2),
|
borderRadius: BorderRadius.circular(dotSize / 2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1134,7 +1165,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
width: dotSize,
|
width: dotSize,
|
||||||
height: dotSize,
|
height: dotSize,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha: 0.4),
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4),
|
||||||
borderRadius: BorderRadius.circular(dotSize / 2),
|
borderRadius: BorderRadius.circular(dotSize / 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1172,216 +1203,226 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}');
|
appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}');
|
||||||
navigateToVideoPlayer(context, metadata: heroItem);
|
navigateToVideoPlayer(context, metadata: heroItem);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Stack(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
fit: StackFit.expand,
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
borderRadius: BorderRadius.circular(16),
|
// Background Image with fade/zoom animation and parallax
|
||||||
boxShadow: [
|
if (heroItem.art != null || heroItem.grandparentArt != null)
|
||||||
BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10)),
|
AnimatedBuilder(
|
||||||
],
|
animation: _scrollController,
|
||||||
),
|
builder: (context, child) {
|
||||||
child: ClipRRect(
|
final scrollOffset = _scrollController.hasClients ? _scrollController.offset : 0.0;
|
||||||
borderRadius: BorderRadius.circular(16),
|
return Transform.translate(offset: Offset(0, scrollOffset * 0.3), child: child);
|
||||||
child: Stack(
|
},
|
||||||
fit: StackFit.expand,
|
child: TweenAnimationBuilder<double>(
|
||||||
children: [
|
tween: Tween(begin: 0.0, end: 1.0),
|
||||||
// Background Image with fade/zoom animation and parallax
|
duration: const Duration(milliseconds: 800),
|
||||||
if (heroItem.art != null || heroItem.grandparentArt != null)
|
curve: Curves.easeOut,
|
||||||
AnimatedBuilder(
|
builder: (context, value, child) {
|
||||||
animation: _scrollController,
|
return Transform.scale(
|
||||||
builder: (context, child) {
|
scale: 1.0 + (0.1 * (1 - value)),
|
||||||
final scrollOffset = _scrollController.hasClients ? _scrollController.offset : 0.0;
|
child: Opacity(opacity: value, child: child),
|
||||||
return Transform.translate(offset: Offset(0, scrollOffset * 0.3), child: child);
|
);
|
||||||
|
},
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final client = _getClientForItem(heroItem);
|
||||||
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
final imageUrl = PlexImageHelper.getOptimizedImageUrl(
|
||||||
|
client: client,
|
||||||
|
thumbPath: heroItem.art ?? heroItem.grandparentArt,
|
||||||
|
maxWidth: mediaQuery.size.width,
|
||||||
|
maxHeight: mediaQuery.size.height * 0.7,
|
||||||
|
devicePixelRatio: mediaQuery.devicePixelRatio,
|
||||||
|
imageType: ImageType.art,
|
||||||
|
);
|
||||||
|
|
||||||
|
return CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (context, url) =>
|
||||||
|
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
||||||
|
errorWidget: (context, url, error) =>
|
||||||
|
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
child: TweenAnimationBuilder<double>(
|
),
|
||||||
tween: Tween(begin: 0.0, end: 1.0),
|
),
|
||||||
duration: const Duration(milliseconds: 800),
|
)
|
||||||
curve: Curves.easeOut,
|
else
|
||||||
builder: (context, value, child) {
|
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
||||||
return Transform.scale(
|
|
||||||
scale: 1.0 + (0.1 * (1 - value)),
|
|
||||||
child: Opacity(opacity: value, child: child),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Builder(
|
|
||||||
builder: (context) {
|
|
||||||
final client = _getClientForItem(heroItem);
|
|
||||||
final mediaQuery = MediaQuery.of(context);
|
|
||||||
final imageUrl = PlexImageHelper.getOptimizedImageUrl(
|
|
||||||
client: client,
|
|
||||||
thumbPath: heroItem.art ?? heroItem.grandparentArt,
|
|
||||||
maxWidth: mediaQuery.size.width,
|
|
||||||
maxHeight: mediaQuery.size.height * 0.7,
|
|
||||||
devicePixelRatio: mediaQuery.devicePixelRatio,
|
|
||||||
imageType: ImageType.art,
|
|
||||||
);
|
|
||||||
|
|
||||||
return CachedNetworkImage(
|
// Gradient Overlay - blends into scaffold background
|
||||||
imageUrl: imageUrl,
|
Builder(
|
||||||
fit: BoxFit.cover,
|
builder: (context) {
|
||||||
placeholder: (context, url) =>
|
final bgColor = Theme.of(context).scaffoldBackgroundColor;
|
||||||
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
return Container(
|
||||||
errorWidget: (context, url, error) =>
|
|
||||||
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Container(color: Theme.of(context).colorScheme.surfaceContainerHighest),
|
|
||||||
|
|
||||||
// Gradient Overlay
|
|
||||||
Container(
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor],
|
||||||
Colors.transparent,
|
stops: const [0.5, 0.85, 1.0],
|
||||||
Colors.black.withValues(alpha: 0.7),
|
|
||||||
Colors.black.withValues(alpha: 0.9),
|
|
||||||
],
|
|
||||||
stops: const [0.0, 0.5, 1.0],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
// Content with responsive alignment
|
// Content with responsive alignment
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: isLargeScreen ? 80 : 50,
|
bottom: isLargeScreen ? 80 : 50,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: isLargeScreen ? 200 : 0,
|
right: isLargeScreen ? 200 : 0,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: isLargeScreen ? 40 : 16),
|
padding: EdgeInsets.symmetric(horizontal: isLargeScreen ? 40 : 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: isLargeScreen ? CrossAxisAlignment.start : CrossAxisAlignment.center,
|
crossAxisAlignment: isLargeScreen ? CrossAxisAlignment.start : CrossAxisAlignment.center,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Show logo or name/title
|
// Show logo or name/title
|
||||||
if (heroItem.clearLogo != null)
|
if (heroItem.clearLogo != null)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 120,
|
height: 120,
|
||||||
width: 400,
|
width: 400,
|
||||||
child: Builder(
|
child: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final client = _getClientForItem(heroItem);
|
final client = _getClientForItem(heroItem);
|
||||||
final dpr = MediaQuery.of(context).devicePixelRatio;
|
final dpr = MediaQuery.of(context).devicePixelRatio;
|
||||||
final logoUrl = PlexImageHelper.getOptimizedImageUrl(
|
final logoUrl = PlexImageHelper.getOptimizedImageUrl(
|
||||||
client: client,
|
client: client,
|
||||||
thumbPath: heroItem.clearLogo,
|
thumbPath: heroItem.clearLogo,
|
||||||
maxWidth: 400,
|
maxWidth: 400,
|
||||||
maxHeight: 120,
|
maxHeight: 120,
|
||||||
devicePixelRatio: dpr,
|
devicePixelRatio: dpr,
|
||||||
imageType: ImageType.logo,
|
imageType: ImageType.logo,
|
||||||
);
|
);
|
||||||
|
|
||||||
return CachedNetworkImage(
|
return CachedNetworkImage(
|
||||||
imageUrl: logoUrl,
|
imageUrl: logoUrl,
|
||||||
filterQuality: FilterQuality.medium,
|
filterQuality: FilterQuality.medium,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
memCacheWidth: (400 * dpr).clamp(200, 800).round(),
|
memCacheWidth: (400 * dpr).clamp(200, 800).round(),
|
||||||
alignment: isLargeScreen ? Alignment.bottomLeft : Alignment.bottomCenter,
|
alignment: isLargeScreen ? Alignment.bottomLeft : Alignment.bottomCenter,
|
||||||
placeholder: (context, url) => Align(
|
placeholder: (context, url) => Align(
|
||||||
alignment: isLargeScreen ? Alignment.centerLeft : Alignment.center,
|
alignment: isLargeScreen ? Alignment.centerLeft : Alignment.center,
|
||||||
child: Text(
|
child: Text(
|
||||||
showName,
|
showName,
|
||||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||||
color: Colors.white.withValues(alpha: 0.3),
|
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8)],
|
shadows: [
|
||||||
|
Shadow(
|
||||||
|
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.8),
|
||||||
|
blurRadius: 8,
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
],
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
errorWidget: (context, url, error) {
|
maxLines: 2,
|
||||||
// Fallback to text if logo fails to load
|
overflow: TextOverflow.ellipsis,
|
||||||
return Align(
|
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
||||||
alignment: isLargeScreen ? Alignment.centerLeft : Alignment.center,
|
),
|
||||||
child: Text(
|
),
|
||||||
showName,
|
errorWidget: (context, url, error) {
|
||||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
// Fallback to text if logo fails to load
|
||||||
color: Colors.white,
|
return Align(
|
||||||
fontWeight: FontWeight.bold,
|
alignment: isLargeScreen ? Alignment.centerLeft : Alignment.center,
|
||||||
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8)],
|
child: Text(
|
||||||
|
showName,
|
||||||
|
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
shadows: [
|
||||||
|
Shadow(
|
||||||
|
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.8),
|
||||||
|
blurRadius: 8,
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
],
|
||||||
overflow: TextOverflow.ellipsis,
|
),
|
||||||
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
maxLines: 2,
|
||||||
),
|
overflow: TextOverflow.ellipsis,
|
||||||
);
|
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
)
|
},
|
||||||
else
|
),
|
||||||
Text(
|
)
|
||||||
showName,
|
else
|
||||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
Text(
|
||||||
color: Colors.white,
|
showName,
|
||||||
fontWeight: FontWeight.bold,
|
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||||
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8)],
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
maxLines: 2,
|
shadows: [
|
||||||
overflow: TextOverflow.ellipsis,
|
Shadow(color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8),
|
||||||
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
],
|
||||||
|
),
|
||||||
|
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) '★ ${heroItem.rating!.toStringAsFixed(1)}',
|
||||||
|
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) ...[
|
||||||
|
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)
|
||||||
|
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||||
|
fontSize: 14,
|
||||||
|
height: 1.4,
|
||||||
),
|
),
|
||||||
|
children: [
|
||||||
// Metadata as dot-separated text with content type
|
if (isEpisode && heroItem.parentIndex != null && heroItem.index != null)
|
||||||
if (heroItem.year != null || heroItem.contentRating != null || heroItem.rating != null) ...[
|
TextSpan(
|
||||||
const SizedBox(height: 16),
|
text: 'S${heroItem.parentIndex}, E${heroItem.index}: ',
|
||||||
Text(
|
style: TextStyle(
|
||||||
[
|
fontWeight: FontWeight.bold,
|
||||||
contentTypeLabel,
|
color: isLargeScreen ? Colors.white : Theme.of(context).colorScheme.onSurface,
|
||||||
if (heroItem.rating != null) '★ ${heroItem.rating!.toStringAsFixed(1)}',
|
|
||||||
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) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
RichText(
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
|
||||||
text: TextSpan(
|
|
||||||
style: const TextStyle(color: Colors.white70, fontSize: 14, height: 1.4),
|
|
||||||
children: [
|
|
||||||
if (isEpisode && heroItem.parentIndex != null && heroItem.index != null)
|
|
||||||
TextSpan(
|
|
||||||
text: 'S${heroItem.parentIndex}, E${heroItem.index}: ',
|
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white),
|
|
||||||
),
|
|
||||||
TextSpan(
|
|
||||||
text: heroItem.summary?.isNotEmpty == true
|
|
||||||
? heroItem.summary!
|
|
||||||
: 'No description available',
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: heroItem.summary?.isNotEmpty == true
|
||||||
|
? heroItem.summary!
|
||||||
|
: 'No description available',
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
// On large screens: show button after summary
|
// On large screens: show button after summary
|
||||||
if (isLargeScreen) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
|
if (isLargeScreen) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||