diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index 488d3b9d..825f72cc 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -281,16 +281,9 @@ class _FocusableWrapperState extends State with SingleTickerPr scrollDelta -= (_focusDecorationPadding - projectedItemTop); } - final targetOffset = (currentOffset + scrollDelta).clamp( - position.minScrollExtent, - position.maxScrollExtent, - ); + final targetOffset = (currentOffset + scrollDelta).clamp(position.minScrollExtent, position.maxScrollExtent); - position.animateTo( - targetOffset, - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - ); + position.animateTo(targetOffset, duration: const Duration(milliseconds: 200), curve: Curves.easeInOut); }); } diff --git a/lib/screens/downloads/downloads_screen.dart b/lib/screens/downloads/downloads_screen.dart index ca8ff34f..80fd217d 100644 --- a/lib/screens/downloads/downloads_screen.dart +++ b/lib/screens/downloads/downloads_screen.dart @@ -360,7 +360,10 @@ class _DownloadsGridContentState extends State<_DownloadsGridContent> { padding: effectivePadding, // Allow focus decoration to render outside scroll bounds clipBehavior: Clip.none, - gridDelegate: MediaGridDelegate.createDelegate(context: context, density: settingsProvider.libraryDensity), + gridDelegate: MediaGridDelegate.createDelegate( + context: context, + density: settingsProvider.libraryDensity, + ), itemCount: items.length, itemBuilder: (context, index) { final item = items[index]; diff --git a/lib/screens/libraries/adaptive_media_grid.dart b/lib/screens/libraries/adaptive_media_grid.dart index 030fc4d4..2678c6a1 100644 --- a/lib/screens/libraries/adaptive_media_grid.dart +++ b/lib/screens/libraries/adaptive_media_grid.dart @@ -17,11 +17,7 @@ class GridItemContext { /// Callback to navigate to the sidebar (for first-column items). final VoidCallback? navigateToSidebar; - const GridItemContext({ - required this.isFirstRow, - required this.isFirstColumn, - this.navigateToSidebar, - }); + const GridItemContext({required this.isFirstRow, required this.isFirstColumn, this.navigateToSidebar}); } /// A widget that automatically switches between grid and list view diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 04ff705c..49b9529a 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -1158,7 +1158,9 @@ class _LibrariesScreenState extends State onKeyEvent: _handleEditKeyEvent, child: Container( decoration: BoxDecoration( - color: _isEditFocused ? Theme.of(context).colorScheme.surfaceContainerHighest : Colors.transparent, + color: _isEditFocused + ? Theme.of(context).colorScheme.surfaceContainerHighest + : Colors.transparent, borderRadius: BorderRadius.circular(20), ), child: IconButton( @@ -1173,7 +1175,9 @@ class _LibrariesScreenState extends State onKeyEvent: _handleRefreshKeyEvent, child: Container( decoration: BoxDecoration( - color: _isRefreshFocused ? Theme.of(context).colorScheme.surfaceContainerHighest : Colors.transparent, + color: _isRefreshFocused + ? Theme.of(context).colorScheme.surfaceContainerHighest + : Colors.transparent, borderRadius: BorderRadius.circular(20), ), child: IconButton( diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 3bf2c5d9..e6da7282 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -120,7 +120,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState= _hubKeys.length) { // At bottom boundary, block navigation return true; diff --git a/lib/screens/playlist/playlist_item_card.dart b/lib/screens/playlist/playlist_item_card.dart index f879688d..dbe44235 100644 --- a/lib/screens/playlist/playlist_item_card.dart +++ b/lib/screens/playlist/playlist_item_card.dart @@ -97,10 +97,7 @@ class _PlaylistItemCardState extends State { padding: const EdgeInsets.all(8), margin: const EdgeInsets.only(right: 4), decoration: isDragHandleFocused - ? BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(8), - ) + ? BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(8)) : null, child: AppIcon( widget.isMoving ? Symbols.swap_vert_rounded : Symbols.drag_indicator_rounded, @@ -161,10 +158,7 @@ class _PlaylistItemCardState extends State { // Remove button Container( decoration: isRemoveButtonFocused - ? BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(20), - ) + ? BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(20)) : null, child: IconButton( icon: const AppIcon(Symbols.close_rounded, fill: 1, size: 20), diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index f6455c1e..86e0eff1 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -229,7 +229,10 @@ class _SearchScreenState extends State with Refreshable, FullRefre : null, filled: true, fillColor: Theme.of(context).colorScheme.surfaceContainerHighest, - border: OutlineInputBorder(borderRadius: BorderRadius.circular(100), borderSide: BorderSide.none), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(100), + borderSide: BorderSide.none, + ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(100), borderSide: BorderSide.none, @@ -267,11 +270,18 @@ class _SearchScreenState extends State with Refreshable, FullRefre else Consumer( builder: (context, settingsProvider, child) { - final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent(context, settingsProvider.libraryDensity); + final maxCrossAxisExtent = GridSizeCalculator.getMaxCrossAxisExtent( + context, + settingsProvider.libraryDensity, + ); const gridPadding = EdgeInsets.all(16); const crossAxisSpacing = 8.0; final gridAvailableWidth = availableWidth - gridPadding.left - gridPadding.right; - final columnCount = _calculateColumnCount(gridAvailableWidth, maxCrossAxisExtent, crossAxisSpacing); + final columnCount = _calculateColumnCount( + gridAvailableWidth, + maxCrossAxisExtent, + crossAxisSpacing, + ); final isList = settingsProvider.viewMode == ViewMode.list; return buildAdaptiveMediaSliverBuilder( @@ -290,12 +300,12 @@ class _SearchScreenState extends State with Refreshable, FullRefre onNavigateUp: isFirstRow ? focusSearchInput : null, ); }, - viewMode: settingsProvider.viewMode, - density: settingsProvider.libraryDensity, - padding: const EdgeInsets.all(16), - childAspectRatio: 2 / 3.3, - crossAxisSpacing: 8, - mainAxisSpacing: 8, + viewMode: settingsProvider.viewMode, + density: settingsProvider.libraryDensity, + padding: const EdgeInsets.all(16), + childAspectRatio: 2 / 3.3, + crossAxisSpacing: 8, + mainAxisSpacing: 8, ); }, ), diff --git a/lib/services/playback_progress_tracker.dart b/lib/services/playback_progress_tracker.dart index 1c9bbc0e..80cd335f 100644 --- a/lib/services/playback_progress_tracker.dart +++ b/lib/services/playback_progress_tracker.dart @@ -117,7 +117,9 @@ class PlaybackProgressTracker { await _sendOnlineProgress(state, position, duration); // Success — reset backoff state if (_consecutiveFailures > 0) { - appLogger.d('Progress update succeeded after $_consecutiveFailures consecutive failure(s), resetting backoff'); + appLogger.d( + 'Progress update succeeded after $_consecutiveFailures consecutive failure(s), resetting backoff', + ); _consecutiveFailures = 0; _ticksToSkip = 0; } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 1ee5d532..95ff0930 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -135,16 +135,6 @@ class PlexClient { } } - /// Test connection to server - Future testConnection() async { - try { - final response = await _dio.get('/'); - return response.statusCode == 200 || response.statusCode == 401; - } catch (e) { - return false; - } - } - /// Test connection to a specific URL with token and measure latency static Future testConnectionWithLatency( String baseUrl, @@ -435,38 +425,6 @@ class PlexClient { return null; } - /// Fetch metadata with cache support for offline mode and network fallback. - /// - /// Returns the raw response data (Map) or null if not available. - /// Used by playback methods to share caching logic. - Future?> _fetchMetadataWithCache(String ratingKey, {Map? queryParams}) async { - final cacheKey = '/library/metadata/$ratingKey'; - - // Offline mode: cache only - if (_offlineMode) { - return await _cache.get(serverId, cacheKey); - } - - // Online: try network first - try { - // Always include markers and chapters to ensure cached data is complete - // This prevents cache corruption when different methods fetch the same endpoint - final mergedParams = {'includeMarkers': 1, 'includeChapters': 1, ...?queryParams}; - final response = await _dio.get('/library/metadata/$ratingKey', queryParameters: mergedParams); - - // Cache at base endpoint - if (response.data != null) { - await _cache.put(serverId, cacheKey, response.data); - } - - return response.data; - } catch (e) { - // Network failed - try cache as fallback - appLogger.w('Network request failed for metadata, trying cache', error: e); - return await _cache.get(serverId, cacheKey); - } - } - /// Generic cache-network-fallback helper for fetching data /// /// This method implements the standard pattern used throughout the client: @@ -940,7 +898,18 @@ class PlexClient { /// This is the primary method for playback initialization. /// Uses cache for offline mode support and network fallback. Future getVideoPlaybackData(String ratingKey, {int mediaIndex = 0}) async { - final data = await _fetchMetadataWithCache(ratingKey); + Map? data; + try { + data = await _fetchWithCacheFallback>( + cacheKey: '/library/metadata/$ratingKey', + networkCall: () => + _dio.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}), + parseCache: (cached) => cached as Map?, + parseResponse: (response) => response.data as Map?, + ); + } catch (_) { + // Gracefully degrade: return empty playback data on total failure + } final metadataJson = _getFirstMetadataJsonFromData(data); String? videoUrl; @@ -1014,7 +983,13 @@ class PlexClient { /// Uses cache for offline mode support and network fallback. Future getFileInfo(String ratingKey) async { try { - final data = await _fetchMetadataWithCache(ratingKey); + final data = await _fetchWithCacheFallback>( + cacheKey: '/library/metadata/$ratingKey', + networkCall: () => + _dio.get('/library/metadata/$ratingKey', queryParameters: {'includeMarkers': 1, 'includeChapters': 1}), + parseCache: (cached) => cached as Map?, + parseResponse: (response) => response.data as Map?, + ); final metadataJson = _getFirstMetadataJsonFromData(data); if (metadataJson != null && metadataJson['Media'] != null && (metadataJson['Media'] as List).isNotEmpty) { diff --git a/lib/utils/plex_image_helper.dart b/lib/utils/plex_image_helper.dart index 95163662..5aea2ad7 100644 --- a/lib/utils/plex_image_helper.dart +++ b/lib/utils/plex_image_helper.dart @@ -1,5 +1,6 @@ import 'dart:math'; import '../services/plex_client.dart'; +import 'plex_url_helper.dart'; /// Image types for different transcoding strategies enum ImageType { @@ -91,9 +92,7 @@ class PlexImageHelper { final token = client.config.token; // URL encode the original path with token - final encodedPath = Uri.encodeComponent( - '$originalPath${originalPath.contains('?') ? '&' : '?'}X-Plex-Token=$token', - ); + final encodedPath = Uri.encodeComponent(originalPath.withPlexToken(token)); // Build the transcode URL final transcodeParams = { diff --git a/lib/widgets/download_tree_view.dart b/lib/widgets/download_tree_view.dart index 73ce8165..707a3558 100644 --- a/lib/widgets/download_tree_view.dart +++ b/lib/widgets/download_tree_view.dart @@ -536,8 +536,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { } int _getActionCount() { - final isContainer = widget.node.type == DownloadNodeType.show || - widget.node.type == DownloadNodeType.season; + final isContainer = widget.node.type == DownloadNodeType.show || widget.node.type == DownloadNodeType.season; if (isContainer) { return _getContainerActionCount(); } @@ -549,19 +548,18 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { final status = widget.node.status; if (status == DownloadStatus.downloading && widget.onPause != null) count++; if (status == DownloadStatus.paused && widget.onResume != null) count++; - if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && - widget.onCancel != null) count++; + if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onCancel != null) count++; if (status == DownloadStatus.failed && widget.onRetry != null) count++; - if ((status == DownloadStatus.completed || status == DownloadStatus.failed || - status == DownloadStatus.cancelled) && widget.onDelete != null) count++; + if ((status == DownloadStatus.completed || status == DownloadStatus.failed || status == DownloadStatus.cancelled) && + widget.onDelete != null) + count++; return count; } int _getContainerActionCount() { int count = 0; final status = widget.node.status; - if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && - widget.onPause != null) count++; + if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onPause != null) count++; if (status == DownloadStatus.paused && widget.onResume != null) count++; if (widget.onDelete != null) count++; return count; @@ -616,11 +614,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { children: [ // Expand/collapse icon if (canExpand) - AppIcon( - widget.isExpanded ? Symbols.expand_more_rounded : Symbols.chevron_right_rounded, - fill: 1, - size: 20, - ) + AppIcon(widget.isExpanded ? Symbols.expand_more_rounded : Symbols.chevron_right_rounded, fill: 1, size: 20) else const SizedBox(width: 20), @@ -650,15 +644,12 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { const SizedBox(height: 4), Text( _getNodeSummary(), - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha: 0.6), - ), + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurface.withValues(alpha: 0.6)), ), ], // Progress bar - if (widget.node.status == DownloadStatus.downloading || - widget.node.status == DownloadStatus.queued) ...[ + if (widget.node.status == DownloadStatus.downloading || widget.node.status == DownloadStatus.queued) ...[ const SizedBox(height: 8), LinearProgressIndicator( value: widget.node.progress, @@ -726,8 +717,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { } Widget _buildActions() { - final isContainer = widget.node.type == DownloadNodeType.show || - widget.node.type == DownloadNodeType.season; + final isContainer = widget.node.type == DownloadNodeType.show || widget.node.type == DownloadNodeType.season; final actions = isContainer ? _buildContainerActions() : _buildItemActions(); @@ -742,54 +732,63 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { // Pause button for downloading items if (status == DownloadStatus.downloading && widget.onPause != null) { - actions.add(_buildActionButton( - icon: Symbols.pause_rounded, - tooltip: 'Pause', - onPressed: () => widget.onPause!(globalKey), - buttonIndex: buttonIndex++, - )); + actions.add( + _buildActionButton( + icon: Symbols.pause_rounded, + tooltip: 'Pause', + onPressed: () => widget.onPause!(globalKey), + buttonIndex: buttonIndex++, + ), + ); } // Resume button for paused items if (status == DownloadStatus.paused && widget.onResume != null) { - actions.add(_buildActionButton( - icon: Symbols.play_arrow_rounded, - tooltip: 'Resume', - onPressed: () => widget.onResume!(globalKey), - buttonIndex: buttonIndex++, - )); + actions.add( + _buildActionButton( + icon: Symbols.play_arrow_rounded, + tooltip: 'Resume', + onPressed: () => widget.onResume!(globalKey), + buttonIndex: buttonIndex++, + ), + ); } // Cancel button for downloading/queued items - if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && - widget.onCancel != null) { - actions.add(_buildActionButton( - icon: Symbols.close_rounded, - tooltip: 'Cancel', - onPressed: () => widget.onCancel!(globalKey), - buttonIndex: buttonIndex++, - )); + if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onCancel != null) { + actions.add( + _buildActionButton( + icon: Symbols.close_rounded, + tooltip: 'Cancel', + onPressed: () => widget.onCancel!(globalKey), + buttonIndex: buttonIndex++, + ), + ); } // Retry button for failed items if (status == DownloadStatus.failed && widget.onRetry != null) { - actions.add(_buildActionButton( - icon: Symbols.refresh_rounded, - tooltip: t.downloads.retryDownload, - onPressed: () => widget.onRetry!(globalKey), - buttonIndex: buttonIndex++, - )); + actions.add( + _buildActionButton( + icon: Symbols.refresh_rounded, + tooltip: t.downloads.retryDownload, + onPressed: () => widget.onRetry!(globalKey), + buttonIndex: buttonIndex++, + ), + ); } // Delete button for completed/failed/cancelled items - if ((status == DownloadStatus.completed || status == DownloadStatus.failed || - status == DownloadStatus.cancelled) && widget.onDelete != null) { - actions.add(_buildActionButton( - icon: Symbols.delete_rounded, - tooltip: 'Delete', - onPressed: () => widget.onDelete!(globalKey), - buttonIndex: buttonIndex++, - )); + if ((status == DownloadStatus.completed || status == DownloadStatus.failed || status == DownloadStatus.cancelled) && + widget.onDelete != null) { + actions.add( + _buildActionButton( + icon: Symbols.delete_rounded, + tooltip: 'Delete', + onPressed: () => widget.onDelete!(globalKey), + buttonIndex: buttonIndex++, + ), + ); } return actions; @@ -801,34 +800,39 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { int buttonIndex = 0; // Pause all button - if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && - widget.onPause != null) { - actions.add(_buildActionButton( - icon: Symbols.pause_rounded, - tooltip: 'Pause all', - onPressed: () => widget.pauseAllChildren(widget.node), - buttonIndex: buttonIndex++, - )); + if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onPause != null) { + actions.add( + _buildActionButton( + icon: Symbols.pause_rounded, + tooltip: 'Pause all', + onPressed: () => widget.pauseAllChildren(widget.node), + buttonIndex: buttonIndex++, + ), + ); } // Resume all button if (status == DownloadStatus.paused && widget.onResume != null) { - actions.add(_buildActionButton( - icon: Symbols.play_arrow_rounded, - tooltip: 'Resume all', - onPressed: () => widget.resumeAllChildren(widget.node), - buttonIndex: buttonIndex++, - )); + actions.add( + _buildActionButton( + icon: Symbols.play_arrow_rounded, + tooltip: 'Resume all', + onPressed: () => widget.resumeAllChildren(widget.node), + buttonIndex: buttonIndex++, + ), + ); } // Delete all button if (widget.onDelete != null) { - actions.add(_buildActionButton( - icon: Symbols.delete_sweep_rounded, - tooltip: 'Delete all', - onPressed: () => widget.deleteAllChildren(widget.node), - buttonIndex: buttonIndex++, - )); + actions.add( + _buildActionButton( + icon: Symbols.delete_sweep_rounded, + tooltip: 'Delete all', + onPressed: () => widget.deleteAllChildren(widget.node), + buttonIndex: buttonIndex++, + ), + ); } return actions; @@ -846,12 +850,8 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { return FocusableWrapper( focusNode: _buttonFocusNodes[buttonIndex], onSelect: onPressed, - onNavigateLeft: isFirst - ? _focusRow - : () => _buttonFocusNodes[buttonIndex - 1].requestFocus(), - onNavigateRight: isLast - ? null - : () => _buttonFocusNodes[buttonIndex + 1].requestFocus(), + onNavigateLeft: isFirst ? _focusRow : () => _buttonFocusNodes[buttonIndex - 1].requestFocus(), + onNavigateRight: isLast ? null : () => _buttonFocusNodes[buttonIndex + 1].requestFocus(), onBack: widget.onBack, borderRadius: 20.0, disableScale: true, @@ -861,10 +861,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { message: tooltip, child: GestureDetector( onTap: onPressed, - child: Padding( - padding: const EdgeInsets.all(8.0), - child: AppIcon(icon, fill: 1, size: 20), - ), + child: Padding(padding: const EdgeInsets.all(8.0), child: AppIcon(icon, fill: 1, size: 20)), ), ), ); diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 1535e884..e24ac2eb 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -14,6 +14,8 @@ import '../../services/fullscreen_state_manager.dart'; import '../../utils/desktop_window_padding.dart'; import '../../i18n/strings.g.dart'; import '../../focus/focusable_wrapper.dart'; +import 'widgets/first_frame_guard.dart'; +import 'widgets/play_pause_stream_builder.dart'; import 'widgets/video_controls_header.dart'; import 'widgets/video_timeline_bar.dart'; import 'widgets/volume_control.dart'; @@ -352,24 +354,17 @@ class DesktopVideoControlsState extends State { @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: widget.hasFirstFrame ?? ValueNotifier(true), - builder: (context, hasFrame, child) { - return Column( - children: [ - // Top bar with back button and title (always visible) - _buildTopBar(context), - if (!hasFrame) - // Loading: empty space, spinner shown by video_player_screen - const Expanded(child: SizedBox.shrink()) - else ...[ - // Loaded: spacer + bottom controls - const Spacer(), - _buildBottomControlsContent(context, hasFrame: true), - ], - ], - ); - }, + return Column( + children: [ + // Top bar with back button and title (always visible) + _buildTopBar(context), + FirstFrameGuard( + hasFirstFrame: widget.hasFirstFrame, + placeholder: const Expanded(child: SizedBox.shrink()), + builder: (context) => + Expanded(child: Column(children: [const Spacer(), _buildBottomControlsContent(context, hasFrame: true)])), + ), + ], ); } @@ -456,11 +451,9 @@ class DesktopVideoControlsState extends State { // Play/Pause Opacity( opacity: widget.canControl ? 1.0 : 0.5, - child: StreamBuilder( - stream: widget.player.streams.playing, - initialData: widget.player.state.playing, - builder: (context, snapshot) { - final isPlaying = snapshot.data ?? false; + child: PlayPauseStreamBuilder( + player: widget.player, + builder: (context, isPlaying) { return _buildFocusableButton( focusNode: _playPauseFocusNode, index: 2, diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index f8915101..71a3331a 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../mpv/mpv.dart'; @@ -7,6 +6,9 @@ import '../../models/plex_media_info.dart'; import '../../models/plex_metadata.dart'; import '../../utils/desktop_window_padding.dart'; import '../../i18n/strings.g.dart'; +import 'widgets/circular_control_button.dart'; +import 'widgets/first_frame_guard.dart'; +import 'widgets/play_pause_stream_builder.dart'; import 'widgets/video_controls_header.dart'; import 'widgets/video_timeline_bar.dart'; @@ -100,41 +102,25 @@ class MobileVideoControls extends StatelessWidget { return const SizedBox.shrink(); } - // Check if we're still loading first frame - final hasFirstFrameNotifier = hasFirstFrame; - if (hasFirstFrameNotifier != null) { - return ValueListenableBuilder( - valueListenable: hasFirstFrameNotifier, - builder: (context, hasFrame, child) { - if (!hasFrame) { - // Empty space, spinner shown by video_player_screen - return const SizedBox.shrink(); - } - return _buildPlaybackControlsContent(context); - }, - ); - } - return _buildPlaybackControlsContent(context); + return FirstFrameGuard(hasFirstFrame: hasFirstFrame, builder: (context) => _buildPlaybackControlsContent(context)); } Widget _buildPlaybackControlsContent(BuildContext context) { - return StreamBuilder( - stream: player.streams.playing, - initialData: player.state.playing, - builder: (context, snapshot) { - final isPlaying = snapshot.data ?? false; + return PlayPauseStreamBuilder( + player: player, + builder: (context, isPlaying) { return Row( mainAxisAlignment: MainAxisAlignment.center, children: [ // Previous episode button (greyed out when unavailable) - _buildCircularButton( + CircularControlButton( semanticLabel: t.videoControls.previousButton, icon: Symbols.skip_previous_rounded, iconSize: 48, onPressed: onPrevious, ), const SizedBox(width: 24), - _buildCircularButton( + CircularControlButton( semanticLabel: isPlaying ? t.videoControls.pauseButton : t.videoControls.playButton, icon: isPlaying ? Symbols.pause_rounded : Symbols.play_arrow_rounded, iconSize: 72, @@ -150,7 +136,7 @@ class MobileVideoControls extends StatelessWidget { ), const SizedBox(width: 24), // Next episode button (greyed out when unavailable) - _buildCircularButton( + CircularControlButton( semanticLabel: t.videoControls.nextButton, icon: Symbols.skip_next_rounded, iconSize: 48, @@ -163,21 +149,7 @@ class MobileVideoControls extends StatelessWidget { } Widget _buildBottomBar(BuildContext context) { - // Check if we're still loading first frame - final hasFirstFrameNotifier = hasFirstFrame; - if (hasFirstFrameNotifier != null) { - return ValueListenableBuilder( - valueListenable: hasFirstFrameNotifier, - builder: (context, hasFrame, child) { - if (!hasFrame) { - // Hide timeline while loading - return const SizedBox.shrink(); - } - return _buildBottomBarContent(context); - }, - ); - } - return _buildBottomBarContent(context); + return FirstFrameGuard(hasFirstFrame: hasFirstFrame, builder: (context) => _buildBottomBarContent(context)); } Widget _buildBottomBarContent(BuildContext context) { @@ -199,33 +171,6 @@ class MobileVideoControls extends StatelessWidget { ); } - Widget _buildCircularButton({ - required String semanticLabel, - required IconData icon, - required double iconSize, - VoidCallback? onPressed, - }) { - final isEnabled = onPressed != null; - return Container( - decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), shape: BoxShape.circle), - child: Semantics( - label: semanticLabel, - button: true, - excludeSemantics: true, - child: IconButton( - icon: AppIcon( - icon, - fill: 1, - color: isEnabled ? Colors.white : Colors.white.withValues(alpha: 0.3), - size: iconSize, - ), - iconSize: iconSize, - onPressed: onPressed, - ), - ), - ); - } - /// Conditionally wraps child with SafeArea only in portrait mode Widget _conditionalSafeArea({ required BuildContext context, diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index 88eaad9d..0a535315 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -1360,8 +1360,9 @@ class _PlexVideoControlsState extends State with WindowListen } // Only handle KeyDown and KeyRepeat events + // Consume KeyUp events to prevent them leaking to previous routes if (!event.isActionable) { - return KeyEventResult.ignored; + return KeyEventResult.handled; } // Reset hide timer on any keyboard/controller input when controls are visible @@ -1426,13 +1427,13 @@ class _PlexVideoControlsState extends State with WindowListen } return KeyEventResult.handled; } - // If controls are shown, let the event propagate to the focused control - // The DesktopVideoControls will handle navigation - return KeyEventResult.ignored; + // Children (DesktopVideoControls) handle navigation first via their own onKeyEvent. + // If we reach here, children already declined the event — consume it to prevent leaking. + return KeyEventResult.handled; } // Pass other events to the keyboard shortcuts service - if (_keyboardService == null) return KeyEventResult.ignored; + if (_keyboardService == null) return KeyEventResult.handled; final result = _keyboardService!.handleVideoPlayerKeyEvent( event, @@ -1445,7 +1446,8 @@ class _PlexVideoControlsState extends State with WindowListen _previousChapter, onBack: widget.onBack ?? () => Navigator.of(context).pop(true), ); - return result; + // Never return .ignored from fullscreen video — prevent leaking to previous routes + return result == KeyEventResult.ignored ? KeyEventResult.handled : result; }, child: Listener( behavior: HitTestBehavior.translucent, diff --git a/lib/widgets/video_controls/widgets/circular_control_button.dart b/lib/widgets/video_controls/widgets/circular_control_button.dart new file mode 100644 index 00000000..199e128f --- /dev/null +++ b/lib/widgets/video_controls/widgets/circular_control_button.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:plezy/widgets/app_icon.dart'; + +/// A circular semi-transparent button used in the mobile video controls. +/// +/// Renders an [AppIcon] inside an [IconButton] on a black circle with 50% +/// opacity. Disabled buttons grey out the icon. +class CircularControlButton extends StatelessWidget { + final String semanticLabel; + final IconData icon; + final double iconSize; + final VoidCallback? onPressed; + + const CircularControlButton({ + super.key, + required this.semanticLabel, + required this.icon, + required this.iconSize, + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + final isEnabled = onPressed != null; + return Container( + decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.5), shape: BoxShape.circle), + child: Semantics( + label: semanticLabel, + button: true, + excludeSemantics: true, + child: IconButton( + icon: AppIcon( + icon, + fill: 1, + color: isEnabled ? Colors.white : Colors.white.withValues(alpha: 0.3), + size: iconSize, + ), + iconSize: iconSize, + onPressed: onPressed, + ), + ), + ); + } +} diff --git a/lib/widgets/video_controls/widgets/first_frame_guard.dart b/lib/widgets/video_controls/widgets/first_frame_guard.dart new file mode 100644 index 00000000..6f105916 --- /dev/null +++ b/lib/widgets/video_controls/widgets/first_frame_guard.dart @@ -0,0 +1,34 @@ +import 'package:flutter/widgets.dart'; + +/// Guards child content behind a [ValueNotifier] that signals +/// whether the first video frame has rendered. +/// +/// Shows [placeholder] (defaults to `SizedBox.shrink()`) until the +/// notifier emits `true`, then renders the [builder] result. +/// If [hasFirstFrame] is null, the builder is rendered immediately. +class FirstFrameGuard extends StatelessWidget { + final ValueNotifier? hasFirstFrame; + final WidgetBuilder builder; + final Widget placeholder; + + const FirstFrameGuard({ + super.key, + required this.hasFirstFrame, + required this.builder, + this.placeholder = const SizedBox.shrink(), + }); + + @override + Widget build(BuildContext context) { + final notifier = hasFirstFrame; + if (notifier == null) return builder(context); + + return ValueListenableBuilder( + valueListenable: notifier, + builder: (context, hasFrame, child) { + if (!hasFrame) return placeholder; + return builder(context); + }, + ); + } +} diff --git a/lib/widgets/video_controls/widgets/play_pause_stream_builder.dart b/lib/widgets/video_controls/widgets/play_pause_stream_builder.dart new file mode 100644 index 00000000..d6f299b7 --- /dev/null +++ b/lib/widgets/video_controls/widgets/play_pause_stream_builder.dart @@ -0,0 +1,24 @@ +import 'package:flutter/widgets.dart'; + +import '../../../mpv/mpv.dart'; + +/// Subscribes to [Player.streams.playing] and exposes the current +/// play/pause state plus the matching icon to a [builder]. +class PlayPauseStreamBuilder extends StatelessWidget { + final Player player; + final Widget Function(BuildContext context, bool isPlaying) builder; + + const PlayPauseStreamBuilder({super.key, required this.player, required this.builder}); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: player.streams.playing, + initialData: player.state.playing, + builder: (context, snapshot) { + final isPlaying = snapshot.data ?? false; + return builder(context, isPlaying); + }, + ); + } +} diff --git a/lib/widgets/video_controls/widgets/video_controls_header.dart b/lib/widgets/video_controls/widgets/video_controls_header.dart index 5b09ffee..3083fa62 100644 --- a/lib/widgets/video_controls/widgets/video_controls_header.dart +++ b/lib/widgets/video_controls/widgets/video_controls_header.dart @@ -60,12 +60,8 @@ class VideoControlsHeader extends StatelessWidget { List parts = [seriesName]; if (hasEpisodeInfo) { - parts.add('S${metadata.parentIndex}'); - parts.add('E${metadata.index}'); - } - - if (metadata.duration != null) { - parts.add(formatDurationTextual(metadata.duration!)); + parts.add('S${metadata.parentIndex}E${metadata.index}'); + parts.add(metadata.title); } return Text(