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