diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index fb6acca9..9f8877db 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -10,6 +10,19 @@ enum PlaybackMode { playQueue, // Play queue-based playback (playlists, collections, shuffle) } +/// Result of trying to locate the current queue index. +class _IndexLookupResult { + final int? index; + final bool attemptedLoad; + final bool loadFailed; + + const _IndexLookupResult({ + this.index, + this.attemptedLoad = false, + this.loadFailed = false, + }); +} + /// Manages playback state using Plex's play queue API. /// This provider is session-only and does not persist across app restarts. class PlaybackStateProvider with ChangeNotifier { @@ -158,6 +171,43 @@ class PlaybackStateProvider with ChangeNotifier { return false; } + Future<_IndexLookupResult> _getCurrentIndex({ + bool loadIfMissing = false, + }) async { + if (_playbackMode != PlaybackMode.playQueue || + _loadedItems.isEmpty || + _currentPlayQueueItemID == null) { + return const _IndexLookupResult(); + } + + var currentIndex = _loadedItems.indexWhere( + (item) => item.playQueueItemID == _currentPlayQueueItemID, + ); + + if (currentIndex != -1) { + return _IndexLookupResult(index: currentIndex); + } + + if (!loadIfMissing || _client == null || _playQueueId == null) { + return const _IndexLookupResult(); + } + + final loaded = await _ensureItemsLoaded(_currentPlayQueueItemID!); + if (!loaded) { + return const _IndexLookupResult(attemptedLoad: true, loadFailed: true); + } + + currentIndex = _loadedItems.indexWhere( + (item) => item.playQueueItemID == _currentPlayQueueItemID, + ); + + if (currentIndex == -1) { + return const _IndexLookupResult(attemptedLoad: true, loadFailed: true); + } + + return _IndexLookupResult(index: currentIndex, attemptedLoad: true); + } + /// Gets the next item in the playback queue. /// Returns null if queue is exhausted or current item is not in queue. /// [loopQueue] - If true, restart from beginning when queue is exhausted @@ -170,23 +220,14 @@ class PlaybackStateProvider with ChangeNotifier { return null; } - if (_loadedItems.isEmpty || _currentPlayQueueItemID == null) return null; - - // Find current item in loaded items - final currentIndex = _loadedItems.indexWhere( - (item) => item.playQueueItemID == _currentPlayQueueItemID, - ); - - if (currentIndex == -1) { - // Current item not in loaded window, try to load it - final loaded = await _ensureItemsLoaded(_currentPlayQueueItemID!); - if (!loaded) { + final indexResult = await _getCurrentIndex(loadIfMissing: true); + if (indexResult.index == null) { + if (indexResult.loadFailed) { clearShuffle(); - return null; } - // Try again after loading - return getNextEpisode(currentItemKey, loopQueue: loopQueue); + return null; } + final currentIndex = indexResult.index!; // Check if there's a next item in the loaded window if (currentIndex + 1 < _loadedItems.length) { @@ -240,17 +281,8 @@ class PlaybackStateProvider with ChangeNotifier { return null; } - if (_loadedItems.isEmpty || _currentPlayQueueItemID == null) return null; - - // Find current item in loaded items - final currentIndex = _loadedItems.indexWhere( - (item) => item.playQueueItemID == _currentPlayQueueItemID, - ); - - if (currentIndex == -1) { - // Current item not in loaded window - return null; - } + final currentIndex = (await _getCurrentIndex()).index; + if (currentIndex == null) return null; // Check if there's a previous item in the loaded window if (currentIndex > 0) { diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 5e3fd5a4..993adc10 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -8,6 +8,7 @@ import '../providers/settings_provider.dart'; import '../services/settings_service.dart'; import '../utils/provider_extensions.dart'; import '../utils/app_logger.dart'; +import '../utils/grid_cross_axis_extent.dart'; import '../widgets/media_card.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/sort_bottom_sheet.dart'; @@ -293,9 +294,10 @@ class _HubDetailScreenState extends State with Refreshable { padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: _getMaxCrossAxisExtent( + maxCrossAxisExtent: getMaxCrossAxisExtentWithPadding( context, context.watch().libraryDensity, + 16, ), childAspectRatio: 2 / 3.3, crossAxisSpacing: 0, @@ -313,49 +315,4 @@ class _HubDetailScreenState extends State with Refreshable { ), ); } - - double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { - final screenWidth = MediaQuery.of(context).size.width; - final padding = 16.0; // 8px left + 8px right - final availableWidth = screenWidth - padding; - - if (screenWidth >= 900) { - // Wide screens (desktop/large tablet landscape): Responsive division - double divisor; - double maxItemWidth; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 6.5; - maxItemWidth = 280; - break; - case LibraryDensity.normal: - divisor = 8.0; - maxItemWidth = 200; - break; - case LibraryDensity.compact: - divisor = 10.0; - maxItemWidth = 160; - break; - } - - return (availableWidth / divisor).clamp(0, maxItemWidth); - } else if (screenWidth >= 600) { - // Medium screens (tablets): Fixed 4-5-6 items - int targetItemCount = switch (density) { - LibraryDensity.comfortable => 4, - LibraryDensity.normal => 5, - LibraryDensity.compact => 6, - }; - return availableWidth / targetItemCount; - } else { - // Small screens (phones): Fixed 2-3-4 items - int targetItemCount = switch (density) { - LibraryDensity.comfortable => 2, - LibraryDensity.normal => 3, - LibraryDensity.compact => 4, - }; - return availableWidth / targetItemCount; - } - } } diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 2764664e..7b181dbb 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -11,6 +11,7 @@ import '../models/plex_metadata.dart'; import '../providers/settings_provider.dart'; import '../services/settings_service.dart'; import '../utils/app_logger.dart'; +import '../utils/grid_cross_axis_extent.dart'; import '../utils/provider_extensions.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/media_card.dart'; @@ -253,9 +254,10 @@ class _SearchScreenState extends State padding: const EdgeInsets.all(16), sliver: SliverGrid( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: _getMaxCrossAxisExtent( + maxCrossAxisExtent: getMaxCrossAxisExtentWithPadding( context, settingsProvider.libraryDensity, + 32, ), childAspectRatio: 2 / 3.3, crossAxisSpacing: 8, @@ -279,49 +281,4 @@ class _SearchScreenState extends State ), ); } - - double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) { - final screenWidth = MediaQuery.of(context).size.width; - final padding = 32.0; // 16px left + 16px right from SliverPadding - final availableWidth = screenWidth - padding; - - if (screenWidth >= 900) { - // Wide screens (desktop/large tablet landscape): Responsive division - double divisor; - double maxItemWidth; - - switch (density) { - case LibraryDensity.comfortable: - divisor = 6.5; - maxItemWidth = 280; - break; - case LibraryDensity.normal: - divisor = 8.0; - maxItemWidth = 200; - break; - case LibraryDensity.compact: - divisor = 10.0; - maxItemWidth = 160; - break; - } - - return (availableWidth / divisor).clamp(0, maxItemWidth); - } else if (screenWidth >= 600) { - // Medium screens (tablets): Fixed 4-5-6 items - int targetItemCount = switch (density) { - LibraryDensity.comfortable => 4, - LibraryDensity.normal => 5, - LibraryDensity.compact => 6, - }; - return availableWidth / targetItemCount; - } else { - // Small screens (phones): Fixed 2-3-4 items - int targetItemCount = switch (density) { - LibraryDensity.comfortable => 2, - LibraryDensity.normal => 3, - LibraryDensity.compact => 4, - }; - return availableWidth / targetItemCount; - } - } } diff --git a/lib/theme/mono_theme.dart b/lib/theme/mono_theme.dart index 78710145..02e46b7e 100644 --- a/lib/theme/mono_theme.dart +++ b/lib/theme/mono_theme.dart @@ -19,6 +19,16 @@ ThemeData monoTheme({required bool dark}) { textMuted: const Color(0x99111111), ); + final buttonStyle = ButtonStyle( + padding: const WidgetStatePropertyAll( + EdgeInsets.symmetric(horizontal: 18, vertical: 14), + ), + elevation: const WidgetStatePropertyAll(0), + backgroundColor: WidgetStatePropertyAll(c.text), + foregroundColor: WidgetStatePropertyAll(dark ? c.bg : Colors.white), + shape: const WidgetStatePropertyAll(StadiumBorder()), + ); + final base = ThemeData( useMaterial3: true, brightness: dark ? Brightness.dark : Brightness.light, @@ -103,28 +113,8 @@ ThemeData monoTheme({required bool dark}) { ), hintStyle: TextStyle(color: c.textMuted), ), - elevatedButtonTheme: ElevatedButtonThemeData( - style: ButtonStyle( - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: 18, vertical: 14), - ), - elevation: const WidgetStatePropertyAll(0), - backgroundColor: WidgetStatePropertyAll(c.text), - foregroundColor: WidgetStatePropertyAll(dark ? c.bg : Colors.white), - shape: const WidgetStatePropertyAll(StadiumBorder()), - ), - ), - filledButtonTheme: FilledButtonThemeData( - style: ButtonStyle( - padding: const WidgetStatePropertyAll( - EdgeInsets.symmetric(horizontal: 18, vertical: 14), - ), - elevation: const WidgetStatePropertyAll(0), - backgroundColor: WidgetStatePropertyAll(c.text), - foregroundColor: WidgetStatePropertyAll(dark ? c.bg : Colors.white), - shape: const WidgetStatePropertyAll(StadiumBorder()), - ), - ), + elevatedButtonTheme: ElevatedButtonThemeData(style: buttonStyle), + filledButtonTheme: FilledButtonThemeData(style: buttonStyle), dividerTheme: DividerThemeData(space: 0, thickness: 1, color: c.outline), listTileTheme: ListTileThemeData( dense: true, diff --git a/lib/utils/duration_formatter.dart b/lib/utils/duration_formatter.dart index afe5a8d8..698b612f 100644 --- a/lib/utils/duration_formatter.dart +++ b/lib/utils/duration_formatter.dart @@ -63,6 +63,15 @@ String formatDurationTimestamp(Duration duration) { } } +/// Formats a sync offset in milliseconds with sign indicator (e.g., "+150ms", "-250ms"). +/// This format is used for audio/subtitle synchronization adjustments. +/// +/// Used for: audio sync sheet, sync offset controls. +String formatSyncOffset(double offsetMs) { + final sign = offsetMs >= 0 ? '+' : ''; + return '$sign${offsetMs.round()}ms'; +} + /// Gets the duration package locale based on the current app locale. /// Falls back to English if the locale is not supported by the duration package. DurationLocale _getDurationLocale() { diff --git a/lib/utils/grid_cross_axis_extent.dart b/lib/utils/grid_cross_axis_extent.dart new file mode 100644 index 00000000..812d010f --- /dev/null +++ b/lib/utils/grid_cross_axis_extent.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import '../services/settings_service.dart'; + +/// Calculates the max cross-axis extent for grid items, accounting for outer padding. +double getMaxCrossAxisExtentWithPadding( + BuildContext context, + LibraryDensity density, + double horizontalPadding, +) { + final screenWidth = MediaQuery.of(context).size.width; + final availableWidth = screenWidth - horizontalPadding; + + if (screenWidth >= 900) { + // Wide screens (desktop/large tablet landscape): Responsive division + double divisor; + double maxItemWidth; + + switch (density) { + case LibraryDensity.comfortable: + divisor = 6.5; + maxItemWidth = 280; + break; + case LibraryDensity.normal: + divisor = 8.0; + maxItemWidth = 200; + break; + case LibraryDensity.compact: + divisor = 10.0; + maxItemWidth = 160; + break; + } + + return (availableWidth / divisor).clamp(0, maxItemWidth); + } else if (screenWidth >= 600) { + // Medium screens (tablets): Fixed 4-5-6 items + int targetItemCount = switch (density) { + LibraryDensity.comfortable => 4, + LibraryDensity.normal => 5, + LibraryDensity.compact => 6, + }; + return availableWidth / targetItemCount; + } else { + // Small screens (phones): Fixed 2-3-4 items + int targetItemCount = switch (density) { + LibraryDensity.comfortable => 2, + LibraryDensity.normal => 3, + LibraryDensity.compact => 4, + }; + return availableWidth / targetItemCount; + } +} diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index e9659b47..94b54cb2 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -323,62 +323,12 @@ class _MediaCardGrid extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(8), - child: _buildPosterImage(context), + child: _buildPosterImage(context, item), ), _PosterOverlay(item: item), ], ); } - - Widget _buildPosterImage(BuildContext context) { - String? posterUrl; - IconData fallbackIcon = Icons.movie; - - if (item is PlexPlaylist) { - posterUrl = (item as PlexPlaylist).displayImage; - fallbackIcon = Icons.playlist_play; - } else if (item is PlexMetadata) { - final useSeasonPoster = context.watch().useSeasonPoster; - posterUrl = (item as PlexMetadata).posterThumb( - useSeasonPoster: useSeasonPoster, - ); - } - - if (posterUrl != null) { - return Consumer( - builder: (context, clientProvider, child) { - final client = clientProvider.client; - if (client == null) { - return SkeletonLoader( - child: Center( - child: Icon(fallbackIcon, size: 40, color: Colors.white54), - ), - ); - } - - return CachedNetworkImage( - imageUrl: client.getThumbnailUrl(posterUrl!), - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - filterQuality: FilterQuality.medium, - fadeInDuration: const Duration(milliseconds: 300), - placeholder: (context, url) => const SkeletonLoader(), - errorWidget: (context, url, error) => Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: Center(child: Icon(fallbackIcon, size: 40)), - ), - ); - }, - ); - } else { - return SkeletonLoader( - child: Center( - child: Icon(fallbackIcon, size: 40, color: Colors.white54), - ), - ); - } - } } /// List layout for media cards @@ -442,14 +392,8 @@ class _MediaCardList extends StatelessWidget { } double get _summaryFontSize { - switch (density) { - case LibraryDensity.compact: - return 11; - case LibraryDensity.normal: - return 12; - case LibraryDensity.comfortable: - return 13; - } + // Summary uses the same sizing as metadata text + return _metadataFontSize; } int get _summaryMaxLines { @@ -576,7 +520,7 @@ class _MediaCardList extends StatelessWidget { children: [ ClipRRect( borderRadius: BorderRadius.circular(8), - child: _buildPosterImage(context), + child: _buildPosterImage(context, item), ), _PosterOverlay(item: item), ], @@ -656,55 +600,53 @@ class _MediaCardList extends StatelessWidget { ), ); } +} - Widget _buildPosterImage(BuildContext context) { - String? posterUrl; - IconData fallbackIcon = Icons.movie; +Widget _buildPosterImage(BuildContext context, dynamic item) { + String? posterUrl; + IconData fallbackIcon = Icons.movie; - if (item is PlexPlaylist) { - posterUrl = (item as PlexPlaylist).displayImage; - fallbackIcon = Icons.playlist_play; - } else if (item is PlexMetadata) { - final useSeasonPoster = context.watch().useSeasonPoster; - posterUrl = (item as PlexMetadata).posterThumb( - useSeasonPoster: useSeasonPoster, - ); - } + if (item is PlexPlaylist) { + posterUrl = (item as PlexPlaylist).displayImage; + fallbackIcon = Icons.playlist_play; + } else if (item is PlexMetadata) { + final useSeasonPoster = context.watch().useSeasonPoster; + posterUrl = (item as PlexMetadata).posterThumb( + useSeasonPoster: useSeasonPoster, + ); + } - if (posterUrl != null) { - return Consumer( - builder: (context, clientProvider, child) { - final client = clientProvider.client; - if (client == null) { - return SkeletonLoader( - child: Center( - child: Icon(fallbackIcon, size: 40, color: Colors.white54), - ), - ); - } - - return CachedNetworkImage( - imageUrl: client.getThumbnailUrl(posterUrl!), - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - filterQuality: FilterQuality.medium, - fadeInDuration: const Duration(milliseconds: 300), - placeholder: (context, url) => const SkeletonLoader(), - errorWidget: (context, url, error) => Container( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: Center(child: Icon(fallbackIcon, size: 40)), + if (posterUrl != null) { + return Consumer( + builder: (context, clientProvider, child) { + final client = clientProvider.client; + if (client == null) { + return SkeletonLoader( + child: Center( + child: Icon(fallbackIcon, size: 40, color: Colors.white54), ), ); - }, - ); - } else { - return SkeletonLoader( - child: Center( - child: Icon(fallbackIcon, size: 40, color: Colors.white54), - ), - ); - } + } + + return CachedNetworkImage( + imageUrl: client.getThumbnailUrl(posterUrl!), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + filterQuality: FilterQuality.medium, + fadeInDuration: const Duration(milliseconds: 300), + placeholder: (context, url) => const SkeletonLoader(), + errorWidget: (context, url, error) => Container( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + child: Center(child: Icon(fallbackIcon, size: 40)), + ), + ); + }, + ); + } else { + return SkeletonLoader( + child: Center(child: Icon(fallbackIcon, size: 40, color: Colors.white54)), + ); } } diff --git a/lib/widgets/video_controls/sheets/audio_sync_sheet.dart b/lib/widgets/video_controls/sheets/audio_sync_sheet.dart index 11ac2857..6757124a 100644 --- a/lib/widgets/video_controls/sheets/audio_sync_sheet.dart +++ b/lib/widgets/video_controls/sheets/audio_sync_sheet.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import 'package:plezy/services/settings_service.dart'; import '../../../i18n/strings.g.dart'; +import '../../../utils/duration_formatter.dart'; import 'base_video_control_sheet.dart'; /// Bottom sheet for adjusting audio sync offset @@ -61,11 +62,6 @@ class _AudioSyncSheetState extends State { _applyOffset(0); } - String _formatOffset(double offsetMs) { - final sign = offsetMs >= 0 ? '+' : ''; - return '$sign${offsetMs.round()}ms'; - } - @override Widget build(BuildContext context) { return SafeArea( @@ -104,7 +100,7 @@ class _AudioSyncSheetState extends State { children: [ // Current offset display Text( - _formatOffset(_currentOffset), + formatSyncOffset(_currentOffset), style: const TextStyle( color: Colors.white, fontSize: 48, diff --git a/lib/widgets/video_controls/widgets/sync_offset_control.dart b/lib/widgets/video_controls/widgets/sync_offset_control.dart index 16a8a7ea..37f4d989 100644 --- a/lib/widgets/video_controls/widgets/sync_offset_control.dart +++ b/lib/widgets/video_controls/widgets/sync_offset_control.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import '../../../i18n/strings.g.dart'; +import '../../../utils/duration_formatter.dart'; /// Reusable widget for adjusting sync offsets (audio or subtitle) class SyncOffsetControl extends StatefulWidget { @@ -63,11 +64,6 @@ class _SyncOffsetControlState extends State { _applyOffset(0); } - String _formatOffset(double offsetMs) { - final sign = offsetMs >= 0 ? '+' : ''; - return '$sign${offsetMs.round()}ms'; - } - String _getDescriptionText() { if (_currentOffset > 0) { return t.videoControls.playsLater(label: widget.labelText); @@ -87,7 +83,7 @@ class _SyncOffsetControlState extends State { children: [ // Current offset display Text( - _formatOffset(_currentOffset), + formatSyncOffset(_currentOffset), style: const TextStyle( color: Colors.white, fontSize: 48,