fix: reduce widget tree depth
This commit is contained in:
+2
-2
@@ -24,8 +24,8 @@ import 'providers/playback_state_provider.dart';
|
||||
import 'providers/download_provider.dart';
|
||||
import 'providers/offline_mode_provider.dart';
|
||||
import 'providers/offline_watch_provider.dart';
|
||||
import 'providers/shader_provider.dart';
|
||||
import 'providers/companion_remote_provider.dart';
|
||||
import 'providers/shader_provider.dart';
|
||||
import 'watch_together/watch_together.dart';
|
||||
import 'services/multi_server_manager.dart';
|
||||
import 'services/offline_watch_sync_service.dart';
|
||||
@@ -311,8 +311,8 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
ChangeNotifierProvider(create: (context) => LibrariesProvider()),
|
||||
ChangeNotifierProvider(create: (context) => PlaybackStateProvider()),
|
||||
ChangeNotifierProvider(create: (context) => WatchTogetherProvider()),
|
||||
ChangeNotifierProvider(create: (context) => ShaderProvider()),
|
||||
ChangeNotifierProvider(create: (context) => CompanionRemoteProvider()),
|
||||
ChangeNotifierProvider(create: (context) => ShaderProvider()),
|
||||
],
|
||||
child: Consumer<ThemeProvider>(
|
||||
builder: (context, themeProvider, child) {
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ class _VideoState extends State<Video> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
return ColoredBox(
|
||||
color: _hasFirstFrame ? Colors.transparent : widget.backgroundColor,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
|
||||
+168
-163
@@ -154,16 +154,16 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
return context.getClientForServer(serverId);
|
||||
}
|
||||
|
||||
/// Update hub keys when hubs list changes
|
||||
/// Update hub keys when hubs list changes — reuse existing keys to avoid
|
||||
/// mass deep unmounts (ARM32 stack overflow during finalizeTree).
|
||||
void _updateHubKeys() {
|
||||
_hubKeys.clear();
|
||||
for (int i = 0; i < _hubs.length; i++) {
|
||||
while (_hubKeys.length < _hubs.length) {
|
||||
_hubKeys.add(GlobalKey<HubSectionState>());
|
||||
}
|
||||
// Create continue watching hub key if needed
|
||||
if (_onDeck.isNotEmpty) {
|
||||
_continueWatchingHubKey ??= GlobalKey<HubSectionState>();
|
||||
if (_hubKeys.length > _hubs.length) {
|
||||
_hubKeys.removeRange(_hubs.length, _hubKeys.length);
|
||||
}
|
||||
_continueWatchingHubKey ??= GlobalKey<HubSectionState>();
|
||||
}
|
||||
|
||||
/// Get all hub states (continue watching + other hubs)
|
||||
@@ -1097,138 +1097,137 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
final showServerNameOnHubs = context.watch<SettingsProvider>().showServerNameOnHubs;
|
||||
final duplicateHubTitles = _getDuplicateHubTitles();
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
final bottomPadding = MediaQuery.of(context).padding.bottom;
|
||||
return Material(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
child: 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),
|
||||
);
|
||||
},
|
||||
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) 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),
|
||||
onNavigateUp: _focusTopBoundary,
|
||||
onNavigateToSidebar: _navigateToSidebar,
|
||||
),
|
||||
),
|
||||
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),
|
||||
onNavigateUp: _focusTopBoundary,
|
||||
onNavigateToSidebar: _navigateToSidebar,
|
||||
),
|
||||
|
||||
// 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),
|
||||
showServerName: showServerNameOnHubs || duplicateHubTitles.contains(_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),
|
||||
onNavigateUp: (i == 0 && _onDeck.isEmpty) ? _focusTopBoundary : null,
|
||||
onNavigateToSidebar: _navigateToSidebar,
|
||||
),
|
||||
),
|
||||
|
||||
// Recommendation Hubs (Trending, Top in Genre, etc.)
|
||||
for (int i = 0; i < _hubs.length; i++)
|
||||
// Show loading skeleton for hubs while they're loading
|
||||
if (_areHubsLoading && _hubs.isEmpty)
|
||||
for (int i = 0; i < 3; i++)
|
||||
SliverToBoxAdapter(
|
||||
child: HubSection(
|
||||
key: i < _hubKeys.length ? _hubKeys[i] : null,
|
||||
hub: _hubs[i],
|
||||
icon: _getHubIcon(_hubs[i].title),
|
||||
showServerName: showServerNameOnHubs || duplicateHubTitles.contains(_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),
|
||||
onNavigateUp: (i == 0 && _onDeck.isEmpty) ? _focusTopBoundary : null,
|
||||
onNavigateToSidebar: _navigateToSidebar,
|
||||
),
|
||||
),
|
||||
|
||||
// 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: const BorderRadius.all(Radius.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: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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)),
|
||||
// Hub title skeleton
|
||||
Container(
|
||||
width: 200,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.all(Radius.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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 24)),
|
||||
],
|
||||
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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
SliverToBoxAdapter(child: SizedBox(height: 24 + bottomPadding)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// Overlaid app bar — excluded from default focus traversal so that
|
||||
// initial/tab-switch focus lands on content (hero/hubs), not the toolbar.
|
||||
@@ -1439,14 +1438,16 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
imageType: ImageType.art,
|
||||
);
|
||||
|
||||
return blurArtwork(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),
|
||||
));
|
||||
return blurArtwork(
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -1507,39 +1508,19 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
imageType: ImageType.logo,
|
||||
);
|
||||
|
||||
return blurArtwork(CachedNetworkImage(
|
||||
imageUrl: logoUrl,
|
||||
filterQuality: FilterQuality.medium,
|
||||
fit: BoxFit.contain,
|
||||
memCacheWidth: (400 * dpr).clamp(200, 800).round(),
|
||||
alignment: isLargeScreen ? Alignment.bottomLeft : Alignment.bottomCenter,
|
||||
placeholder: (context, url) => Align(
|
||||
alignment: isLargeScreen ? Alignment.centerLeft : Alignment.center,
|
||||
child: Text(
|
||||
showName,
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
|
||||
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,
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) {
|
||||
// Fallback to text if logo fails to load
|
||||
return Align(
|
||||
return blurArtwork(
|
||||
CachedNetworkImage(
|
||||
imageUrl: logoUrl,
|
||||
filterQuality: FilterQuality.medium,
|
||||
fit: BoxFit.contain,
|
||||
memCacheWidth: (400 * dpr).clamp(200, 800).round(),
|
||||
alignment: isLargeScreen ? Alignment.bottomLeft : Alignment.bottomCenter,
|
||||
placeholder: (context, url) => Align(
|
||||
alignment: isLargeScreen ? Alignment.centerLeft : Alignment.center,
|
||||
child: Text(
|
||||
showName,
|
||||
style: Theme.of(context).textTheme.displaySmall?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3),
|
||||
fontWeight: FontWeight.bold,
|
||||
shadows: [
|
||||
Shadow(
|
||||
@@ -1552,9 +1533,33 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: isLargeScreen ? TextAlign.left : TextAlign.center,
|
||||
),
|
||||
);
|
||||
},
|
||||
), sigma: 10, clip: false);
|
||||
),
|
||||
errorWidget: (context, url, error) {
|
||||
// Fallback to text if logo fails to load
|
||||
return Align(
|
||||
alignment: isLargeScreen ? Alignment.centerLeft : Alignment.center,
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
sigma: 10,
|
||||
clip: false,
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -982,6 +982,10 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
Widget build(BuildContext context) {
|
||||
final useSideNav = PlatformDetector.shouldUseSideNavigation(context);
|
||||
|
||||
return _buildContent(context, useSideNav);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, bool useSideNav) {
|
||||
if (useSideNav) {
|
||||
return Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
@@ -998,60 +1002,60 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
child: Focus(
|
||||
onKeyEvent: (node, event) => _handleBackKey(event),
|
||||
child: MainScreenFocusScope(
|
||||
focusSidebar: _focusSidebar,
|
||||
focusContent: _focusContent,
|
||||
isSidebarFocused: _isSidebarFocused,
|
||||
child: SideNavigationScope(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Content with animated left padding based on sidebar state
|
||||
Positioned.fill(
|
||||
child: AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: EdgeInsets.only(left: contentLeftPadding),
|
||||
focusSidebar: _focusSidebar,
|
||||
focusContent: _focusContent,
|
||||
isSidebarFocused: _isSidebarFocused,
|
||||
child: SideNavigationScope(
|
||||
child: Stack(
|
||||
children: [
|
||||
// Content with animated left padding based on sidebar state
|
||||
Positioned.fill(
|
||||
child: AnimatedPadding(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOutCubic,
|
||||
padding: EdgeInsets.only(left: contentLeftPadding),
|
||||
child: FocusScope(
|
||||
node: _contentFocusScope,
|
||||
// No autofocus - we control focus programmatically to prevent
|
||||
// autofocus from stealing focus back after setState() rebuilds
|
||||
child: IndexedStack(index: _currentIndex, children: _screens),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Sidebar overlays content when expanded (unless always expanded)
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
child: FocusScope(
|
||||
node: _contentFocusScope,
|
||||
// No autofocus - we control focus programmatically to prevent
|
||||
// autofocus from stealing focus back after setState() rebuilds
|
||||
child: IndexedStack(index: _currentIndex, children: _screens),
|
||||
node: _sidebarFocusScope,
|
||||
child: SideNavigationRail(
|
||||
key: _sideNavKey,
|
||||
selectedIndex: _currentIndex,
|
||||
selectedLibraryKey: _selectedLibraryGlobalKey,
|
||||
isOfflineMode: _isOffline,
|
||||
isSidebarFocused: _isSidebarFocused,
|
||||
alwaysExpanded: alwaysExpanded,
|
||||
isReconnecting: _isReconnecting,
|
||||
onDestinationSelected: (index) {
|
||||
_selectTab(index);
|
||||
_focusContent();
|
||||
},
|
||||
onLibrarySelected: (key) {
|
||||
_selectLibrary(key);
|
||||
_focusContent();
|
||||
},
|
||||
onNavigateToContent: _focusContent,
|
||||
onReconnect: _triggerReconnect,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Sidebar overlays content when expanded (unless always expanded)
|
||||
Positioned(
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
child: FocusScope(
|
||||
node: _sidebarFocusScope,
|
||||
child: SideNavigationRail(
|
||||
key: _sideNavKey,
|
||||
selectedIndex: _currentIndex,
|
||||
selectedLibraryKey: _selectedLibraryGlobalKey,
|
||||
isOfflineMode: _isOffline,
|
||||
isSidebarFocused: _isSidebarFocused,
|
||||
alwaysExpanded: alwaysExpanded,
|
||||
isReconnecting: _isReconnecting,
|
||||
onDestinationSelected: (index) {
|
||||
_selectTab(index);
|
||||
_focusContent();
|
||||
},
|
||||
onLibrarySelected: (key) {
|
||||
_selectLibrary(key);
|
||||
_focusContent();
|
||||
},
|
||||
onNavigateToContent: _focusContent,
|
||||
onReconnect: _triggerReconnect,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1061,51 +1065,51 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
child: Scaffold(
|
||||
body: IndexedStack(index: _currentIndex, children: _screens),
|
||||
bottomNavigationBar: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Reconnect bar when offline
|
||||
if (_isOffline)
|
||||
Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: InkWell(
|
||||
onTap: _isReconnecting ? null : _triggerReconnect,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (_isReconnecting)
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Reconnect bar when offline
|
||||
if (_isOffline)
|
||||
Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: InkWell(
|
||||
onTap: _isReconnecting ? null : _triggerReconnect,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (_isReconnecting)
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(Symbols.wifi_rounded, size: 18, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
t.common.reconnect,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(Symbols.wifi_rounded, size: 18, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
t.common.reconnect,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: _selectTab,
|
||||
destinations: _buildNavDestinations(_isOffline),
|
||||
),
|
||||
NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: _selectTab,
|
||||
destinations: _buildNavDestinations(_isOffline),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import '../../widgets/plex_optimized_image.dart';
|
||||
|
||||
/// Custom list item widget for playlist items
|
||||
/// Shows drag handle, poster, title/metadata, duration, and remove button
|
||||
class PlaylistItemCard extends StatelessWidget {
|
||||
class PlaylistItemCard extends StatefulWidget {
|
||||
final PlexMetadata item;
|
||||
final int index;
|
||||
final VoidCallback onRemove;
|
||||
@@ -38,21 +38,37 @@ class PlaylistItemCard extends StatelessWidget {
|
||||
this.isMoving = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlaylistItemCard> createState() => _PlaylistItemCardState();
|
||||
}
|
||||
|
||||
class _PlaylistItemCardState extends State<PlaylistItemCard> {
|
||||
final _contextMenuKey = GlobalKey<MediaContextMenuState>();
|
||||
Offset? _tapPosition;
|
||||
|
||||
void _storeTapPosition(TapDownDetails details) {
|
||||
_tapPosition = details.globalPosition;
|
||||
}
|
||||
|
||||
void _showContextMenu() {
|
||||
_contextMenuKey.currentState?.showContextMenu(context, position: _tapPosition);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Determine if row is focused (main content area)
|
||||
final isRowFocused = isFocused && focusedColumn == 0;
|
||||
final isRowFocused = widget.isFocused && widget.focusedColumn == 0;
|
||||
|
||||
// Focus states for individual elements
|
||||
final isDragHandleFocused = isFocused && focusedColumn == 1;
|
||||
final isRemoveButtonFocused = isFocused && focusedColumn == 2;
|
||||
final isDragHandleFocused = widget.isFocused && widget.focusedColumn == 1;
|
||||
final isRemoveButtonFocused = widget.isFocused && widget.focusedColumn == 2;
|
||||
|
||||
// Determine card styling based on focus/move state
|
||||
Color? cardColor;
|
||||
ShapeBorder? cardShape;
|
||||
if (isMoving) {
|
||||
if (widget.isMoving) {
|
||||
cardColor = colorScheme.primaryContainer;
|
||||
} else if (isRowFocused) {
|
||||
// Row is focused - use visible border like FocusableWrapper
|
||||
@@ -64,27 +80,32 @@ class PlaylistItemCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
return MediaContextMenu(
|
||||
item: item,
|
||||
onRefresh: onRefresh,
|
||||
onTap: onTap,
|
||||
key: _contextMenuKey,
|
||||
item: widget.item,
|
||||
onRefresh: widget.onRefresh,
|
||||
onTap: widget.onTap,
|
||||
child: Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
color: cardColor,
|
||||
shape: cardShape,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onTap: widget.onTap,
|
||||
onTapDown: _storeTapPosition,
|
||||
onLongPress: _showContextMenu,
|
||||
onSecondaryTapDown: _storeTapPosition,
|
||||
onSecondaryTap: _showContextMenu,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
// Drag handle (if reorderable)
|
||||
// Wrapped in GestureDetector to consume long-press and prevent context menu
|
||||
if (canReorder)
|
||||
if (widget.canReorder)
|
||||
GestureDetector(
|
||||
// ignore: no-empty-block - consumes long-press to prevent context menu on drag
|
||||
onLongPress: () {},
|
||||
child: ReorderableDragStartListener(
|
||||
index: index,
|
||||
index: widget.index,
|
||||
child: Container(
|
||||
color: Colors.transparent,
|
||||
height: 90,
|
||||
@@ -99,9 +120,9 @@ class PlaylistItemCard extends StatelessWidget {
|
||||
)
|
||||
: null,
|
||||
child: AppIcon(
|
||||
isMoving ? Symbols.swap_vert_rounded : Symbols.drag_indicator_rounded,
|
||||
widget.isMoving ? Symbols.swap_vert_rounded : Symbols.drag_indicator_rounded,
|
||||
fill: 1,
|
||||
color: (isMoving || isDragHandleFocused) ? colorScheme.primary : Colors.grey,
|
||||
color: (widget.isMoving || isDragHandleFocused) ? colorScheme.primary : Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -121,7 +142,7 @@ class PlaylistItemCard extends StatelessWidget {
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
item.displayTitle,
|
||||
widget.item.displayTitle,
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -138,10 +159,14 @@ class PlaylistItemCard extends StatelessWidget {
|
||||
),
|
||||
|
||||
// Progress indicator if partially watched
|
||||
if (item.viewOffset != null && item.duration != null)
|
||||
if (widget.item.viewOffset != null && widget.item.duration != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: MediaProgressBar(viewOffset: item.viewOffset!, duration: item.duration!, minHeight: 3),
|
||||
child: MediaProgressBar(
|
||||
viewOffset: widget.item.viewOffset!,
|
||||
duration: widget.item.duration!,
|
||||
minHeight: 3,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -150,8 +175,11 @@ class PlaylistItemCard extends StatelessWidget {
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Duration
|
||||
if (item.duration != null)
|
||||
Text(formatDurationTextual(item.duration!), style: TextStyle(fontSize: 13, color: Colors.grey[400])),
|
||||
if (widget.item.duration != null)
|
||||
Text(
|
||||
formatDurationTextual(widget.item.duration!),
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
@@ -165,7 +193,7 @@ class PlaylistItemCard extends StatelessWidget {
|
||||
: null,
|
||||
child: IconButton(
|
||||
icon: const AppIcon(Symbols.close_rounded, fill: 1, size: 20),
|
||||
onPressed: onRemove,
|
||||
onPressed: widget.onRemove,
|
||||
tooltip: t.playlists.removeItem,
|
||||
color: isRemoveButtonFocused ? colorScheme.primary : Colors.grey[400],
|
||||
),
|
||||
@@ -180,11 +208,11 @@ class PlaylistItemCard extends StatelessWidget {
|
||||
|
||||
/// Get the correct PlexClient for this item's server
|
||||
PlexClient _getClientForItem(BuildContext context) {
|
||||
return context.getClientForServer(item.serverId!);
|
||||
return context.getClientForServer(widget.item.serverId!);
|
||||
}
|
||||
|
||||
Widget _buildPosterImage(BuildContext context) {
|
||||
final posterUrl = item.posterThumb();
|
||||
final posterUrl = widget.item.posterThumb();
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
||||
child: PlexOptimizedImage.poster(
|
||||
@@ -209,22 +237,22 @@ class PlaylistItemCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
String _buildSubtitle() {
|
||||
final itemType = item.type.toLowerCase();
|
||||
final itemType = widget.item.type.toLowerCase();
|
||||
|
||||
if (itemType == 'episode') {
|
||||
// For episodes, show "S#E# - Episode Title"
|
||||
final season = item.parentIndex;
|
||||
final episode = item.index;
|
||||
final season = widget.item.parentIndex;
|
||||
final episode = widget.item.index;
|
||||
if (season != null && episode != null) {
|
||||
return 'S${season}E$episode${item.displaySubtitle != null ? ' - ${item.displaySubtitle}' : ''}';
|
||||
return 'S${season}E$episode${widget.item.displaySubtitle != null ? ' - ${widget.item.displaySubtitle}' : ''}';
|
||||
}
|
||||
return item.displaySubtitle ?? t.discover.tvShow;
|
||||
return widget.item.displaySubtitle ?? t.discover.tvShow;
|
||||
} else if (itemType == 'movie') {
|
||||
// For movies, show year
|
||||
return item.year?.toString() ?? t.discover.movie;
|
||||
return widget.item.year?.toString() ?? t.discover.movie;
|
||||
}
|
||||
|
||||
// Default to type
|
||||
return item.type;
|
||||
return widget.item.type;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,9 +72,22 @@ class FocusBuilders {
|
||||
double borderRadius = FocusTheme.defaultBorderRadius,
|
||||
required Widget child,
|
||||
}) {
|
||||
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
|
||||
|
||||
// In touch mode, no item ever shows focus effects — skip animated wrappers
|
||||
// entirely. This saves ~2 element levels per card on ARM32 Android phones.
|
||||
if (!isKeyboardMode) {
|
||||
final gestureWidget = (onTap != null || onLongPress != null)
|
||||
? GestureDetector(onTap: onTap, onLongPress: onLongPress, child: child)
|
||||
: child;
|
||||
if (focusNode != null && onKeyEvent != null) {
|
||||
return Focus(focusNode: focusNode, onKeyEvent: onKeyEvent, child: gestureWidget);
|
||||
}
|
||||
return gestureWidget;
|
||||
}
|
||||
|
||||
final duration = FocusTheme.getAnimationDuration(context);
|
||||
// Only show focus effects during keyboard/d-pad navigation
|
||||
final showFocus = isFocused && InputModeTracker.isKeyboardMode(context);
|
||||
final showFocus = isFocused && isKeyboardMode;
|
||||
|
||||
final focusedWidget = AnimatedScale(
|
||||
scale: showFocus ? FocusTheme.focusScale : 1.0,
|
||||
|
||||
@@ -377,10 +377,11 @@ class HubSectionState extends State<HubSection> {
|
||||
Focus(
|
||||
focusNode: _hubFocusNode,
|
||||
onKeyEvent: _handleKeyEvent,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
// Responsive base card width for posters (2:3 aspect ratio)
|
||||
final screenWidth = constraints.maxWidth;
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
// Use MediaQuery instead of LayoutBuilder to save 1 element level.
|
||||
// HubSection is always full-width inside CustomScrollView.
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final settings = context.watch<SettingsProvider>();
|
||||
final densityScale = switch (settings.libraryDensity) {
|
||||
LibraryDensity.compact => 0.8,
|
||||
@@ -444,7 +445,8 @@ class HubSectionState extends State<HubSection> {
|
||||
if (index == widget.hub.items.length) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: _LockedHubItemWrapper(
|
||||
child: FocusBuilders.buildLockedFocusWrapper(
|
||||
context: context,
|
||||
isFocused: isItemFocused,
|
||||
onTap: () {
|
||||
_onItemTapped(index);
|
||||
@@ -481,7 +483,8 @@ class HubSectionState extends State<HubSection> {
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: _LockedHubItemWrapper(
|
||||
child: FocusBuilders.buildLockedFocusWrapper(
|
||||
context: context,
|
||||
isFocused: isItemFocused,
|
||||
onTap: () => _onItemTapped(index),
|
||||
onLongPress: () => _mediaCardKeys[index]?.currentState?.showContextMenu(),
|
||||
@@ -526,24 +529,3 @@ class HubSectionState extends State<HubSection> {
|
||||
_hubFocusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper that provides visual focus decoration without using Flutter's focus system.
|
||||
class _LockedHubItemWrapper extends StatelessWidget {
|
||||
final bool isFocused;
|
||||
final Widget child;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
const _LockedHubItemWrapper({required this.isFocused, required this.child, this.onTap, this.onLongPress});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FocusBuilders.buildLockedFocusWrapper(
|
||||
context: context,
|
||||
isFocused: isFocused,
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+182
-197
@@ -53,9 +53,14 @@ class MediaCard extends StatefulWidget {
|
||||
|
||||
class MediaCardState extends State<MediaCard> {
|
||||
final _contextMenuKey = GlobalKey<MediaContextMenuState>();
|
||||
Offset? _tapPosition;
|
||||
|
||||
void _storeTapPosition(TapDownDetails details) {
|
||||
_tapPosition = details.globalPosition;
|
||||
}
|
||||
|
||||
void _showContextMenu() {
|
||||
_contextMenuKey.currentState?.showContextMenu(context);
|
||||
_contextMenuKey.currentState?.showContextMenu(context, position: _tapPosition);
|
||||
}
|
||||
|
||||
/// Public method to trigger tap action (for keyboard/gamepad SELECT)
|
||||
@@ -65,7 +70,7 @@ class MediaCardState extends State<MediaCard> {
|
||||
|
||||
/// Public method to show context menu (for keyboard/gamepad context menu key)
|
||||
void showContextMenu() {
|
||||
_showContextMenu();
|
||||
_contextMenuKey.currentState?.showContextMenu(context);
|
||||
}
|
||||
|
||||
String _buildSemanticLabel() {
|
||||
@@ -165,28 +170,22 @@ class MediaCardState extends State<MediaCard> {
|
||||
final localPosterPath = _getLocalPosterPath(context);
|
||||
|
||||
final cardWidget = viewMode == ViewMode.grid
|
||||
? _MediaCardGrid(
|
||||
item: widget.item,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
semanticLabel: semanticLabel,
|
||||
onTap: () => _handleTap(context),
|
||||
onLongPress: _showContextMenu,
|
||||
isOffline: widget.isOffline,
|
||||
localPosterPath: localPosterPath,
|
||||
mixedHubContext: widget.mixedHubContext,
|
||||
)
|
||||
? _buildGridCard(context, semanticLabel, localPosterPath)
|
||||
: _MediaCardList(
|
||||
item: widget.item,
|
||||
semanticLabel: semanticLabel,
|
||||
onTap: () => _handleTap(context),
|
||||
onTapDown: _storeTapPosition,
|
||||
onLongPress: _showContextMenu,
|
||||
onSecondaryTapDown: _storeTapPosition,
|
||||
onSecondaryTap: _showContextMenu,
|
||||
density: settingsProvider.libraryDensity,
|
||||
isOffline: widget.isOffline,
|
||||
localPosterPath: localPosterPath,
|
||||
);
|
||||
|
||||
// Use context menu for both PlexMetadata and PlexPlaylist items
|
||||
// MediaContextMenu as a non-widget helper — only wrap with its key for
|
||||
// programmatic context menu access; gesture callbacks are on InkWell directly.
|
||||
return MediaContextMenu(
|
||||
key: _contextMenuKey,
|
||||
item: widget.item,
|
||||
@@ -199,96 +198,91 @@ class MediaCardState extends State<MediaCard> {
|
||||
child: cardWidget,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid layout for media cards
|
||||
class _MediaCardGrid extends StatelessWidget {
|
||||
final dynamic item; // Can be PlexMetadata or PlexPlaylist
|
||||
final double? width;
|
||||
final double? height;
|
||||
final String semanticLabel;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
final bool isOffline;
|
||||
final String? localPosterPath;
|
||||
final bool mixedHubContext;
|
||||
/// Grid layout — inlined from former _MediaCardGrid, _PosterOverlay, and
|
||||
/// flattened Column. Semantics removed (InkWell provides button semantics).
|
||||
Widget _buildGridCard(BuildContext context, String semanticLabel, String? localPosterPath) {
|
||||
final item = widget.item;
|
||||
// Compute actual poster dimensions from card dimensions
|
||||
final posterWidth = widget.width != null ? widget.width! - 16 : null; // 8px padding each side
|
||||
final posterHeight = widget.height;
|
||||
|
||||
const _MediaCardGrid({
|
||||
required this.item,
|
||||
this.width,
|
||||
this.height,
|
||||
required this.semanticLabel,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
this.isOffline = false,
|
||||
this.localPosterPath,
|
||||
this.mixedHubContext = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: Semantics(
|
||||
label: semanticLabel,
|
||||
button: true,
|
||||
child: InkWell(
|
||||
canRequestFocus: false, // Keyboard handled by FocusableMediaCard
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Poster
|
||||
if (height != null)
|
||||
SizedBox(width: double.infinity, height: height, child: _buildPosterWithOverlay(context))
|
||||
else
|
||||
Expanded(child: _buildPosterWithOverlay(context)),
|
||||
const SizedBox(height: 4),
|
||||
// Text content
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item is PlexPlaylist ? (item as PlexPlaylist).title : (item as PlexMetadata).displayTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13, height: 1.1),
|
||||
),
|
||||
if (item is PlexPlaylist)
|
||||
_MediaCardHelpers.buildPlaylistMeta(context, item as PlexPlaylist)
|
||||
else if (item is PlexMetadata)
|
||||
_MediaCardHelpers.buildMetadataSubtitle(context, item as PlexMetadata),
|
||||
],
|
||||
width: widget.width,
|
||||
child: InkWell(
|
||||
canRequestFocus: false,
|
||||
onTap: () => _handleTap(context),
|
||||
onTapDown: _storeTapPosition,
|
||||
onLongPress: _showContextMenu,
|
||||
onSecondaryTapDown: _storeTapPosition,
|
||||
onSecondaryTap: _showContextMenu,
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Poster with overlay
|
||||
if (posterHeight != null)
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: posterHeight,
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
child: _buildPosterImage(
|
||||
context,
|
||||
item,
|
||||
isOffline: widget.isOffline,
|
||||
localPosterPath: localPosterPath,
|
||||
mixedHubContext: widget.mixedHubContext,
|
||||
knownWidth: posterWidth,
|
||||
knownHeight: posterHeight,
|
||||
),
|
||||
),
|
||||
// Inlined _PosterOverlay
|
||||
if (item is PlexMetadata) _MediaCardHelpers.buildWatchProgress(context, item),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
child: _buildPosterImage(
|
||||
context,
|
||||
item,
|
||||
isOffline: widget.isOffline,
|
||||
localPosterPath: localPosterPath,
|
||||
mixedHubContext: widget.mixedHubContext,
|
||||
),
|
||||
),
|
||||
if (item is PlexMetadata) _MediaCardHelpers.buildWatchProgress(context, item),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Title (flattened — no inner Column)
|
||||
Text(
|
||||
item is PlexPlaylist ? item.title : (item as PlexMetadata).displayTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13, height: 1.1),
|
||||
),
|
||||
// Subtitle
|
||||
if (item is PlexPlaylist)
|
||||
_MediaCardHelpers.buildPlaylistMeta(context, item)
|
||||
else if (item is PlexMetadata)
|
||||
_MediaCardHelpers.buildMetadataSubtitle(context, item),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPosterWithOverlay(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
child: _buildPosterImage(
|
||||
context,
|
||||
item,
|
||||
isOffline: isOffline,
|
||||
localPosterPath: localPosterPath,
|
||||
mixedHubContext: mixedHubContext,
|
||||
),
|
||||
),
|
||||
_PosterOverlay(item: item),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// List layout for media cards
|
||||
@@ -297,6 +291,9 @@ class _MediaCardList extends StatelessWidget {
|
||||
final String semanticLabel;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
final void Function(TapDownDetails)? onTapDown;
|
||||
final VoidCallback? onSecondaryTap;
|
||||
final void Function(TapDownDetails)? onSecondaryTapDown;
|
||||
final LibraryDensity density;
|
||||
final bool isOffline;
|
||||
final String? localPosterPath;
|
||||
@@ -306,6 +303,9 @@ class _MediaCardList extends StatelessWidget {
|
||||
required this.semanticLabel,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
this.onTapDown,
|
||||
this.onSecondaryTap,
|
||||
this.onSecondaryTapDown,
|
||||
required this.density,
|
||||
this.isOffline = false,
|
||||
this.localPosterPath,
|
||||
@@ -488,92 +488,92 @@ class _MediaCardList extends StatelessWidget {
|
||||
final metadataLine = _buildMetadataLine();
|
||||
final subtitle = _buildSubtitleText();
|
||||
|
||||
return Semantics(
|
||||
label: semanticLabel,
|
||||
button: true,
|
||||
child: InkWell(
|
||||
canRequestFocus: false, // Keyboard handled by FocusableMediaCard
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Poster (responsive size based on density)
|
||||
SizedBox(
|
||||
width: _posterWidth(context),
|
||||
height: _posterHeight(context),
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
child: _buildPosterImage(context, item, isOffline: isOffline, localPosterPath: localPosterPath),
|
||||
),
|
||||
_PosterOverlay(item: item),
|
||||
],
|
||||
),
|
||||
return InkWell(
|
||||
canRequestFocus: false, // Keyboard handled by FocusableMediaCard
|
||||
onTap: onTap,
|
||||
onTapDown: onTapDown,
|
||||
onLongPress: onLongPress,
|
||||
onSecondaryTapDown: onSecondaryTapDown,
|
||||
onSecondaryTap: onSecondaryTap,
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Poster (responsive size based on density)
|
||||
SizedBox(
|
||||
width: _posterWidth(context),
|
||||
height: _posterHeight(context),
|
||||
child: Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(tokens(context).radiusSm),
|
||||
child: _buildPosterImage(context, item, isOffline: isOffline, localPosterPath: localPosterPath),
|
||||
),
|
||||
if (item is PlexMetadata) _MediaCardHelpers.buildWatchProgress(context, item as PlexMetadata),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Metadata
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
// Title
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// Metadata
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
item.displayTitle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: _titleFontSize, height: 1.2),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Metadata info line (rating, duration, score, studio)
|
||||
if (metadataLine.isNotEmpty) ...[
|
||||
Text(
|
||||
item.displayTitle,
|
||||
maxLines: 2,
|
||||
metadataLine,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: _titleFontSize, height: 1.2),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tokens(context).textMuted.withValues(alpha: 0.9),
|
||||
fontSize: _metadataFontSize,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
// Subtitle (S#E# or year/parent title)
|
||||
if (subtitle != null) ...[
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tokens(context).textMuted.withValues(alpha: 0.85),
|
||||
fontSize: _subtitleFontSize,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// Metadata info line (rating, duration, score, studio)
|
||||
if (metadataLine.isNotEmpty) ...[
|
||||
Text(
|
||||
metadataLine,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tokens(context).textMuted.withValues(alpha: 0.9),
|
||||
fontSize: _metadataFontSize,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
// Subtitle (S#E# or year/parent title)
|
||||
if (subtitle != null) ...[
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tokens(context).textMuted.withValues(alpha: 0.85),
|
||||
fontSize: _subtitleFontSize,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
// Summary
|
||||
if (item.summary != null) ...[
|
||||
Text(
|
||||
item.summary!,
|
||||
maxLines: _summaryMaxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tokens(context).textMuted.withValues(alpha: 0.7),
|
||||
fontSize: _summaryFontSize,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
// Summary
|
||||
if (item.summary != null) ...[
|
||||
Text(
|
||||
item.summary!,
|
||||
maxLines: _summaryMaxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tokens(context).textMuted.withValues(alpha: 0.7),
|
||||
fontSize: _summaryFontSize,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -586,6 +586,8 @@ Widget _buildPosterImage(
|
||||
bool isOffline = false,
|
||||
String? localPosterPath,
|
||||
bool mixedHubContext = false,
|
||||
double? knownWidth,
|
||||
double? knownHeight,
|
||||
}) {
|
||||
String? posterUrl;
|
||||
IconData fallbackIcon = Symbols.movie_rounded;
|
||||
@@ -597,8 +599,8 @@ Widget _buildPosterImage(
|
||||
return PlexOptimizedImage.playlist(
|
||||
client: isOffline ? null : context.getClientWithFallback(item.serverId),
|
||||
imagePath: posterUrl,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
width: knownWidth ?? double.infinity,
|
||||
height: knownHeight ?? double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
localFilePath: localPosterPath,
|
||||
);
|
||||
@@ -611,8 +613,8 @@ Widget _buildPosterImage(
|
||||
return PlexOptimizedImage.thumb(
|
||||
client: isOffline ? null : context.getClientWithFallback(item.serverId),
|
||||
imagePath: posterUrl,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
width: knownWidth ?? double.infinity,
|
||||
height: knownHeight ?? double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
localFilePath: localPosterPath,
|
||||
);
|
||||
@@ -621,8 +623,8 @@ Widget _buildPosterImage(
|
||||
return PlexOptimizedImage.poster(
|
||||
client: isOffline ? null : context.getClientWithFallback(item.serverId),
|
||||
imagePath: posterUrl,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
width: knownWidth ?? double.infinity,
|
||||
height: knownHeight ?? double.infinity,
|
||||
fit: BoxFit.cover,
|
||||
localFilePath: localPosterPath,
|
||||
);
|
||||
@@ -633,23 +635,6 @@ Widget _buildPosterImage(
|
||||
);
|
||||
}
|
||||
|
||||
/// Overlay widget for poster showing watched indicator and progress bar
|
||||
class _PosterOverlay extends StatelessWidget {
|
||||
final dynamic item; // Can be PlexMetadata or PlexPlaylist
|
||||
|
||||
const _PosterOverlay({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Only show overlays for PlexMetadata items
|
||||
if (item is! PlexMetadata) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return _MediaCardHelpers.buildWatchProgress(context, item as PlexMetadata);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper methods for building media card metadata and subtitles
|
||||
class _MediaCardHelpers {
|
||||
/// Builds playlist metadata (item count)
|
||||
|
||||
@@ -72,10 +72,6 @@ class MediaContextMenu extends StatefulWidget {
|
||||
class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
Offset? _tapPosition;
|
||||
|
||||
void _storeTapPosition(TapDownDetails details) {
|
||||
_tapPosition = details.globalPosition;
|
||||
}
|
||||
|
||||
bool _openedFromKeyboard = false;
|
||||
bool _isContextMenuOpen = false;
|
||||
|
||||
@@ -110,11 +106,6 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
/// Get the correct PlexClient for this item's server
|
||||
PlexClient _getClientForItem() => context.getClientWithFallback(_itemServerId);
|
||||
|
||||
void _handleTap() {
|
||||
if (_isContextMenuOpen) return;
|
||||
widget.onTap?.call();
|
||||
}
|
||||
|
||||
void _showContextMenu(BuildContext context) async {
|
||||
if (_isContextMenuOpen) return;
|
||||
_isContextMenuOpen = true;
|
||||
@@ -195,9 +186,7 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
mediaType == PlexMediaType.show ||
|
||||
mediaType == PlexMediaType.season ||
|
||||
mediaType == PlexMediaType.episode) {
|
||||
menuActions.add(
|
||||
_MenuAction(value: 'rate', icon: Symbols.star_rounded, label: t.mediaMenu.rate),
|
||||
);
|
||||
menuActions.add(_MenuAction(value: 'rate', icon: Symbols.star_rounded, label: t.mediaMenu.rate));
|
||||
}
|
||||
|
||||
// Edit Metadata (for movies, shows, seasons, and episodes)
|
||||
@@ -924,7 +913,9 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
|
||||
/// Handle remove from collection action
|
||||
Future<void> _showRatingSheet(BuildContext context, PlexMetadata metadata, PlexClient client) async {
|
||||
final currentStarValue = (metadata.userRating != null && metadata.userRating! > 0) ? metadata.userRating! / 2.0 : 0.0;
|
||||
final currentStarValue = (metadata.userRating != null && metadata.userRating! > 0)
|
||||
? metadata.userRating! / 2.0
|
||||
: 0.0;
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (context) => RatingBottomSheet(
|
||||
@@ -1177,14 +1168,10 @@ class MediaContextMenuState extends State<MediaContextMenu> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: _handleTap,
|
||||
onTapDown: _storeTapPosition,
|
||||
onLongPress: () => _showContextMenu(context),
|
||||
onSecondaryTapDown: _storeTapPosition,
|
||||
onSecondaryTap: () => _showContextMenu(context),
|
||||
child: widget.child,
|
||||
);
|
||||
// GestureDetector wrapping removed — gesture callbacks are now on InkWell
|
||||
// directly in the card widgets, saving 1 element level. The context menu
|
||||
// is still accessible programmatically via showContextMenu().
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1235,10 +1222,7 @@ class _PlaylistSelectionDialog extends StatelessWidget {
|
||||
FocusableButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -1284,10 +1268,7 @@ class _CollectionSelectionDialog extends StatelessWidget {
|
||||
FocusableButton(
|
||||
autofocus: true,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(t.common.cancel),
|
||||
),
|
||||
child: TextButton(onPressed: () => Navigator.pop(context), child: Text(t.common.cancel)),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -421,13 +421,14 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
||||
// No PopScope here — the parent screen's PopScope should check
|
||||
// OverlaySheetController.isOpen and delegate to us. This avoids
|
||||
// the double-callback problem with nested PopScopes in one route.
|
||||
|
||||
return _OverlaySheetScope(
|
||||
controller: _controller,
|
||||
child: Stack(
|
||||
children: [
|
||||
widget.child,
|
||||
// Barrier + sheet only when open
|
||||
if (_isOpen) ...[
|
||||
// Barrier
|
||||
AnimatedBuilder(
|
||||
animation: _barrierAnimation,
|
||||
builder: (context, child) {
|
||||
@@ -437,7 +438,6 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
||||
);
|
||||
},
|
||||
),
|
||||
// Sheet
|
||||
_buildSheet(context),
|
||||
],
|
||||
],
|
||||
@@ -451,10 +451,7 @@ class _OverlaySheetHostState extends State<OverlaySheetHost> with SingleTickerPr
|
||||
|
||||
final effectiveConstraints =
|
||||
_constraints ??
|
||||
BoxConstraints(
|
||||
maxWidth: isDesktop ? 700 : double.infinity,
|
||||
maxHeight: isDesktop ? 400 : size.height * 0.75,
|
||||
);
|
||||
BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: isDesktop ? 400 : size.height * 0.75);
|
||||
|
||||
Widget sheet = FocusScope(
|
||||
node: _sheetFocusScopeNode,
|
||||
|
||||
@@ -334,19 +334,23 @@ class PlexOptimizedImage extends StatelessWidget {
|
||||
// Generate cache key if not provided
|
||||
final effectiveCacheKey = cacheKey ?? _generateCacheKey(imageUrl, memWidth, memHeight);
|
||||
|
||||
return CachedNetworkImage(
|
||||
imageUrl: imageUrl,
|
||||
return Image(
|
||||
image: CachedNetworkImageProvider(
|
||||
imageUrl,
|
||||
cacheKey: effectiveCacheKey,
|
||||
headers: const {'User-Agent': 'Plezy'},
|
||||
maxHeight: memHeight,
|
||||
),
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
filterQuality: filterQuality,
|
||||
alignment: alignment,
|
||||
fadeInDuration: fadeInDuration,
|
||||
memCacheHeight: memHeight,
|
||||
cacheKey: effectiveCacheKey,
|
||||
placeholder: placeholder != null ? placeholder! : (context, url) => _buildPlaceholder(context),
|
||||
errorWidget: errorWidget != null ? errorWidget! : (context, url, error) => _buildErrorWidget(context, error),
|
||||
httpHeaders: {'User-Agent': 'Plezy Flutter Client'},
|
||||
errorBuilder: (context, error, stackTrace) => _buildErrorWidget(context, error),
|
||||
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
|
||||
if (wasSynchronouslyLoaded || frame != null) return child;
|
||||
return _buildPlaceholder(context);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -270,7 +270,6 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
leading: true,
|
||||
trailing: true,
|
||||
);
|
||||
_loadPlaybackExtras();
|
||||
_loadSeekTimes();
|
||||
_startHideTimer();
|
||||
_initKeyboardService();
|
||||
@@ -286,16 +285,19 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
_initAlwaysOnTopState();
|
||||
}
|
||||
|
||||
// Focus play/pause button on first frame if in keyboard mode
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusPlayPauseIfKeyboardMode();
|
||||
});
|
||||
// Register global key handler for focus-independent shortcuts (desktop only)
|
||||
HardwareKeyboard.instance.addHandler(_handleGlobalKeyEvent);
|
||||
// Listen for first frame to start auto-hide timer
|
||||
widget.hasFirstFrame?.addListener(_onFirstFrameReady);
|
||||
// Listen for external requests to show controls (e.g. screen-level focus recovery)
|
||||
widget.controlsVisible?.addListener(_onControlsVisibleExternal);
|
||||
|
||||
// Defer context-dependent initialization to after first build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_loadPlaybackExtras();
|
||||
_focusPlayPauseIfKeyboardMode();
|
||||
});
|
||||
}
|
||||
|
||||
/// Called when hasFirstFrame changes - start auto-hide timer when first frame is ready
|
||||
@@ -634,7 +636,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
|
||||
/// Controls hide delay: 5s on mobile/TV/keyboard-nav, 3s on desktop with mouse.
|
||||
Duration get _hideDelay {
|
||||
final isMobile = PlatformDetector.isMobile(context) && !PlatformDetector.isTV();
|
||||
final isMobile = (Platform.isIOS || Platform.isAndroid) && !PlatformDetector.isTV();
|
||||
if (isMobile || PlatformDetector.isTV() || _videoPlayerNavigationEnabled) {
|
||||
return const Duration(seconds: 5);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user