fix: reduce widget tree depth

This commit is contained in:
edde746
2026-02-24 02:40:04 +01:00
parent 9528b8f61a
commit d1d5b808bb
12 changed files with 555 additions and 554 deletions
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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,
);
},
),
)
+89 -85
View File
@@ -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),
),
],
),
],
),
),
);
}
+56 -28
View File
@@ -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;
}
}
+15 -2
View File
@@ -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,
+9 -27
View File
@@ -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
View File
@@ -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)
+10 -29
View File
@@ -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)),
),
],
);
+3 -6
View File
@@ -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,
+12 -8
View File
@@ -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);
}