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)
}
/// 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);
}
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) {
+3 -46
View File
@@ -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<HubDetailScreen> with Refreshable {
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
sliver: SliverGrid(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: _getMaxCrossAxisExtent(
maxCrossAxisExtent: getMaxCrossAxisExtentWithPadding(
context,
context.watch<SettingsProvider>().libraryDensity,
16,
),
childAspectRatio: 2 / 3.3,
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 '../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<SearchScreen>
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<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),
);
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,
+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.
/// Falls back to English if the locale is not supported by the duration package.
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;
}
}
+7 -65
View File
@@ -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<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
@@ -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,8 +600,9 @@ class _MediaCardList extends StatelessWidget {
),
);
}
}
Widget _buildPosterImage(BuildContext context) {
Widget _buildPosterImage(BuildContext context, dynamic item) {
String? posterUrl;
IconData fallbackIcon = Icons.movie;
@@ -700,12 +645,9 @@ class _MediaCardList extends StatelessWidget {
);
} else {
return SkeletonLoader(
child: Center(
child: Icon(fallbackIcon, size: 40, color: Colors.white54),
),
child: Center(child: Icon(fallbackIcon, size: 40, color: Colors.white54)),
);
}
}
}
/// Overlay widget for poster showing watched indicator and progress bar
@@ -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<AudioSyncSheet> {
_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<AudioSyncSheet> {
children: [
// Current offset display
Text(
_formatOffset(_currentOffset),
formatSyncOffset(_currentOffset),
style: const TextStyle(
color: Colors.white,
fontSize: 48,
@@ -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<SyncOffsetControl> {
_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<SyncOffsetControl> {
children: [
// Current offset display
Text(
_formatOffset(_currentOffset),
formatSyncOffset(_currentOffset),
style: const TextStyle(
color: Colors.white,
fontSize: 48,