+48
-48
@@ -225,23 +225,19 @@ FutureOr<SentryEvent?> _beforeSend(SentryEvent event, Hint _) {
|
||||
bool shouldDrop(SentryException e) {
|
||||
final v = e.value;
|
||||
// Windows file-lock errors from cache manager cleanup
|
||||
if (e.type == 'FileSystemException' &&
|
||||
v != null &&
|
||||
v.contains('plexImageCache') &&
|
||||
v.contains('errno = 32')) {
|
||||
if (e.type == 'FileSystemException' && v != null && v.contains('plexImageCache') && v.contains('errno = 32')) {
|
||||
return true;
|
||||
}
|
||||
// Linux without DBus/NetworkManager
|
||||
if (e.type == 'DBusServiceUnknownException' ||
|
||||
(v != null && v.contains('system_bus_socket'))) {
|
||||
if (e.type == 'DBusServiceUnknownException' || (v != null && v.contains('system_bus_socket'))) {
|
||||
return true;
|
||||
}
|
||||
// Device out of disk space
|
||||
if (v != null &&
|
||||
(v.contains('SQLITE_FULL') ||
|
||||
v.contains('No space left on device') ||
|
||||
v.contains('errno = 112') ||
|
||||
v.contains('database or disk is full'))) {
|
||||
v.contains('No space left on device') ||
|
||||
v.contains('errno = 112') ||
|
||||
v.contains('database or disk is full'))) {
|
||||
return true;
|
||||
}
|
||||
// Native HTTP errors from CFNetwork (server errors, not actionable)
|
||||
@@ -387,7 +383,8 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
|
||||
_memoryCheckTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||
final rss = ProcessInfo.currentRss;
|
||||
if (rss > 1536 * 1024 * 1024) { // 1.5GB
|
||||
if (rss > 1536 * 1024 * 1024) {
|
||||
// 1.5GB
|
||||
appLogger.w('RSS high ($rss bytes), evicting image caches');
|
||||
_evictImageCaches();
|
||||
}
|
||||
@@ -446,23 +443,23 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
if (_isAutoDeleteRunning) return;
|
||||
_isAutoDeleteRunning = true;
|
||||
try {
|
||||
await downloadProvider.refreshMetadataFromCache();
|
||||
final activeKey = VideoPlayerScreenState.activeRatingKey;
|
||||
final settings = SettingsService.instanceOrNull;
|
||||
if (settings != null && settings.getAutoRemoveWatchedDownloads()) {
|
||||
final deleted = await downloadProvider.autoDeleteWatchedDownloads(activeRatingKey: activeKey);
|
||||
if (deleted.isNotEmpty) {
|
||||
final msg = deleted.length == 1
|
||||
? t.messages.autoRemovedWatchedDownload(title: deleted.first)
|
||||
: t.messages.autoRemovedWatchedDownload(title: '${deleted.length} items');
|
||||
showGlobalSnackBar(msg);
|
||||
await downloadProvider.refreshMetadataFromCache();
|
||||
final activeKey = VideoPlayerScreenState.activeRatingKey;
|
||||
final settings = SettingsService.instanceOrNull;
|
||||
if (settings != null && settings.getAutoRemoveWatchedDownloads()) {
|
||||
final deleted = await downloadProvider.autoDeleteWatchedDownloads(activeRatingKey: activeKey);
|
||||
if (deleted.isNotEmpty) {
|
||||
final msg = deleted.length == 1
|
||||
? t.messages.autoRemovedWatchedDownload(title: deleted.first)
|
||||
: t.messages.autoRemovedWatchedDownload(title: '${deleted.length} items');
|
||||
showMainSnackBar(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final synced = await downloadProvider.executeSyncRules(_serverManager);
|
||||
if (synced.isNotEmpty) {
|
||||
showGlobalSnackBar(t.downloads.syncedNewEpisodes(count: synced.length.toString(), title: synced.first));
|
||||
}
|
||||
final synced = await downloadProvider.executeSyncRules(_serverManager);
|
||||
if (synced.isNotEmpty) {
|
||||
showMainSnackBar(t.downloads.syncedNewEpisodes(count: synced.length.toString(), title: synced.first));
|
||||
}
|
||||
} finally {
|
||||
_isAutoDeleteRunning = false;
|
||||
}
|
||||
@@ -495,7 +492,8 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
// SQLite WAL mode handles process death; desktop uses onExitRequested.
|
||||
InAppReviewService.instance.endSession();
|
||||
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
|
||||
if (ProcessInfo.currentRss > 1024 * 1024 * 1024) { // 1GB
|
||||
if (ProcessInfo.currentRss > 1024 * 1024 * 1024) {
|
||||
// 1GB
|
||||
_evictImageCaches();
|
||||
}
|
||||
}
|
||||
@@ -526,7 +524,8 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
),
|
||||
// Download provider
|
||||
ChangeNotifierProvider(
|
||||
create: (context) => DownloadProvider(downloadManager: _downloadManager, database: _appDatabase)),
|
||||
create: (context) => DownloadProvider(downloadManager: _downloadManager, database: _appDatabase),
|
||||
),
|
||||
// Offline watch sync service
|
||||
ChangeNotifierProvider<OfflineWatchSyncService>(
|
||||
create: (context) {
|
||||
@@ -586,24 +585,21 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: InputModeTracker(
|
||||
child: MaterialApp(
|
||||
title: t.app.title,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: themeProvider.lightTheme,
|
||||
darkTheme: themeProvider.darkTheme,
|
||||
themeMode: themeProvider.materialThemeMode,
|
||||
navigatorKey: rootNavigatorKey,
|
||||
navigatorObservers: [routeObserver, BackKeySuppressorObserver()],
|
||||
home: const OrientationAwareSetup(),
|
||||
builder: (context, child) => ScaffoldMessenger(
|
||||
key: rootScaffoldMessengerKey,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: child,
|
||||
title: t.app.title,
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: themeProvider.lightTheme,
|
||||
darkTheme: themeProvider.darkTheme,
|
||||
themeMode: themeProvider.materialThemeMode,
|
||||
navigatorKey: rootNavigatorKey,
|
||||
navigatorObservers: [routeObserver, BackKeySuppressorObserver()],
|
||||
home: const OrientationAwareSetup(),
|
||||
builder: (context, child) => ScaffoldMessenger(
|
||||
key: rootScaffoldMessengerKey,
|
||||
child: Scaffold(backgroundColor: Colors.transparent, body: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -786,9 +782,9 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
_statusMessage,
|
||||
key: ValueKey(_statusMessage),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -808,7 +804,8 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
final Widget statusIcon;
|
||||
if (connected == null) {
|
||||
statusIcon = const SizedBox(
|
||||
width: 12, height: 12,
|
||||
width: 12,
|
||||
height: 12,
|
||||
child: CircularProgressIndicator(strokeWidth: 1.5, color: coralColor),
|
||||
);
|
||||
} else if (connected) {
|
||||
@@ -840,17 +837,20 @@ class _SetupScreenState extends State<SetupScreen> {
|
||||
children: [
|
||||
Center(child: SvgPicture.asset('assets/plezy_adaptive_foreground.svg', width: 288, height: 288)),
|
||||
Positioned(
|
||||
left: 0, right: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: MediaQuery.of(context).size.height * 0.5 - 170,
|
||||
child: _buildStatusText(context),
|
||||
),
|
||||
Positioned(
|
||||
left: 0, right: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: MediaQuery.of(context).size.height * 0.5 + 180,
|
||||
child: Center(
|
||||
child: _serverStatus.isEmpty
|
||||
? const SizedBox(
|
||||
width: 20, height: 20,
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: coralColor),
|
||||
)
|
||||
: _buildServerStatusList(context),
|
||||
|
||||
@@ -22,11 +22,9 @@ class OfflineWatchProvider extends ChangeNotifier {
|
||||
final OfflineWatchSyncService _syncService;
|
||||
final DownloadProvider _downloadProvider;
|
||||
|
||||
OfflineWatchProvider({
|
||||
required OfflineWatchSyncService syncService,
|
||||
required DownloadProvider downloadProvider,
|
||||
}) : _syncService = syncService,
|
||||
_downloadProvider = downloadProvider {
|
||||
OfflineWatchProvider({required OfflineWatchSyncService syncService, required DownloadProvider downloadProvider})
|
||||
: _syncService = syncService,
|
||||
_downloadProvider = downloadProvider {
|
||||
// Listen to sync service changes to update UI
|
||||
_syncService.addListener(_onSyncServiceChanged);
|
||||
}
|
||||
@@ -198,11 +196,16 @@ class OfflineWatchProvider extends ChangeNotifier {
|
||||
if (progress?.status != DownloadStatus.completed) return;
|
||||
|
||||
appLogger.i('Auto-deleting locally-watched download: ${meta.title} ($globalKey)');
|
||||
_downloadProvider.deleteDownload(globalKey).then((_) {
|
||||
showGlobalSnackBar(t.messages.autoRemovedWatchedDownload(title: meta.title ?? 'Unknown'));
|
||||
}, onError: (e) {
|
||||
appLogger.w('Failed to auto-delete locally-watched download $globalKey: $e');
|
||||
});
|
||||
_downloadProvider
|
||||
.deleteDownload(globalKey)
|
||||
.then(
|
||||
(_) {
|
||||
showMainSnackBar(t.messages.autoRemovedWatchedDownload(title: meta.title ?? 'Unknown'));
|
||||
},
|
||||
onError: (e) {
|
||||
appLogger.w('Failed to auto-delete locally-watched download $globalKey: $e');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Mark an item as unwatched while offline.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'dart:io' show Platform, exit;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart' show HardwareKeyboard, KeyDownEvent, KeyUpEvent, LogicalKeyboardKey, SystemNavigator;
|
||||
import 'package:flutter/services.dart'
|
||||
show HardwareKeyboard, KeyDownEvent, KeyUpEvent, LogicalKeyboardKey, SystemNavigator;
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -14,6 +15,7 @@ import '../focus/focusable_button.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../main.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
@@ -553,13 +555,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
Widget _buildTickerAwareStack() {
|
||||
return IndexedStack(
|
||||
index: _currentIndex,
|
||||
children: [
|
||||
for (var i = 0; i < _screens.length; i++)
|
||||
TickerMode(
|
||||
enabled: i == _currentIndex,
|
||||
child: _screens[i],
|
||||
),
|
||||
],
|
||||
children: [for (var i = 0; i < _screens.length; i++) TickerMode(enabled: i == _currentIndex, child: _screens[i])],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -568,7 +564,10 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
for (final tab in _getVisibleTabs(offline))
|
||||
switch (tab.id) {
|
||||
NavigationTabId.discover => DiscoverScreen(key: _discoverKey),
|
||||
NavigationTabId.libraries => LibrariesScreen(key: _librariesKey, onLibraryOrderChanged: _onLibraryOrderChanged),
|
||||
NavigationTabId.libraries => LibrariesScreen(
|
||||
key: _librariesKey,
|
||||
onLibraryOrderChanged: _onLibraryOrderChanged,
|
||||
),
|
||||
NavigationTabId.liveTv => LiveTvScreen(key: _liveTvKey),
|
||||
NavigationTabId.search => SearchScreen(key: _searchKey),
|
||||
NavigationTabId.downloads => DownloadsScreen(key: _downloadsKey),
|
||||
@@ -634,8 +633,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
|
||||
// Track if we auto-switched to Downloads because the previous tab was unavailable.
|
||||
_autoSwitchedToDownloads =
|
||||
previousTab != NavigationTabId.downloads &&
|
||||
normalizedTab == NavigationTabId.downloads;
|
||||
previousTab != NavigationTabId.downloads && normalizedTab == NavigationTabId.downloads;
|
||||
} else {
|
||||
// Coming back online: restore the last online tab if we forced a switch to Downloads.
|
||||
if (_autoSwitchedToDownloads) {
|
||||
@@ -1033,67 +1031,70 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
||||
}
|
||||
|
||||
return OverlaySheetHost(
|
||||
child: Scaffold(
|
||||
body: _buildTickerAwareStack(),
|
||||
bottomNavigationBar: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Reconnect bar when offline
|
||||
if (_isOffline)
|
||||
Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: InkWell(
|
||||
onTap: _isReconnecting ? null : _triggerReconnect,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (_isReconnecting)
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
child: ScaffoldMessenger(
|
||||
key: mainScaffoldMessengerKey,
|
||||
child: Scaffold(
|
||||
body: _buildTickerAwareStack(),
|
||||
bottomNavigationBar: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Reconnect bar when offline
|
||||
if (_isOffline)
|
||||
Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: InkWell(
|
||||
onTap: _isReconnecting ? null : _triggerReconnect,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (_isReconnecting)
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(Symbols.wifi_rounded, size: 18, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
t.common.reconnect,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(Symbols.wifi_rounded, size: 18, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
t.common.reconnect,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
final hideLabels = !settingsProvider.showNavBarLabels;
|
||||
return NavigationBarTheme(
|
||||
data: NavigationBarTheme.of(context).copyWith(height: hideLabels ? 56 : null),
|
||||
child: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: (i) {
|
||||
final tabs = _getVisibleTabs(_isOffline);
|
||||
if (i >= 0 && i < tabs.length) _selectTab(tabs[i].id);
|
||||
},
|
||||
labelBehavior: hideLabels
|
||||
? NavigationDestinationLabelBehavior.alwaysHide
|
||||
: NavigationDestinationLabelBehavior.alwaysShow,
|
||||
destinations: _buildNavDestinations(_isOffline),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Consumer<SettingsProvider>(
|
||||
builder: (context, settingsProvider, child) {
|
||||
final hideLabels = !settingsProvider.showNavBarLabels;
|
||||
return NavigationBarTheme(
|
||||
data: NavigationBarTheme.of(context).copyWith(height: hideLabels ? 56 : null),
|
||||
child: NavigationBar(
|
||||
selectedIndex: _currentIndex,
|
||||
onDestinationSelected: (i) {
|
||||
final tabs = _getVisibleTabs(_isOffline);
|
||||
if (i >= 0 && i < tabs.length) _selectTab(tabs[i].id);
|
||||
},
|
||||
labelBehavior: hideLabels
|
||||
? NavigationDestinationLabelBehavior.alwaysHide
|
||||
: NavigationDestinationLabelBehavior.alwaysShow,
|
||||
destinations: _buildNavDestinations(_isOffline),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -64,6 +64,7 @@ import '../utils/plex_url_helper.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
import '../widgets/overlay_sheet.dart';
|
||||
import '../widgets/video_controls/video_controls.dart';
|
||||
import '../widgets/video_controls/widgets/player_toast_indicator.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/dpad_navigator.dart';
|
||||
@@ -216,6 +217,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// Screen-level focus node: persists across loading/initialized phases so
|
||||
// key events never escape the video player route.
|
||||
late final FocusNode _screenFocusNode;
|
||||
|
||||
// VLC-style in-player toast controller (rate changes, backend switch, etc.).
|
||||
final PlayerToastController _toastController = PlayerToastController();
|
||||
bool _reclaimingFocus = false;
|
||||
|
||||
// Cached setting: when false on Windows/Linux, ESC should not exit the player
|
||||
@@ -745,11 +749,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// warn is included so we can catch ffmpeg's "HTTP error 500" line in
|
||||
// _onPlayerLog — the error-level log that follows omits the status code.
|
||||
_logSubscription = player!.streams.log
|
||||
.where((log) => const {
|
||||
PlayerLogLevel.fatal,
|
||||
PlayerLogLevel.error,
|
||||
PlayerLogLevel.warn,
|
||||
}.contains(log.level))
|
||||
.where((log) => const {PlayerLogLevel.fatal, PlayerLogLevel.error, PlayerLogLevel.warn}.contains(log.level))
|
||||
.listen(_onPlayerLog);
|
||||
|
||||
// Listen for backend switched event (ExoPlayer -> MPV fallback on Android)
|
||||
@@ -2034,6 +2034,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_hasFirstFrame.dispose();
|
||||
_isExiting.dispose();
|
||||
_controlsVisible.dispose();
|
||||
_toastController.dispose();
|
||||
|
||||
// Stop progress tracking and send final state.
|
||||
// Fire-and-forget: dispose() is synchronous so we can't await, but the
|
||||
@@ -2285,8 +2286,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
String? _lastLogError;
|
||||
bool _sawServer500 = false;
|
||||
|
||||
static final RegExp _server500Pattern =
|
||||
RegExp(r'\b(?:HTTP error |Response code: )500\b');
|
||||
static final RegExp _server500Pattern = RegExp(r'\b(?:HTTP error |Response code: )500\b');
|
||||
|
||||
void _onPlayerLog(PlayerLog log) {
|
||||
if (!_sawServer500 && _server500Pattern.hasMatch(log.text)) {
|
||||
@@ -2309,9 +2309,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
_playerBackendLabel = 'mpv';
|
||||
_recordLifecycleState('backend_switched', action: 'mpv_fallback');
|
||||
|
||||
if (mounted) {
|
||||
showAppSnackBar(context, t.messages.switchingToCompatiblePlayer);
|
||||
}
|
||||
_toastController.show(
|
||||
Symbols.swap_horiz_rounded,
|
||||
t.messages.switchingToCompatiblePlayer,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
|
||||
await _trackManager?.onBackendSwitched();
|
||||
}
|
||||
@@ -2944,7 +2946,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
if (widget.isOffline) {
|
||||
result = await _startOfflinePlayback();
|
||||
} else {
|
||||
final playbackService = PlaybackInitializationService(client: client!, database: PlexApiCache.instance.database);
|
||||
final playbackService = PlaybackInitializationService(
|
||||
client: client!,
|
||||
database: PlexApiCache.instance.database,
|
||||
);
|
||||
result = await playbackService.getPlaybackData(
|
||||
metadata: episodeMetadata,
|
||||
selectedMediaIndex: widget.selectedMediaIndex,
|
||||
@@ -3312,6 +3317,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
onJumpToLive: _captureBuffer != null && !_isAtLiveEdge ? _jumpToLiveEdge : null,
|
||||
isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false,
|
||||
onToggleAmbientLighting: _toggleAmbientLighting,
|
||||
toastController: _toastController,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -8,7 +8,6 @@ import '../i18n/strings.g.dart';
|
||||
import '../mpv/mpv.dart';
|
||||
import 'settings_service.dart';
|
||||
import '../utils/player_utils.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
|
||||
class KeyboardShortcutsService {
|
||||
static KeyboardShortcutsService? _instance;
|
||||
@@ -294,18 +293,15 @@ class KeyboardShortcutsService {
|
||||
final newRateUp = (player.state.rate + 0.25).clamp(0.25, 3.0);
|
||||
player.setRate(newRateUp);
|
||||
_settingsService.setDefaultPlaybackSpeed(newRateUp);
|
||||
showGlobalSnackBar(_formatSpeed(newRateUp));
|
||||
break;
|
||||
case 'speed_decrease':
|
||||
final newRateDown = (player.state.rate - 0.25).clamp(0.25, 3.0);
|
||||
player.setRate(newRateDown);
|
||||
_settingsService.setDefaultPlaybackSpeed(newRateDown);
|
||||
showGlobalSnackBar(_formatSpeed(newRateDown));
|
||||
break;
|
||||
case 'speed_reset':
|
||||
player.setRate(1.0);
|
||||
_settingsService.setDefaultPlaybackSpeed(1.0);
|
||||
showGlobalSnackBar(_formatSpeed(1.0));
|
||||
break;
|
||||
case 'sub_seek_next':
|
||||
player.command(['sub-seek', '1']);
|
||||
@@ -391,9 +387,4 @@ class KeyboardShortcutsService {
|
||||
|
||||
return aModifiers.length == bModifiers.length && aModifiers.every((modifier) => bModifiers.contains(modifier));
|
||||
}
|
||||
|
||||
String _formatSpeed(double speed) {
|
||||
final s = speed.toStringAsFixed(2).replaceFirst(RegExp(r'\.?0+$'), '');
|
||||
return '${s}x';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,18 @@ ThemeData monoTheme({required bool dark, bool oled = false}) {
|
||||
return IconThemeData(opacity: active ? 1 : 0.6, size: 22, color: c.text);
|
||||
}),
|
||||
),
|
||||
// Floating snackbars auto-offset above the Scaffold's bottom NavigationBar,
|
||||
// so they don't cover it on mobile. Background color tracks the theme to
|
||||
// avoid jarring brightness on HDR playback / dark mode.
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: c.surface,
|
||||
contentTextStyle: TextStyle(color: c.text),
|
||||
actionTextColor: c.text,
|
||||
elevation: 6,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
),
|
||||
);
|
||||
|
||||
return base.copyWith(
|
||||
|
||||
@@ -53,7 +53,6 @@ class ByteFormatter {
|
||||
if (kbps < 1000) return '$kbps kbps';
|
||||
return '${(kbps / 1000).toStringAsFixed(1)} Mbps';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Formats a duration in human-readable textual format (e.g., "1h 23m" or "1 hour 23 minutes").
|
||||
@@ -181,6 +180,16 @@ String toBulletedString(List<String> parts) {
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
final RegExp _trailingZeroPattern = RegExp(r'\.?0+$');
|
||||
|
||||
/// Format a playback rate for display (e.g. 1.25 → "1.25x", 2.0 → "2x").
|
||||
/// When [normalAtOne] is true, 1.0 renders as "Normal" for menu labels;
|
||||
/// the in-player pill passes false to keep a numeric indicator.
|
||||
String formatPlaybackRate(double rate, {bool normalAtOne = false}) {
|
||||
if (normalAtOne && (rate - 1.0).abs() < 0.005) return 'Normal';
|
||||
return '${rate.toStringAsFixed(2).replaceFirst(_trailingZeroPattern, '')}x';
|
||||
}
|
||||
|
||||
/// Takes a date string in the format "YYYY-MM-DD" and returns a localized full date string
|
||||
/// If there is any error, `dateString` is returned as is
|
||||
String formatFullDate(String dateString) {
|
||||
|
||||
@@ -3,6 +3,10 @@ import 'package:flutter/material.dart';
|
||||
/// Global key for the root ScaffoldMessenger, allowing snackbars to survive navigation.
|
||||
final rootScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
/// Nested messenger inside MainScreen — its Scaffold owns the bottom NavigationBar
|
||||
/// so floating snackbars auto-offset above the navbar.
|
||||
final mainScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
/// Types of snackbars available in the app
|
||||
enum SnackBarType {
|
||||
/// Standard informational snackbar
|
||||
@@ -68,6 +72,16 @@ void showGlobalSnackBar(String message, {Duration duration = const Duration(seco
|
||||
..showSnackBar(SnackBar(content: Text(message), duration: duration));
|
||||
}
|
||||
|
||||
/// Shows an info snackbar through the main-screen messenger when available
|
||||
/// (so it floats above the mobile NavigationBar), falling back to the root
|
||||
/// messenger when the main screen is not mounted.
|
||||
void showMainSnackBar(String message, {Duration duration = const Duration(seconds: 3)}) {
|
||||
final messenger = mainScaffoldMessengerKey.currentState ?? rootScaffoldMessengerKey.currentState;
|
||||
messenger
|
||||
?..removeCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(message), duration: duration));
|
||||
}
|
||||
|
||||
/// Shows a success snackbar with a message
|
||||
///
|
||||
/// [context] The build context
|
||||
|
||||
@@ -241,30 +241,32 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
// show() with new alignment replaces the current sheet (completing the
|
||||
// settings sheet future, which restarts the auto-hide timer via
|
||||
// whenComplete in track_chapter_controls). Cancel it again here.
|
||||
controller.show(
|
||||
alignment: Alignment.topCenter,
|
||||
constraints: const BoxConstraints(maxHeight: 80, maxWidth: 900),
|
||||
initialFocusNode: sliderFocusNode,
|
||||
builder: (_) => _CompactSyncBar(
|
||||
title: title,
|
||||
icon: icon,
|
||||
player: widget.player,
|
||||
propertyName: propertyName,
|
||||
initialOffset: initialOffset,
|
||||
sliderFocusNode: sliderFocusNode,
|
||||
onOffsetChanged: (offset) async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
if (isSubtitle) {
|
||||
await settings.setSubtitleSyncOffset(offset);
|
||||
} else {
|
||||
await settings.setAudioSyncOffset(offset);
|
||||
}
|
||||
widget.onSyncOffsetChanged?.call(propertyName, offset);
|
||||
},
|
||||
),
|
||||
).whenComplete(() {
|
||||
widget.onStartAutoHide?.call();
|
||||
});
|
||||
controller
|
||||
.show(
|
||||
alignment: Alignment.topCenter,
|
||||
constraints: const BoxConstraints(maxHeight: 80, maxWidth: 900),
|
||||
initialFocusNode: sliderFocusNode,
|
||||
builder: (_) => _CompactSyncBar(
|
||||
title: title,
|
||||
icon: icon,
|
||||
player: widget.player,
|
||||
propertyName: propertyName,
|
||||
initialOffset: initialOffset,
|
||||
sliderFocusNode: sliderFocusNode,
|
||||
onOffsetChanged: (offset) async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
if (isSubtitle) {
|
||||
await settings.setSubtitleSyncOffset(offset);
|
||||
} else {
|
||||
await settings.setAudioSyncOffset(offset);
|
||||
}
|
||||
widget.onSyncOffsetChanged?.call(propertyName, offset);
|
||||
},
|
||||
),
|
||||
)
|
||||
.whenComplete(() {
|
||||
widget.onStartAutoHide?.call();
|
||||
});
|
||||
|
||||
// Cancel auto-hide after show() — the previous sheet's whenComplete
|
||||
// fires as a microtask and restarts the timer, so schedule our cancel
|
||||
@@ -317,11 +319,6 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
String _formatSpeed(double speed) {
|
||||
if (speed == 1.0) return 'Normal';
|
||||
return '${speed.toStringAsFixed(2)}x';
|
||||
}
|
||||
|
||||
String _formatSleepTimer(SleepTimerService sleepTimer) {
|
||||
if (!sleepTimer.isActive) return 'Off';
|
||||
final remaining = sleepTimer.remainingTime;
|
||||
@@ -345,7 +342,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
return _SettingsMenuItem(
|
||||
icon: Symbols.speed_rounded,
|
||||
title: t.videoSettings.playbackSpeed,
|
||||
valueText: _formatSpeed(currentRate),
|
||||
valueText: formatPlaybackRate(currentRate, normalAtOne: true),
|
||||
onTap: () => _navigateTo(_SettingsView.speed),
|
||||
);
|
||||
},
|
||||
@@ -387,7 +384,11 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
// HDR Toggle (iOS, macOS, and Windows)
|
||||
if (Platform.isIOS || Platform.isMacOS || Platform.isWindows)
|
||||
FocusableListTile(
|
||||
leading: AppIcon(Symbols.hdr_strong_rounded, fill: 1, color: _enableHDR ? Colors.amber : tokens(context).textMuted),
|
||||
leading: AppIcon(
|
||||
Symbols.hdr_strong_rounded,
|
||||
fill: 1,
|
||||
color: _enableHDR ? Colors.amber : tokens(context).textMuted,
|
||||
),
|
||||
title: Text(t.videoSettings.hdr),
|
||||
trailing: Switch(value: _enableHDR, onChanged: (_) => _toggleHDR(), activeThumbColor: Colors.amber),
|
||||
onTap: _toggleHDR,
|
||||
@@ -416,9 +417,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
initialData: widget.player.state.audioDevice,
|
||||
builder: (context, snapshot) {
|
||||
final currentDevice = snapshot.data ?? widget.player.state.audioDevice;
|
||||
final deviceLabel = currentDevice.description.isEmpty
|
||||
? currentDevice.name
|
||||
: currentDevice.description;
|
||||
final deviceLabel = currentDevice.description.isEmpty ? currentDevice.name : currentDevice.description;
|
||||
|
||||
return _SettingsMenuItem(
|
||||
icon: Symbols.speaker_rounded,
|
||||
@@ -553,7 +552,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
itemBuilder: (context, index) {
|
||||
final speed = speeds[index];
|
||||
final isSelected = (currentRate - speed).abs() < 0.01;
|
||||
final label = speed == 1.0 ? 'Normal' : '${speed.toStringAsFixed(2)}x';
|
||||
final label = formatPlaybackRate(speed, normalAtOne: true);
|
||||
|
||||
final primary = Theme.of(context).colorScheme.primary;
|
||||
return FocusableListTile(
|
||||
@@ -578,7 +577,11 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
Widget _buildSleepView() {
|
||||
final sleepTimer = SleepTimerService();
|
||||
|
||||
return SleepTimerContent(player: widget.player, sleepTimer: sleepTimer, onCancel: () => OverlaySheetController.of(context).close());
|
||||
return SleepTimerContent(
|
||||
player: widget.player,
|
||||
sleepTimer: sleepTimer,
|
||||
onCancel: () => OverlaySheetController.of(context).close(),
|
||||
);
|
||||
}
|
||||
|
||||
// Audio/subtitle sync views are now opened as compact top bars via _openSyncBar()
|
||||
@@ -745,10 +748,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
}
|
||||
|
||||
Future<void> _importCustomShader(ShaderProvider shaderProvider) async {
|
||||
final result = await FilePickerService.instance.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['glsl'],
|
||||
);
|
||||
final result = await FilePickerService.instance.pickFiles(type: FileType.custom, allowedExtensions: ['glsl']);
|
||||
|
||||
if (result == null || result.files.isEmpty || !mounted) return;
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ import '../../screens/video_player_screen.dart';
|
||||
import '../../focus/key_event_utils.dart';
|
||||
import '../../services/keyboard_shortcuts_service.dart';
|
||||
import '../../services/settings_service.dart';
|
||||
import '../../utils/formatters.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../utils/plex_cache_parser.dart';
|
||||
import '../../utils/player_utils.dart';
|
||||
@@ -44,6 +45,7 @@ import '../../theme/mono_tokens.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
import 'icons.dart';
|
||||
import 'widgets/player_toast_indicator.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import '../../focus/input_mode_tracker.dart';
|
||||
@@ -94,10 +96,12 @@ Widget plexVideoControlsBuilder(
|
||||
VoidCallback? onJumpToLive,
|
||||
bool isAmbientLightingEnabled = false,
|
||||
VoidCallback? onToggleAmbientLighting,
|
||||
required PlayerToastController toastController,
|
||||
}) {
|
||||
return PlexVideoControls(
|
||||
player: player,
|
||||
metadata: metadata,
|
||||
toastController: toastController,
|
||||
onNext: onNext,
|
||||
onPrevious: onPrevious,
|
||||
availableVersions: availableVersions ?? [],
|
||||
@@ -205,10 +209,14 @@ class PlexVideoControls extends StatefulWidget {
|
||||
/// Called to toggle ambient lighting (passed to settings sheet)
|
||||
final VoidCallback? onToggleAmbientLighting;
|
||||
|
||||
/// Toast controller for VLC-style in-player notifications (rate changes, backend switch).
|
||||
final PlayerToastController toastController;
|
||||
|
||||
const PlexVideoControls({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.metadata,
|
||||
required this.toastController,
|
||||
this.onNext,
|
||||
this.onPrevious,
|
||||
this.availableVersions = const [],
|
||||
@@ -321,6 +329,11 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
late final FocusNode _skipMarkerFocusNode;
|
||||
double? _rateBeforeLongPress;
|
||||
bool _showSpeedIndicator = false;
|
||||
StreamSubscription<double>? _rateSubscription;
|
||||
double? _lastReportedRate;
|
||||
// Suppression window used when long-press ends so the rate-restore emission
|
||||
// doesn't flash a second pill as the rate snaps back.
|
||||
DateTime? _suppressRateToastUntil;
|
||||
|
||||
// PiP support
|
||||
bool _isPipSupported = false;
|
||||
@@ -368,11 +381,26 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
// Defer context-dependent initialization to after first build
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
// Subscribe to rate stream *after* first frame so the initial
|
||||
// setRate(defaultSpeed) emission during player startup is missed.
|
||||
_lastReportedRate = widget.player.state.rate;
|
||||
_rateSubscription = widget.player.streams.rate.listen(_onRateChanged);
|
||||
_loadPlaybackExtras();
|
||||
_focusPlayPauseIfKeyboardMode();
|
||||
});
|
||||
}
|
||||
|
||||
void _onRateChanged(double newRate) {
|
||||
if (!mounted) return;
|
||||
if (_isLongPressing) return;
|
||||
if (_suppressRateToastUntil != null && DateTime.now().isBefore(_suppressRateToastUntil!)) return;
|
||||
final prev = _lastReportedRate;
|
||||
if (prev != null && (prev - newRate).abs() < 0.005) return;
|
||||
_lastReportedRate = newRate;
|
||||
final icon = newRate >= 1.0 ? Symbols.fast_forward_rounded : Symbols.slow_motion_video_rounded;
|
||||
widget.toastController.show(icon, formatPlaybackRate(newRate));
|
||||
}
|
||||
|
||||
/// Called when hasFirstFrame changes - start auto-hide timer when first frame is ready
|
||||
void _onFirstFrameReady() {
|
||||
if (widget.hasFirstFrame?.value == true) {
|
||||
@@ -493,8 +521,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
final marker = _currentMarker!;
|
||||
final endTime = marker.endTime;
|
||||
final duration = widget.player.state.duration;
|
||||
final isAtEnd = duration > Duration.zero &&
|
||||
(duration - endTime).inMilliseconds <= 1000;
|
||||
final isAtEnd = duration > Duration.zero && (duration - endTime).inMilliseconds <= 1000;
|
||||
|
||||
if (marker.isCredits && isAtEnd) {
|
||||
// Credits extend to end of video — don't seek (unreliable due to
|
||||
@@ -703,6 +730,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
_playingSubscription?.cancel();
|
||||
_completedSubscription?.cancel();
|
||||
_positionSubscription?.cancel();
|
||||
_rateSubscription?.cancel();
|
||||
_focusNode.dispose();
|
||||
_skipMarkerFocusNode.dispose();
|
||||
// Restore original rate if long-press was active when disposed
|
||||
@@ -1019,7 +1047,12 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
final settings = await SettingsService.getInstance();
|
||||
final introPattern = settings.getIntroPattern();
|
||||
final creditsPattern = settings.getCreditsPattern();
|
||||
final extras = await client.getPlaybackExtras(widget.metadata.ratingKey, introPattern: introPattern, creditsPattern: creditsPattern, forceRefresh: forceRefresh);
|
||||
final extras = await client.getPlaybackExtras(
|
||||
widget.metadata.ratingKey,
|
||||
introPattern: introPattern,
|
||||
creditsPattern: creditsPattern,
|
||||
forceRefresh: forceRefresh,
|
||||
);
|
||||
appLogger.d('_loadPlaybackExtras: got ${extras.chapters.length} chapters');
|
||||
|
||||
if (mounted) {
|
||||
@@ -1118,9 +1151,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
isScreenLocked: _isScreenLocked,
|
||||
isFullscreen: _isFullscreen,
|
||||
isAlwaysOnTop: _isAlwaysOnTop,
|
||||
onTogglePIPMode: (_isPipSupported && !PlatformDetector.isTV())
|
||||
? widget.onTogglePIPMode
|
||||
: null,
|
||||
onTogglePIPMode: (_isPipSupported && !PlatformDetector.isTV()) ? widget.onTogglePIPMode : null,
|
||||
onCycleBoxFitMode: widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null,
|
||||
onToggleRotationLock: _toggleRotationLock,
|
||||
onToggleScreenLock: _toggleScreenLock,
|
||||
@@ -1459,6 +1490,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
/// Handle long-press end - restore original speed
|
||||
void _handleLongPressEnd() {
|
||||
if (!_isLongPressing) return;
|
||||
// Swallow the rate-restore emission so the stream-driven toast doesn't
|
||||
// flash as the rate snaps back to the prior value.
|
||||
_suppressRateToastUntil = DateTime.now().add(const Duration(milliseconds: 250));
|
||||
widget.player.setRate(_rateBeforeLongPress ?? 1.0);
|
||||
setState(() {
|
||||
_isLongPressing = false;
|
||||
@@ -1498,31 +1532,10 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
);
|
||||
}
|
||||
|
||||
/// Build the visual indicator for long-press 2x speed
|
||||
Widget _buildSpeedIndicator() {
|
||||
return Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 20),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const AppIcon(Symbols.fast_forward_rounded, fill: 1, color: Colors.white, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'2x',
|
||||
style: TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
/// Build the visual indicator for long-press 2x speed.
|
||||
/// Manual (persistent for duration of press) — separate from the stream-driven
|
||||
/// toast so it stays visible for the full long-press rather than auto-hiding.
|
||||
Widget _buildSpeedIndicator() => const PlayerToastIndicator(icon: Symbols.fast_forward_rounded, text: '2x');
|
||||
|
||||
Future<void> _toggleFullscreen() async {
|
||||
if (!PlatformDetector.isMobile(context)) {
|
||||
@@ -2173,6 +2186,26 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
),
|
||||
// Speed indicator overlay for long-press 2x
|
||||
if (_showSpeedIndicator) Positioned.fill(child: IgnorePointer(child: _buildSpeedIndicator())),
|
||||
// Stream-driven VLC-style pill (rate changes, backend-switch notifications)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: ListenableBuilder(
|
||||
listenable: widget.toastController,
|
||||
builder: (context, _) {
|
||||
final toast = widget.toastController.current;
|
||||
if (toast == null) return const SizedBox.shrink();
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: PlayerToastIndicator(
|
||||
key: ValueKey('${toast.icon.codePoint}:${toast.text}'),
|
||||
icon: toast.icon,
|
||||
text: toast.text,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
// Skip intro/credits button (auto-dismisses after 7s, then only shows with controls)
|
||||
if (_currentMarker != null && (!_skipButtonDismissed || _showControls))
|
||||
AnimatedPositioned(
|
||||
@@ -2230,7 +2263,11 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
t.videoControls.longPressToUnlock,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -2313,7 +2350,8 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
final hasNextEpisode = widget.onNext != null;
|
||||
|
||||
// Show "Next Episode" only when credits extend to end AND there's a next episode
|
||||
final bool creditsAtEnd = isCredits &&
|
||||
final bool creditsAtEnd =
|
||||
isCredits &&
|
||||
widget.player.state.duration > Duration.zero &&
|
||||
(widget.player.state.duration - _currentMarker!.endTime).inMilliseconds <= 1000;
|
||||
final bool showNextEpisode = creditsAtEnd && hasNextEpisode;
|
||||
@@ -2442,10 +2480,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
||||
if (token == null) return;
|
||||
|
||||
// Find external subtitle tracks from the refreshed metadata
|
||||
final existingUris = widget.player.state.tracks.subtitle
|
||||
.where((t) => t.uri != null)
|
||||
.map((t) => t.uri!)
|
||||
.toSet();
|
||||
final existingUris = widget.player.state.tracks.subtitle.where((t) => t.uri != null).map((t) => t.uri!).toSet();
|
||||
|
||||
for (final plexTrack in data.mediaInfo!.subtitleTracks) {
|
||||
if (!plexTrack.isExternal) continue;
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
|
||||
/// VLC-style dark pill shown at top-center of the video player.
|
||||
/// Used for rate changes and other transient in-player notifications.
|
||||
class PlayerToastIndicator extends StatelessWidget {
|
||||
const PlayerToastIndicator({super.key, required this.icon, required this.text});
|
||||
|
||||
final IconData icon;
|
||||
final String text;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.8),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(top: 20),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.7),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AppIcon(icon, fill: 1, color: Colors.white, size: 16),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
text,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the currently-displayed toast + auto-hide timer.
|
||||
/// Created per video-player session; disposed with the screen.
|
||||
class PlayerToastController extends ChangeNotifier {
|
||||
({IconData icon, String text})? _current;
|
||||
Timer? _timer;
|
||||
|
||||
({IconData icon, String text})? get current => _current;
|
||||
|
||||
void show(IconData icon, String text, {Duration duration = const Duration(milliseconds: 1200)}) {
|
||||
_timer?.cancel();
|
||||
_current = (icon: icon, text: text);
|
||||
notifyListeners();
|
||||
_timer = Timer(duration, () {
|
||||
_current = null;
|
||||
_timer = null;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
void hide() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
if (_current != null) {
|
||||
_current = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user