refactor: deduplicate

This commit is contained in:
edde746
2025-11-17 00:21:57 +01:00
parent 79a355c768
commit 1e12897c8a
9 changed files with 185 additions and 255 deletions
+57 -25
View File
@@ -10,6 +10,19 @@ enum PlaybackMode {
playQueue, // Play queue-based playback (playlists, collections, shuffle) 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. /// Manages playback state using Plex's play queue API.
/// This provider is session-only and does not persist across app restarts. /// This provider is session-only and does not persist across app restarts.
class PlaybackStateProvider with ChangeNotifier { class PlaybackStateProvider with ChangeNotifier {
@@ -158,6 +171,43 @@ class PlaybackStateProvider with ChangeNotifier {
return false; 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. /// Gets the next item in the playback queue.
/// Returns null if queue is exhausted or current item is not in queue. /// Returns null if queue is exhausted or current item is not in queue.
/// [loopQueue] - If true, restart from beginning when queue is exhausted /// [loopQueue] - If true, restart from beginning when queue is exhausted
@@ -170,23 +220,14 @@ class PlaybackStateProvider with ChangeNotifier {
return null; return null;
} }
if (_loadedItems.isEmpty || _currentPlayQueueItemID == null) return null; final indexResult = await _getCurrentIndex(loadIfMissing: true);
if (indexResult.index == null) {
// Find current item in loaded items if (indexResult.loadFailed) {
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) {
clearShuffle(); clearShuffle();
return null;
} }
// Try again after loading return null;
return getNextEpisode(currentItemKey, loopQueue: loopQueue);
} }
final currentIndex = indexResult.index!;
// Check if there's a next item in the loaded window // Check if there's a next item in the loaded window
if (currentIndex + 1 < _loadedItems.length) { if (currentIndex + 1 < _loadedItems.length) {
@@ -240,17 +281,8 @@ class PlaybackStateProvider with ChangeNotifier {
return null; return null;
} }
if (_loadedItems.isEmpty || _currentPlayQueueItemID == null) return null; final currentIndex = (await _getCurrentIndex()).index;
if (currentIndex == 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;
}
// Check if there's a previous item in the loaded window // Check if there's a previous item in the loaded window
if (currentIndex > 0) { if (currentIndex > 0) {
+3 -46
View File
@@ -8,6 +8,7 @@ import '../providers/settings_provider.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../utils/provider_extensions.dart'; import '../utils/provider_extensions.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/grid_cross_axis_extent.dart';
import '../widgets/media_card.dart'; import '../widgets/media_card.dart';
import '../widgets/desktop_app_bar.dart'; import '../widgets/desktop_app_bar.dart';
import '../widgets/sort_bottom_sheet.dart'; import '../widgets/sort_bottom_sheet.dart';
@@ -293,9 +294,10 @@ class _HubDetailScreenState extends State<HubDetailScreen> with Refreshable {
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8), padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
sliver: SliverGrid( sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _getMaxCrossAxisExtent( maxCrossAxisExtent: getMaxCrossAxisExtentWithPadding(
context, context,
context.watch<SettingsProvider>().libraryDensity, context.watch<SettingsProvider>().libraryDensity,
16,
), ),
childAspectRatio: 2 / 3.3, childAspectRatio: 2 / 3.3,
crossAxisSpacing: 0, crossAxisSpacing: 0,
@@ -313,49 +315,4 @@ class _HubDetailScreenState extends State<HubDetailScreen> 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;
}
}
} }
+3 -46
View File
@@ -11,6 +11,7 @@ import '../models/plex_metadata.dart';
import '../providers/settings_provider.dart'; import '../providers/settings_provider.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/grid_cross_axis_extent.dart';
import '../utils/provider_extensions.dart'; import '../utils/provider_extensions.dart';
import '../widgets/desktop_app_bar.dart'; import '../widgets/desktop_app_bar.dart';
import '../widgets/media_card.dart'; import '../widgets/media_card.dart';
@@ -253,9 +254,10 @@ class _SearchScreenState extends State<SearchScreen>
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
sliver: SliverGrid( sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _getMaxCrossAxisExtent( maxCrossAxisExtent: getMaxCrossAxisExtentWithPadding(
context, context,
settingsProvider.libraryDensity, settingsProvider.libraryDensity,
32,
), ),
childAspectRatio: 2 / 3.3, childAspectRatio: 2 / 3.3,
crossAxisSpacing: 8, crossAxisSpacing: 8,
@@ -279,49 +281,4 @@ class _SearchScreenState extends State<SearchScreen>
), ),
); );
} }
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;
}
}
} }
+12 -22
View File
@@ -19,6 +19,16 @@ ThemeData monoTheme({required bool dark}) {
textMuted: const Color(0x99111111), 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( final base = ThemeData(
useMaterial3: true, useMaterial3: true,
brightness: dark ? Brightness.dark : Brightness.light, brightness: dark ? Brightness.dark : Brightness.light,
@@ -103,28 +113,8 @@ ThemeData monoTheme({required bool dark}) {
), ),
hintStyle: TextStyle(color: c.textMuted), hintStyle: TextStyle(color: c.textMuted),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(style: buttonStyle),
style: ButtonStyle( 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()),
),
),
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()),
),
),
dividerTheme: DividerThemeData(space: 0, thickness: 1, color: c.outline), dividerTheme: DividerThemeData(space: 0, thickness: 1, color: c.outline),
listTileTheme: ListTileThemeData( listTileTheme: ListTileThemeData(
dense: true, dense: true,
+9
View File
@@ -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. /// 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. /// Falls back to English if the locale is not supported by the duration package.
DurationLocale _getDurationLocale() { DurationLocale _getDurationLocale() {
+51
View File
@@ -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;
}
}
+46 -104
View File
@@ -323,62 +323,12 @@ class _MediaCardGrid extends StatelessWidget {
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: _buildPosterImage(context), child: _buildPosterImage(context, item),
), ),
_PosterOverlay(item: 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<SettingsProvider>().useSeasonPoster;
posterUrl = (item as PlexMetadata).posterThumb(
useSeasonPoster: useSeasonPoster,
);
}
if (posterUrl != null) {
return Consumer<PlexClientProvider>(
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 /// List layout for media cards
@@ -442,14 +392,8 @@ class _MediaCardList extends StatelessWidget {
} }
double get _summaryFontSize { double get _summaryFontSize {
switch (density) { // Summary uses the same sizing as metadata text
case LibraryDensity.compact: return _metadataFontSize;
return 11;
case LibraryDensity.normal:
return 12;
case LibraryDensity.comfortable:
return 13;
}
} }
int get _summaryMaxLines { int get _summaryMaxLines {
@@ -576,7 +520,7 @@ class _MediaCardList extends StatelessWidget {
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: _buildPosterImage(context), child: _buildPosterImage(context, item),
), ),
_PosterOverlay(item: item), _PosterOverlay(item: item),
], ],
@@ -656,55 +600,53 @@ class _MediaCardList extends StatelessWidget {
), ),
); );
} }
}
Widget _buildPosterImage(BuildContext context) { Widget _buildPosterImage(BuildContext context, dynamic item) {
String? posterUrl; String? posterUrl;
IconData fallbackIcon = Icons.movie; IconData fallbackIcon = Icons.movie;
if (item is PlexPlaylist) { if (item is PlexPlaylist) {
posterUrl = (item as PlexPlaylist).displayImage; posterUrl = (item as PlexPlaylist).displayImage;
fallbackIcon = Icons.playlist_play; fallbackIcon = Icons.playlist_play;
} else if (item is PlexMetadata) { } else if (item is PlexMetadata) {
final useSeasonPoster = context.watch<SettingsProvider>().useSeasonPoster; final useSeasonPoster = context.watch<SettingsProvider>().useSeasonPoster;
posterUrl = (item as PlexMetadata).posterThumb( posterUrl = (item as PlexMetadata).posterThumb(
useSeasonPoster: useSeasonPoster, useSeasonPoster: useSeasonPoster,
); );
} }
if (posterUrl != null) { if (posterUrl != null) {
return Consumer<PlexClientProvider>( return Consumer<PlexClientProvider>(
builder: (context, clientProvider, child) { builder: (context, clientProvider, child) {
final client = clientProvider.client; final client = clientProvider.client;
if (client == null) { if (client == null) {
return SkeletonLoader( return SkeletonLoader(
child: Center( child: Center(
child: Icon(fallbackIcon, size: 40, color: Colors.white54), 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 CachedNetworkImage(
return SkeletonLoader( imageUrl: client.getThumbnailUrl(posterUrl!),
child: Center( fit: BoxFit.cover,
child: Icon(fallbackIcon, size: 40, color: Colors.white54), 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)),
);
} }
} }
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/settings_service.dart';
import '../../../i18n/strings.g.dart'; import '../../../i18n/strings.g.dart';
import '../../../utils/duration_formatter.dart';
import 'base_video_control_sheet.dart'; import 'base_video_control_sheet.dart';
/// Bottom sheet for adjusting audio sync offset /// Bottom sheet for adjusting audio sync offset
@@ -61,11 +62,6 @@ class _AudioSyncSheetState extends State<AudioSyncSheet> {
_applyOffset(0); _applyOffset(0);
} }
String _formatOffset(double offsetMs) {
final sign = offsetMs >= 0 ? '+' : '';
return '$sign${offsetMs.round()}ms';
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( return SafeArea(
@@ -104,7 +100,7 @@ class _AudioSyncSheetState extends State<AudioSyncSheet> {
children: [ children: [
// Current offset display // Current offset display
Text( Text(
_formatOffset(_currentOffset), formatSyncOffset(_currentOffset),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 48, fontSize: 48,
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import '../../../i18n/strings.g.dart'; import '../../../i18n/strings.g.dart';
import '../../../utils/duration_formatter.dart';
/// Reusable widget for adjusting sync offsets (audio or subtitle) /// Reusable widget for adjusting sync offsets (audio or subtitle)
class SyncOffsetControl extends StatefulWidget { class SyncOffsetControl extends StatefulWidget {
@@ -63,11 +64,6 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
_applyOffset(0); _applyOffset(0);
} }
String _formatOffset(double offsetMs) {
final sign = offsetMs >= 0 ? '+' : '';
return '$sign${offsetMs.round()}ms';
}
String _getDescriptionText() { String _getDescriptionText() {
if (_currentOffset > 0) { if (_currentOffset > 0) {
return t.videoControls.playsLater(label: widget.labelText); return t.videoControls.playsLater(label: widget.labelText);
@@ -87,7 +83,7 @@ class _SyncOffsetControlState extends State<SyncOffsetControl> {
children: [ children: [
// Current offset display // Current offset display
Text( Text(
_formatOffset(_currentOffset), formatSyncOffset(_currentOffset),
style: const TextStyle( style: const TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 48, fontSize: 48,