refactor: deduplicate
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user