diff --git a/lib/models/plex_metadata.dart b/lib/models/plex_metadata.dart index e83aacfa..fc90a793 100644 --- a/lib/models/plex_metadata.dart +++ b/lib/models/plex_metadata.dart @@ -327,8 +327,7 @@ class PlexMetadata with MultiServerFields { } // For movies/shows in mixed hub context with episode thumbnail mode, use art (16:9) - if (mixedHubContext && mode == EpisodePosterMode.episodeThumbnail && - (itemType == 'movie' || itemType == 'show')) { + if (mixedHubContext && mode == EpisodePosterMode.episodeThumbnail && (itemType == 'movie' || itemType == 'show')) { return art ?? thumb; } @@ -344,7 +343,8 @@ class PlexMetadata with MultiServerFields { return true; } // Movies, shows, and seasons use 16:9 in mixed hubs with episode thumbnail mode - if (mixedHubContext && mode == EpisodePosterMode.episodeThumbnail && + if (mixedHubContext && + mode == EpisodePosterMode.episodeThumbnail && (itemType == 'movie' || itemType == 'show' || itemType == 'season')) { return true; } diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index fbb336fe..2a2345b2 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -46,7 +46,13 @@ class DiscoverScreen extends StatefulWidget { } class _DiscoverScreenState extends State - with Refreshable, FullRefreshable, ItemUpdatable, WatchStateAware, SingleTickerProviderStateMixin, WidgetsBindingObserver { + with + Refreshable, + FullRefreshable, + ItemUpdatable, + WatchStateAware, + SingleTickerProviderStateMixin, + WidgetsBindingObserver { static const Duration _heroAutoScrollDuration = Duration(seconds: 8); @override diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index f43969a2..8dbd9120 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -253,10 +253,8 @@ class _HubDetailScreenState extends State with Refreshable { final episodePosterMode = context.watch().episodePosterMode; // Determine hub content type for layout decisions - final hasEpisodes = _filteredItems.any((item) => - item.usesWideAspectRatio(episodePosterMode)); - final hasNonEpisodes = _filteredItems.any((item) => - !item.usesWideAspectRatio(episodePosterMode)); + final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode)); + final hasNonEpisodes = _filteredItems.any((item) => !item.usesWideAspectRatio(episodePosterMode)); // Mixed hub = has both episodes AND non-episodes final isMixedHub = hasEpisodes && hasNonEpisodes; @@ -265,8 +263,8 @@ class _HubDetailScreenState extends State with Refreshable { final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes; // Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode) - final useWideLayout = episodePosterMode == EpisodePosterMode.episodeThumbnail && - (isEpisodeOnlyHub || isMixedHub); + final useWideLayout = + episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub); return MediaGridSliver( items: _filteredItems, diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 2c685fa2..f9bac924 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -1135,9 +1135,7 @@ class _LibrariesScreenState extends State controller: _tabController, // Disable swipe on desktop - trackpad scrolling triggers accidental tab switches // See: https://github.com/flutter/flutter/issues/11132 - physics: PlatformDetector.isDesktop(context) - ? const NeverScrollableScrollPhysics() - : null, + physics: PlatformDetector.isDesktop(context) ? const NeverScrollableScrollPhysics() : null, children: [ LibraryRecommendedTab( key: _recommendedTabKey, @@ -1235,11 +1233,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { final key = _tileKeys[_focusedIndex]; final context = key?.currentContext; if (context != null) { - Scrollable.ensureVisible( - context, - alignment: 0.25, - duration: const Duration(milliseconds: 200), - ); + Scrollable.ensureVisible(context, alignment: 0.25, duration: const Duration(milliseconds: 200)); } }); } @@ -1442,10 +1436,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { ), automaticallyImplyLeading: false, actions: [ - IconButton( - icon: const AppIcon(Symbols.close_rounded, fill: 1), - onPressed: () => Navigator.pop(context), - ), + IconButton(icon: const AppIcon(Symbols.close_rounded, fill: 1), onPressed: () => Navigator.pop(context)), ], ), body: Focus( diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index ab41f21f..50314c9e 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -48,10 +48,7 @@ class DataAggregationService { /// Fetch "On Deck" (Continue Watching) from all servers and merge by recency /// Items are automatically tagged with server info by PlexClient - Future> getOnDeckFromAllServers({ - int? limit, - Set? hiddenLibraryKeys, - }) async { + Future> getOnDeckFromAllServers({int? limit, Set? hiddenLibraryKeys}) async { final allOnDeck = await _perServer( operationName: 'fetching on deck', operation: (serverId, client, server) async { @@ -162,29 +159,32 @@ class DataAggregationService { // Filter out items from hidden libraries if specified if (hiddenLibraryKeys != null && hiddenLibraryKeys.isNotEmpty) { - return hubs.map((hub) { - final filteredItems = hub.items.where((item) { - // Build the global key for the item's library section - final librarySectionId = item.librarySectionID; - if (librarySectionId == null) return true; // Keep if no section ID - final globalKey = '$serverId:$librarySectionId'; - return !hiddenLibraryKeys.contains(globalKey); - }).toList(); + return hubs + .map((hub) { + final filteredItems = hub.items.where((item) { + // Build the global key for the item's library section + final librarySectionId = item.librarySectionID; + if (librarySectionId == null) return true; // Keep if no section ID + final globalKey = '$serverId:$librarySectionId'; + return !hiddenLibraryKeys.contains(globalKey); + }).toList(); - if (filteredItems.isEmpty) return null; + if (filteredItems.isEmpty) return null; - return PlexHub( - hubKey: hub.hubKey, - title: hub.title, - type: hub.type, - hubIdentifier: hub.hubIdentifier, - size: filteredItems.length, - more: hub.more, - items: filteredItems, - serverId: hub.serverId, - serverName: hub.serverName, - ); - }).whereType().toList(); + return PlexHub( + hubKey: hub.hubKey, + title: hub.title, + type: hub.type, + hubIdentifier: hub.hubIdentifier, + size: filteredItems.length, + more: hub.more, + items: filteredItems, + serverId: hub.serverId, + serverName: hub.serverName, + ); + }) + .whereType() + .toList(); } return hubs; diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 4310d9c8..e1401dd9 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -360,10 +360,7 @@ class PlexClient { /// /// When [forceRefresh] is true, bypasses cache to get fresh OnDeck data. /// Use this when cross-device sync is needed (e.g., after app resume). - Future> getMetadataWithImagesAndOnDeck( - String ratingKey, { - bool forceRefresh = false, - }) async { + Future> getMetadataWithImagesAndOnDeck(String ratingKey, {bool forceRefresh = false}) async { // Cache key is always the base endpoint (no query params) final cacheKey = '/library/metadata/$ratingKey'; @@ -1265,10 +1262,7 @@ class PlexClient { /// This matches the official Plex client's home page layout. Future> getGlobalHubs({int limit = 10}) async { try { - final response = await _dio.get( - '/hubs', - queryParameters: {'count': limit, 'includeGuids': 1}, - ); + final response = await _dio.get('/hubs', queryParameters: {'count': limit, 'includeGuids': 1}); final container = _getMediaContainer(response); if (container != null && container['Hub'] != null) { diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index 78ca2424..4e2dc65d 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -382,11 +382,7 @@ class _SessionCodeRow extends StatelessWidget { ), ), const SizedBox(width: 4), - Icon( - Symbols.content_copy_rounded, - size: 14, - color: theme.colorScheme.onSurfaceVariant, - ), + Icon(Symbols.content_copy_rounded, size: 14, color: theme.colorScheme.onSurfaceVariant), ], ), ), @@ -395,8 +391,6 @@ class _SessionCodeRow extends StatelessWidget { void _copySessionCode(BuildContext context) { Clipboard.setData(ClipboardData(text: sessionId)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.watchTogether.sessionCodeCopied)), - ); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.watchTogether.sessionCodeCopied))); } } diff --git a/lib/watch_together/widgets/watch_together_overlay.dart b/lib/watch_together/widgets/watch_together_overlay.dart index 71bee8f8..edd6c6b0 100644 --- a/lib/watch_together/widgets/watch_together_overlay.dart +++ b/lib/watch_together/widgets/watch_together_overlay.dart @@ -186,9 +186,7 @@ class _SessionMenuSheet extends StatelessWidget { children: [ Text( '${t.watchTogether.sessionCode}: ', - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurfaceVariant, - ), + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), Text( provider.sessionId!, @@ -198,11 +196,7 @@ class _SessionMenuSheet extends StatelessWidget { ), ), const SizedBox(width: 8), - Icon( - Symbols.content_copy_rounded, - size: 16, - color: theme.colorScheme.onSurfaceVariant, - ), + Icon(Symbols.content_copy_rounded, size: 16, color: theme.colorScheme.onSurfaceVariant), ], ), ), @@ -261,9 +255,7 @@ class _SessionMenuSheet extends StatelessWidget { void _copySessionCode(BuildContext context, String sessionId) { Clipboard.setData(ClipboardData(text: sessionId)); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(t.watchTogether.sessionCodeCopied)), - ); + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(t.watchTogether.sessionCodeCopied))); } void _confirmLeave(BuildContext context) { diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index f83a6381..dd15230b 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -274,7 +274,13 @@ class _MediaCardGrid extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(tokens(context).radiusSm), - child: _buildPosterImage(context, item, isOffline: isOffline, localPosterPath: localPosterPath, mixedHubContext: mixedHubContext), + child: _buildPosterImage( + context, + item, + isOffline: isOffline, + localPosterPath: localPosterPath, + mixedHubContext: mixedHubContext, + ), ), _PosterOverlay(item: item), ], @@ -571,7 +577,13 @@ class _MediaCardList extends StatelessWidget { } } -Widget _buildPosterImage(BuildContext context, dynamic item, {bool isOffline = false, String? localPosterPath, bool mixedHubContext = false}) { +Widget _buildPosterImage( + BuildContext context, + dynamic item, { + bool isOffline = false, + String? localPosterPath, + bool mixedHubContext = false, +}) { String? posterUrl; IconData fallbackIcon = Symbols.movie_rounded; diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index f0728e85..3ef69851 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -1283,7 +1283,8 @@ class _PlexVideoControlsState extends State with WindowListen children: [ // Keep-alive: 1px widget that continuously repaints to prevent // Flutter animations from freezing when the frame clock goes idle - if (Platform.isLinux || Platform.isWindows) const Positioned(top: 0, left: 0, child: _LinuxKeepAlive()), + if (Platform.isLinux || Platform.isWindows) + const Positioned(top: 0, left: 0, child: _LinuxKeepAlive()), // Invisible tap detector that always covers the full area // Also handles long-press for 2x speed Positioned.fill( @@ -1389,119 +1390,119 @@ class _PlexVideoControlsState extends State with WindowListen opacity: _showControls ? 1.0 : 0.0, duration: const Duration(milliseconds: 200), child: LayoutBuilder( - builder: (context, constraints) { - return GestureDetector( - onTapUp: (details) => _handleControlsOverlayTap(details, constraints), - onLongPressStart: (_) => _handleLongPressStart(), - onLongPressEnd: (_) => _handleLongPressEnd(), - onLongPressCancel: _handleLongPressCancel, - behavior: HitTestBehavior.deferToChild, - child: ValueListenableBuilder( - valueListenable: widget.hasFirstFrame ?? ValueNotifier(true), - builder: (context, hasFrame, child) { - return Container( - decoration: BoxDecoration( - // Use solid black when loading, gradient when loaded - color: hasFrame ? null : Colors.black, - gradient: hasFrame - ? LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withValues(alpha: 0.7), - Colors.transparent, - Colors.transparent, - Colors.black.withValues(alpha: 0.7), - ], - stops: const [0.0, 0.2, 0.8, 1.0], - ) - : null, - ), - child: child, - ); - }, - child: isMobile - ? Listener( - behavior: HitTestBehavior.translucent, - onPointerDown: (_) => _restartHideTimerIfPlaying(), - child: MobileVideoControls( - player: widget.player, - metadata: widget.metadata, - chapters: _chapters, - chaptersLoaded: _chaptersLoaded, - seekTimeSmall: _seekTimeSmall, - trackChapterControls: _buildTrackChapterControlsWidget(), - onSeek: _throttledSeek, - onSeekEnd: _finalizeSeek, - onSeekCompleted: widget.onSeekCompleted, - onPlayPause: () {}, // Not used, handled internally - onCancelAutoHide: () => _hideTimer?.cancel(), - onStartAutoHide: _startHideTimer, - onBack: widget.onBack, - onNext: widget.onNext, - onPrevious: widget.onPrevious, - canControl: widget.canControl, - hasFirstFrame: widget.hasFirstFrame, - ), - ) - : Listener( - behavior: HitTestBehavior.translucent, - onPointerDown: (_) => _restartHideTimerIfPlaying(), - child: DesktopVideoControls( - key: _desktopControlsKey, - player: widget.player, - metadata: widget.metadata, - onNext: widget.onNext, - onPrevious: widget.onPrevious, - chapters: _chapters, - chaptersLoaded: _chaptersLoaded, - seekTimeSmall: _seekTimeSmall, - onSeekToPreviousChapter: _seekToPreviousChapter, - onSeekToNextChapter: _seekToNextChapter, - onSeek: _throttledSeek, - onSeekEnd: _finalizeSeek, - getReplayIcon: getReplayIcon, - getForwardIcon: getForwardIcon, - onFocusActivity: _restartHideTimerIfPlaying, - onHideControls: _hideControlsFromKeyboard, - // Track chapter controls data - availableVersions: widget.availableVersions, - selectedMediaIndex: widget.selectedMediaIndex, - boxFitMode: widget.boxFitMode, - audioSyncOffset: _audioSyncOffset, - subtitleSyncOffset: _subtitleSyncOffset, - isFullscreen: _isFullscreen, - isAlwaysOnTop: _isAlwaysOnTop, - onTogglePIPMode: (_isPipSupported && Platform.isAndroid) - ? widget.onTogglePIPMode - : null, - onCycleBoxFitMode: widget.onCycleBoxFitMode, - onToggleFullscreen: _toggleFullscreen, - onToggleAlwaysOnTop: _toggleAlwaysOnTop, - onSwitchVersion: _switchMediaVersion, - onAudioTrackChanged: widget.onAudioTrackChanged, - onSubtitleTrackChanged: widget.onSubtitleTrackChanged, - onLoadSeekTimes: () async { - if (mounted) { - await _loadSeekTimes(); - } - }, - onCancelAutoHide: () => _hideTimer?.cancel(), - onStartAutoHide: _startHideTimer, - serverId: widget.metadata.serverId ?? '', - onBack: widget.onBack, - canControl: widget.canControl, - hasFirstFrame: widget.hasFirstFrame, - ), + builder: (context, constraints) { + return GestureDetector( + onTapUp: (details) => _handleControlsOverlayTap(details, constraints), + onLongPressStart: (_) => _handleLongPressStart(), + onLongPressEnd: (_) => _handleLongPressEnd(), + onLongPressCancel: _handleLongPressCancel, + behavior: HitTestBehavior.deferToChild, + child: ValueListenableBuilder( + valueListenable: widget.hasFirstFrame ?? ValueNotifier(true), + builder: (context, hasFrame, child) { + return Container( + decoration: BoxDecoration( + // Use solid black when loading, gradient when loaded + color: hasFrame ? null : Colors.black, + gradient: hasFrame + ? LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.7), + Colors.transparent, + Colors.transparent, + Colors.black.withValues(alpha: 0.7), + ], + stops: const [0.0, 0.2, 0.8, 1.0], + ) + : null, + ), + child: child, + ); + }, + child: isMobile + ? Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) => _restartHideTimerIfPlaying(), + child: MobileVideoControls( + player: widget.player, + metadata: widget.metadata, + chapters: _chapters, + chaptersLoaded: _chaptersLoaded, + seekTimeSmall: _seekTimeSmall, + trackChapterControls: _buildTrackChapterControlsWidget(), + onSeek: _throttledSeek, + onSeekEnd: _finalizeSeek, + onSeekCompleted: widget.onSeekCompleted, + onPlayPause: () {}, // Not used, handled internally + onCancelAutoHide: () => _hideTimer?.cancel(), + onStartAutoHide: _startHideTimer, + onBack: widget.onBack, + onNext: widget.onNext, + onPrevious: widget.onPrevious, + canControl: widget.canControl, + hasFirstFrame: widget.hasFirstFrame, ), - ), - ); - }, - ), + ) + : Listener( + behavior: HitTestBehavior.translucent, + onPointerDown: (_) => _restartHideTimerIfPlaying(), + child: DesktopVideoControls( + key: _desktopControlsKey, + player: widget.player, + metadata: widget.metadata, + onNext: widget.onNext, + onPrevious: widget.onPrevious, + chapters: _chapters, + chaptersLoaded: _chaptersLoaded, + seekTimeSmall: _seekTimeSmall, + onSeekToPreviousChapter: _seekToPreviousChapter, + onSeekToNextChapter: _seekToNextChapter, + onSeek: _throttledSeek, + onSeekEnd: _finalizeSeek, + getReplayIcon: getReplayIcon, + getForwardIcon: getForwardIcon, + onFocusActivity: _restartHideTimerIfPlaying, + onHideControls: _hideControlsFromKeyboard, + // Track chapter controls data + availableVersions: widget.availableVersions, + selectedMediaIndex: widget.selectedMediaIndex, + boxFitMode: widget.boxFitMode, + audioSyncOffset: _audioSyncOffset, + subtitleSyncOffset: _subtitleSyncOffset, + isFullscreen: _isFullscreen, + isAlwaysOnTop: _isAlwaysOnTop, + onTogglePIPMode: (_isPipSupported && Platform.isAndroid) + ? widget.onTogglePIPMode + : null, + onCycleBoxFitMode: widget.onCycleBoxFitMode, + onToggleFullscreen: _toggleFullscreen, + onToggleAlwaysOnTop: _toggleAlwaysOnTop, + onSwitchVersion: _switchMediaVersion, + onAudioTrackChanged: widget.onAudioTrackChanged, + onSubtitleTrackChanged: widget.onSubtitleTrackChanged, + onLoadSeekTimes: () async { + if (mounted) { + await _loadSeekTimes(); + } + }, + onCancelAutoHide: () => _hideTimer?.cancel(), + onStartAutoHide: _startHideTimer, + serverId: widget.metadata.serverId ?? '', + onBack: widget.onBack, + canControl: widget.canControl, + hasFirstFrame: widget.hasFirstFrame, + ), + ), + ), + ); + }, ), ), ), ), + ), // Visual feedback overlay for double-tap if (isMobile && _showDoubleTapFeedback) Positioned.fill(