+48
-48
@@ -225,23 +225,19 @@ FutureOr<SentryEvent?> _beforeSend(SentryEvent event, Hint _) {
|
|||||||
bool shouldDrop(SentryException e) {
|
bool shouldDrop(SentryException e) {
|
||||||
final v = e.value;
|
final v = e.value;
|
||||||
// Windows file-lock errors from cache manager cleanup
|
// Windows file-lock errors from cache manager cleanup
|
||||||
if (e.type == 'FileSystemException' &&
|
if (e.type == 'FileSystemException' && v != null && v.contains('plexImageCache') && v.contains('errno = 32')) {
|
||||||
v != null &&
|
|
||||||
v.contains('plexImageCache') &&
|
|
||||||
v.contains('errno = 32')) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Linux without DBus/NetworkManager
|
// Linux without DBus/NetworkManager
|
||||||
if (e.type == 'DBusServiceUnknownException' ||
|
if (e.type == 'DBusServiceUnknownException' || (v != null && v.contains('system_bus_socket'))) {
|
||||||
(v != null && v.contains('system_bus_socket'))) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Device out of disk space
|
// Device out of disk space
|
||||||
if (v != null &&
|
if (v != null &&
|
||||||
(v.contains('SQLITE_FULL') ||
|
(v.contains('SQLITE_FULL') ||
|
||||||
v.contains('No space left on device') ||
|
v.contains('No space left on device') ||
|
||||||
v.contains('errno = 112') ||
|
v.contains('errno = 112') ||
|
||||||
v.contains('database or disk is full'))) {
|
v.contains('database or disk is full'))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Native HTTP errors from CFNetwork (server errors, not actionable)
|
// 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) {
|
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
|
||||||
_memoryCheckTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
_memoryCheckTimer = Timer.periodic(const Duration(seconds: 30), (_) {
|
||||||
final rss = ProcessInfo.currentRss;
|
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');
|
appLogger.w('RSS high ($rss bytes), evicting image caches');
|
||||||
_evictImageCaches();
|
_evictImageCaches();
|
||||||
}
|
}
|
||||||
@@ -446,23 +443,23 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
|||||||
if (_isAutoDeleteRunning) return;
|
if (_isAutoDeleteRunning) return;
|
||||||
_isAutoDeleteRunning = true;
|
_isAutoDeleteRunning = true;
|
||||||
try {
|
try {
|
||||||
await downloadProvider.refreshMetadataFromCache();
|
await downloadProvider.refreshMetadataFromCache();
|
||||||
final activeKey = VideoPlayerScreenState.activeRatingKey;
|
final activeKey = VideoPlayerScreenState.activeRatingKey;
|
||||||
final settings = SettingsService.instanceOrNull;
|
final settings = SettingsService.instanceOrNull;
|
||||||
if (settings != null && settings.getAutoRemoveWatchedDownloads()) {
|
if (settings != null && settings.getAutoRemoveWatchedDownloads()) {
|
||||||
final deleted = await downloadProvider.autoDeleteWatchedDownloads(activeRatingKey: activeKey);
|
final deleted = await downloadProvider.autoDeleteWatchedDownloads(activeRatingKey: activeKey);
|
||||||
if (deleted.isNotEmpty) {
|
if (deleted.isNotEmpty) {
|
||||||
final msg = deleted.length == 1
|
final msg = deleted.length == 1
|
||||||
? t.messages.autoRemovedWatchedDownload(title: deleted.first)
|
? t.messages.autoRemovedWatchedDownload(title: deleted.first)
|
||||||
: t.messages.autoRemovedWatchedDownload(title: '${deleted.length} items');
|
: t.messages.autoRemovedWatchedDownload(title: '${deleted.length} items');
|
||||||
showGlobalSnackBar(msg);
|
showMainSnackBar(msg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
final synced = await downloadProvider.executeSyncRules(_serverManager);
|
final synced = await downloadProvider.executeSyncRules(_serverManager);
|
||||||
if (synced.isNotEmpty) {
|
if (synced.isNotEmpty) {
|
||||||
showGlobalSnackBar(t.downloads.syncedNewEpisodes(count: synced.length.toString(), title: synced.first));
|
showMainSnackBar(t.downloads.syncedNewEpisodes(count: synced.length.toString(), title: synced.first));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
_isAutoDeleteRunning = false;
|
_isAutoDeleteRunning = false;
|
||||||
}
|
}
|
||||||
@@ -495,7 +492,8 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
|||||||
// SQLite WAL mode handles process death; desktop uses onExitRequested.
|
// SQLite WAL mode handles process death; desktop uses onExitRequested.
|
||||||
InAppReviewService.instance.endSession();
|
InAppReviewService.instance.endSession();
|
||||||
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
|
if (Platform.isWindows || Platform.isMacOS || Platform.isLinux) {
|
||||||
if (ProcessInfo.currentRss > 1024 * 1024 * 1024) { // 1GB
|
if (ProcessInfo.currentRss > 1024 * 1024 * 1024) {
|
||||||
|
// 1GB
|
||||||
_evictImageCaches();
|
_evictImageCaches();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -526,7 +524,8 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
|||||||
),
|
),
|
||||||
// Download provider
|
// Download provider
|
||||||
ChangeNotifierProvider(
|
ChangeNotifierProvider(
|
||||||
create: (context) => DownloadProvider(downloadManager: _downloadManager, database: _appDatabase)),
|
create: (context) => DownloadProvider(downloadManager: _downloadManager, database: _appDatabase),
|
||||||
|
),
|
||||||
// Offline watch sync service
|
// Offline watch sync service
|
||||||
ChangeNotifierProvider<OfflineWatchSyncService>(
|
ChangeNotifierProvider<OfflineWatchSyncService>(
|
||||||
create: (context) {
|
create: (context) {
|
||||||
@@ -586,24 +585,21 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
|||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
child: InputModeTracker(
|
child: InputModeTracker(
|
||||||
child: MaterialApp(
|
child: MaterialApp(
|
||||||
title: t.app.title,
|
title: t.app.title,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: themeProvider.lightTheme,
|
theme: themeProvider.lightTheme,
|
||||||
darkTheme: themeProvider.darkTheme,
|
darkTheme: themeProvider.darkTheme,
|
||||||
themeMode: themeProvider.materialThemeMode,
|
themeMode: themeProvider.materialThemeMode,
|
||||||
navigatorKey: rootNavigatorKey,
|
navigatorKey: rootNavigatorKey,
|
||||||
navigatorObservers: [routeObserver, BackKeySuppressorObserver()],
|
navigatorObservers: [routeObserver, BackKeySuppressorObserver()],
|
||||||
home: const OrientationAwareSetup(),
|
home: const OrientationAwareSetup(),
|
||||||
builder: (context, child) => ScaffoldMessenger(
|
builder: (context, child) => ScaffoldMessenger(
|
||||||
key: rootScaffoldMessengerKey,
|
key: rootScaffoldMessengerKey,
|
||||||
child: Scaffold(
|
child: Scaffold(backgroundColor: Colors.transparent, body: child),
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
body: child,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -786,9 +782,9 @@ class _SetupScreenState extends State<SetupScreen> {
|
|||||||
_statusMessage,
|
_statusMessage,
|
||||||
key: ValueKey(_statusMessage),
|
key: ValueKey(_statusMessage),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
style: Theme.of(
|
||||||
color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6),
|
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;
|
final Widget statusIcon;
|
||||||
if (connected == null) {
|
if (connected == null) {
|
||||||
statusIcon = const SizedBox(
|
statusIcon = const SizedBox(
|
||||||
width: 12, height: 12,
|
width: 12,
|
||||||
|
height: 12,
|
||||||
child: CircularProgressIndicator(strokeWidth: 1.5, color: coralColor),
|
child: CircularProgressIndicator(strokeWidth: 1.5, color: coralColor),
|
||||||
);
|
);
|
||||||
} else if (connected) {
|
} else if (connected) {
|
||||||
@@ -840,17 +837,20 @@ class _SetupScreenState extends State<SetupScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Center(child: SvgPicture.asset('assets/plezy_adaptive_foreground.svg', width: 288, height: 288)),
|
Center(child: SvgPicture.asset('assets/plezy_adaptive_foreground.svg', width: 288, height: 288)),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0, right: 0,
|
left: 0,
|
||||||
|
right: 0,
|
||||||
bottom: MediaQuery.of(context).size.height * 0.5 - 170,
|
bottom: MediaQuery.of(context).size.height * 0.5 - 170,
|
||||||
child: _buildStatusText(context),
|
child: _buildStatusText(context),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 0, right: 0,
|
left: 0,
|
||||||
|
right: 0,
|
||||||
top: MediaQuery.of(context).size.height * 0.5 + 180,
|
top: MediaQuery.of(context).size.height * 0.5 + 180,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: _serverStatus.isEmpty
|
child: _serverStatus.isEmpty
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 20, height: 20,
|
width: 20,
|
||||||
|
height: 20,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2, color: coralColor),
|
child: CircularProgressIndicator(strokeWidth: 2, color: coralColor),
|
||||||
)
|
)
|
||||||
: _buildServerStatusList(context),
|
: _buildServerStatusList(context),
|
||||||
|
|||||||
@@ -22,11 +22,9 @@ class OfflineWatchProvider extends ChangeNotifier {
|
|||||||
final OfflineWatchSyncService _syncService;
|
final OfflineWatchSyncService _syncService;
|
||||||
final DownloadProvider _downloadProvider;
|
final DownloadProvider _downloadProvider;
|
||||||
|
|
||||||
OfflineWatchProvider({
|
OfflineWatchProvider({required OfflineWatchSyncService syncService, required DownloadProvider downloadProvider})
|
||||||
required OfflineWatchSyncService syncService,
|
: _syncService = syncService,
|
||||||
required DownloadProvider downloadProvider,
|
_downloadProvider = downloadProvider {
|
||||||
}) : _syncService = syncService,
|
|
||||||
_downloadProvider = downloadProvider {
|
|
||||||
// Listen to sync service changes to update UI
|
// Listen to sync service changes to update UI
|
||||||
_syncService.addListener(_onSyncServiceChanged);
|
_syncService.addListener(_onSyncServiceChanged);
|
||||||
}
|
}
|
||||||
@@ -198,11 +196,16 @@ class OfflineWatchProvider extends ChangeNotifier {
|
|||||||
if (progress?.status != DownloadStatus.completed) return;
|
if (progress?.status != DownloadStatus.completed) return;
|
||||||
|
|
||||||
appLogger.i('Auto-deleting locally-watched download: ${meta.title} ($globalKey)');
|
appLogger.i('Auto-deleting locally-watched download: ${meta.title} ($globalKey)');
|
||||||
_downloadProvider.deleteDownload(globalKey).then((_) {
|
_downloadProvider
|
||||||
showGlobalSnackBar(t.messages.autoRemovedWatchedDownload(title: meta.title ?? 'Unknown'));
|
.deleteDownload(globalKey)
|
||||||
}, onError: (e) {
|
.then(
|
||||||
appLogger.w('Failed to auto-delete locally-watched download $globalKey: $e');
|
(_) {
|
||||||
});
|
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.
|
/// Mark an item as unwatched while offline.
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import 'dart:io' show Platform, exit;
|
import 'dart:io' show Platform, exit;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
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:material_symbols_icons/symbols.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -14,6 +15,7 @@ import '../focus/focusable_button.dart';
|
|||||||
import '../utils/dialogs.dart';
|
import '../utils/dialogs.dart';
|
||||||
import '../utils/provider_extensions.dart';
|
import '../utils/provider_extensions.dart';
|
||||||
import '../utils/platform_detector.dart';
|
import '../utils/platform_detector.dart';
|
||||||
|
import '../utils/snackbar_helper.dart';
|
||||||
import '../utils/video_player_navigation.dart';
|
import '../utils/video_player_navigation.dart';
|
||||||
import '../main.dart';
|
import '../main.dart';
|
||||||
import '../mixins/refreshable.dart';
|
import '../mixins/refreshable.dart';
|
||||||
@@ -553,13 +555,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
|||||||
Widget _buildTickerAwareStack() {
|
Widget _buildTickerAwareStack() {
|
||||||
return IndexedStack(
|
return IndexedStack(
|
||||||
index: _currentIndex,
|
index: _currentIndex,
|
||||||
children: [
|
children: [for (var i = 0; i < _screens.length; i++) TickerMode(enabled: i == _currentIndex, child: _screens[i])],
|
||||||
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))
|
for (final tab in _getVisibleTabs(offline))
|
||||||
switch (tab.id) {
|
switch (tab.id) {
|
||||||
NavigationTabId.discover => DiscoverScreen(key: _discoverKey),
|
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.liveTv => LiveTvScreen(key: _liveTvKey),
|
||||||
NavigationTabId.search => SearchScreen(key: _searchKey),
|
NavigationTabId.search => SearchScreen(key: _searchKey),
|
||||||
NavigationTabId.downloads => DownloadsScreen(key: _downloadsKey),
|
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.
|
// Track if we auto-switched to Downloads because the previous tab was unavailable.
|
||||||
_autoSwitchedToDownloads =
|
_autoSwitchedToDownloads =
|
||||||
previousTab != NavigationTabId.downloads &&
|
previousTab != NavigationTabId.downloads && normalizedTab == NavigationTabId.downloads;
|
||||||
normalizedTab == NavigationTabId.downloads;
|
|
||||||
} else {
|
} else {
|
||||||
// Coming back online: restore the last online tab if we forced a switch to Downloads.
|
// Coming back online: restore the last online tab if we forced a switch to Downloads.
|
||||||
if (_autoSwitchedToDownloads) {
|
if (_autoSwitchedToDownloads) {
|
||||||
@@ -1033,67 +1031,70 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
|
|||||||
}
|
}
|
||||||
|
|
||||||
return OverlaySheetHost(
|
return OverlaySheetHost(
|
||||||
child: Scaffold(
|
child: ScaffoldMessenger(
|
||||||
body: _buildTickerAwareStack(),
|
key: mainScaffoldMessengerKey,
|
||||||
bottomNavigationBar: Column(
|
child: Scaffold(
|
||||||
mainAxisSize: MainAxisSize.min,
|
body: _buildTickerAwareStack(),
|
||||||
children: [
|
bottomNavigationBar: Column(
|
||||||
// Reconnect bar when offline
|
mainAxisSize: MainAxisSize.min,
|
||||||
if (_isOffline)
|
children: [
|
||||||
Material(
|
// Reconnect bar when offline
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
if (_isOffline)
|
||||||
child: InkWell(
|
Material(
|
||||||
onTap: _isReconnecting ? null : _triggerReconnect,
|
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
child: Padding(
|
child: InkWell(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
onTap: _isReconnecting ? null : _triggerReconnect,
|
||||||
child: Row(
|
child: Padding(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
children: [
|
child: Row(
|
||||||
if (_isReconnecting)
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
SizedBox(
|
children: [
|
||||||
width: 16,
|
if (_isReconnecting)
|
||||||
height: 16,
|
SizedBox(
|
||||||
child: CircularProgressIndicator(
|
width: 16,
|
||||||
strokeWidth: 2,
|
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,
|
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 '../utils/video_player_navigation.dart';
|
||||||
import '../widgets/overlay_sheet.dart';
|
import '../widgets/overlay_sheet.dart';
|
||||||
import '../widgets/video_controls/video_controls.dart';
|
import '../widgets/video_controls/video_controls.dart';
|
||||||
|
import '../widgets/video_controls/widgets/player_toast_indicator.dart';
|
||||||
import '../focus/focusable_button.dart';
|
import '../focus/focusable_button.dart';
|
||||||
import '../focus/input_mode_tracker.dart';
|
import '../focus/input_mode_tracker.dart';
|
||||||
import '../focus/dpad_navigator.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
|
// Screen-level focus node: persists across loading/initialized phases so
|
||||||
// key events never escape the video player route.
|
// key events never escape the video player route.
|
||||||
late final FocusNode _screenFocusNode;
|
late final FocusNode _screenFocusNode;
|
||||||
|
|
||||||
|
// VLC-style in-player toast controller (rate changes, backend switch, etc.).
|
||||||
|
final PlayerToastController _toastController = PlayerToastController();
|
||||||
bool _reclaimingFocus = false;
|
bool _reclaimingFocus = false;
|
||||||
|
|
||||||
// Cached setting: when false on Windows/Linux, ESC should not exit the player
|
// 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
|
// 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.
|
// _onPlayerLog — the error-level log that follows omits the status code.
|
||||||
_logSubscription = player!.streams.log
|
_logSubscription = player!.streams.log
|
||||||
.where((log) => const {
|
.where((log) => const {PlayerLogLevel.fatal, PlayerLogLevel.error, PlayerLogLevel.warn}.contains(log.level))
|
||||||
PlayerLogLevel.fatal,
|
|
||||||
PlayerLogLevel.error,
|
|
||||||
PlayerLogLevel.warn,
|
|
||||||
}.contains(log.level))
|
|
||||||
.listen(_onPlayerLog);
|
.listen(_onPlayerLog);
|
||||||
|
|
||||||
// Listen for backend switched event (ExoPlayer -> MPV fallback on Android)
|
// Listen for backend switched event (ExoPlayer -> MPV fallback on Android)
|
||||||
@@ -2034,6 +2034,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
_hasFirstFrame.dispose();
|
_hasFirstFrame.dispose();
|
||||||
_isExiting.dispose();
|
_isExiting.dispose();
|
||||||
_controlsVisible.dispose();
|
_controlsVisible.dispose();
|
||||||
|
_toastController.dispose();
|
||||||
|
|
||||||
// Stop progress tracking and send final state.
|
// Stop progress tracking and send final state.
|
||||||
// Fire-and-forget: dispose() is synchronous so we can't await, but the
|
// 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;
|
String? _lastLogError;
|
||||||
bool _sawServer500 = false;
|
bool _sawServer500 = false;
|
||||||
|
|
||||||
static final RegExp _server500Pattern =
|
static final RegExp _server500Pattern = RegExp(r'\b(?:HTTP error |Response code: )500\b');
|
||||||
RegExp(r'\b(?:HTTP error |Response code: )500\b');
|
|
||||||
|
|
||||||
void _onPlayerLog(PlayerLog log) {
|
void _onPlayerLog(PlayerLog log) {
|
||||||
if (!_sawServer500 && _server500Pattern.hasMatch(log.text)) {
|
if (!_sawServer500 && _server500Pattern.hasMatch(log.text)) {
|
||||||
@@ -2309,9 +2309,11 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
_playerBackendLabel = 'mpv';
|
_playerBackendLabel = 'mpv';
|
||||||
_recordLifecycleState('backend_switched', action: 'mpv_fallback');
|
_recordLifecycleState('backend_switched', action: 'mpv_fallback');
|
||||||
|
|
||||||
if (mounted) {
|
_toastController.show(
|
||||||
showAppSnackBar(context, t.messages.switchingToCompatiblePlayer);
|
Symbols.swap_horiz_rounded,
|
||||||
}
|
t.messages.switchingToCompatiblePlayer,
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
);
|
||||||
|
|
||||||
await _trackManager?.onBackendSwitched();
|
await _trackManager?.onBackendSwitched();
|
||||||
}
|
}
|
||||||
@@ -2944,7 +2946,10 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
if (widget.isOffline) {
|
if (widget.isOffline) {
|
||||||
result = await _startOfflinePlayback();
|
result = await _startOfflinePlayback();
|
||||||
} else {
|
} else {
|
||||||
final playbackService = PlaybackInitializationService(client: client!, database: PlexApiCache.instance.database);
|
final playbackService = PlaybackInitializationService(
|
||||||
|
client: client!,
|
||||||
|
database: PlexApiCache.instance.database,
|
||||||
|
);
|
||||||
result = await playbackService.getPlaybackData(
|
result = await playbackService.getPlaybackData(
|
||||||
metadata: episodeMetadata,
|
metadata: episodeMetadata,
|
||||||
selectedMediaIndex: widget.selectedMediaIndex,
|
selectedMediaIndex: widget.selectedMediaIndex,
|
||||||
@@ -3312,6 +3317,7 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
onJumpToLive: _captureBuffer != null && !_isAtLiveEdge ? _jumpToLiveEdge : null,
|
onJumpToLive: _captureBuffer != null && !_isAtLiveEdge ? _jumpToLiveEdge : null,
|
||||||
isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false,
|
isAmbientLightingEnabled: _ambientLightingService?.isEnabled ?? false,
|
||||||
onToggleAmbientLighting: _toggleAmbientLighting,
|
onToggleAmbientLighting: _toggleAmbientLighting,
|
||||||
|
toastController: _toastController,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import '../i18n/strings.g.dart';
|
|||||||
import '../mpv/mpv.dart';
|
import '../mpv/mpv.dart';
|
||||||
import 'settings_service.dart';
|
import 'settings_service.dart';
|
||||||
import '../utils/player_utils.dart';
|
import '../utils/player_utils.dart';
|
||||||
import '../utils/snackbar_helper.dart';
|
|
||||||
|
|
||||||
class KeyboardShortcutsService {
|
class KeyboardShortcutsService {
|
||||||
static KeyboardShortcutsService? _instance;
|
static KeyboardShortcutsService? _instance;
|
||||||
@@ -294,18 +293,15 @@ class KeyboardShortcutsService {
|
|||||||
final newRateUp = (player.state.rate + 0.25).clamp(0.25, 3.0);
|
final newRateUp = (player.state.rate + 0.25).clamp(0.25, 3.0);
|
||||||
player.setRate(newRateUp);
|
player.setRate(newRateUp);
|
||||||
_settingsService.setDefaultPlaybackSpeed(newRateUp);
|
_settingsService.setDefaultPlaybackSpeed(newRateUp);
|
||||||
showGlobalSnackBar(_formatSpeed(newRateUp));
|
|
||||||
break;
|
break;
|
||||||
case 'speed_decrease':
|
case 'speed_decrease':
|
||||||
final newRateDown = (player.state.rate - 0.25).clamp(0.25, 3.0);
|
final newRateDown = (player.state.rate - 0.25).clamp(0.25, 3.0);
|
||||||
player.setRate(newRateDown);
|
player.setRate(newRateDown);
|
||||||
_settingsService.setDefaultPlaybackSpeed(newRateDown);
|
_settingsService.setDefaultPlaybackSpeed(newRateDown);
|
||||||
showGlobalSnackBar(_formatSpeed(newRateDown));
|
|
||||||
break;
|
break;
|
||||||
case 'speed_reset':
|
case 'speed_reset':
|
||||||
player.setRate(1.0);
|
player.setRate(1.0);
|
||||||
_settingsService.setDefaultPlaybackSpeed(1.0);
|
_settingsService.setDefaultPlaybackSpeed(1.0);
|
||||||
showGlobalSnackBar(_formatSpeed(1.0));
|
|
||||||
break;
|
break;
|
||||||
case 'sub_seek_next':
|
case 'sub_seek_next':
|
||||||
player.command(['sub-seek', '1']);
|
player.command(['sub-seek', '1']);
|
||||||
@@ -391,9 +387,4 @@ class KeyboardShortcutsService {
|
|||||||
|
|
||||||
return aModifiers.length == bModifiers.length && aModifiers.every((modifier) => bModifiers.contains(modifier));
|
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);
|
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(
|
return base.copyWith(
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ class ByteFormatter {
|
|||||||
if (kbps < 1000) return '$kbps kbps';
|
if (kbps < 1000) return '$kbps kbps';
|
||||||
return '${(kbps / 1000).toStringAsFixed(1)} Mbps';
|
return '${(kbps / 1000).toStringAsFixed(1)} Mbps';
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formats a duration in human-readable textual format (e.g., "1h 23m" or "1 hour 23 minutes").
|
/// 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(' · ');
|
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
|
/// 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
|
/// If there is any error, `dateString` is returned as is
|
||||||
String formatFullDate(String dateString) {
|
String formatFullDate(String dateString) {
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ import 'package:flutter/material.dart';
|
|||||||
/// Global key for the root ScaffoldMessenger, allowing snackbars to survive navigation.
|
/// Global key for the root ScaffoldMessenger, allowing snackbars to survive navigation.
|
||||||
final rootScaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
|
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
|
/// Types of snackbars available in the app
|
||||||
enum SnackBarType {
|
enum SnackBarType {
|
||||||
/// Standard informational snackbar
|
/// Standard informational snackbar
|
||||||
@@ -68,6 +72,16 @@ void showGlobalSnackBar(String message, {Duration duration = const Duration(seco
|
|||||||
..showSnackBar(SnackBar(content: Text(message), duration: duration));
|
..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
|
/// Shows a success snackbar with a message
|
||||||
///
|
///
|
||||||
/// [context] The build context
|
/// [context] The build context
|
||||||
|
|||||||
@@ -241,30 +241,32 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
|||||||
// show() with new alignment replaces the current sheet (completing the
|
// show() with new alignment replaces the current sheet (completing the
|
||||||
// settings sheet future, which restarts the auto-hide timer via
|
// settings sheet future, which restarts the auto-hide timer via
|
||||||
// whenComplete in track_chapter_controls). Cancel it again here.
|
// whenComplete in track_chapter_controls). Cancel it again here.
|
||||||
controller.show(
|
controller
|
||||||
alignment: Alignment.topCenter,
|
.show(
|
||||||
constraints: const BoxConstraints(maxHeight: 80, maxWidth: 900),
|
alignment: Alignment.topCenter,
|
||||||
initialFocusNode: sliderFocusNode,
|
constraints: const BoxConstraints(maxHeight: 80, maxWidth: 900),
|
||||||
builder: (_) => _CompactSyncBar(
|
initialFocusNode: sliderFocusNode,
|
||||||
title: title,
|
builder: (_) => _CompactSyncBar(
|
||||||
icon: icon,
|
title: title,
|
||||||
player: widget.player,
|
icon: icon,
|
||||||
propertyName: propertyName,
|
player: widget.player,
|
||||||
initialOffset: initialOffset,
|
propertyName: propertyName,
|
||||||
sliderFocusNode: sliderFocusNode,
|
initialOffset: initialOffset,
|
||||||
onOffsetChanged: (offset) async {
|
sliderFocusNode: sliderFocusNode,
|
||||||
final settings = await SettingsService.getInstance();
|
onOffsetChanged: (offset) async {
|
||||||
if (isSubtitle) {
|
final settings = await SettingsService.getInstance();
|
||||||
await settings.setSubtitleSyncOffset(offset);
|
if (isSubtitle) {
|
||||||
} else {
|
await settings.setSubtitleSyncOffset(offset);
|
||||||
await settings.setAudioSyncOffset(offset);
|
} else {
|
||||||
}
|
await settings.setAudioSyncOffset(offset);
|
||||||
widget.onSyncOffsetChanged?.call(propertyName, offset);
|
}
|
||||||
},
|
widget.onSyncOffsetChanged?.call(propertyName, offset);
|
||||||
),
|
},
|
||||||
).whenComplete(() {
|
),
|
||||||
widget.onStartAutoHide?.call();
|
)
|
||||||
});
|
.whenComplete(() {
|
||||||
|
widget.onStartAutoHide?.call();
|
||||||
|
});
|
||||||
|
|
||||||
// Cancel auto-hide after show() — the previous sheet's whenComplete
|
// Cancel auto-hide after show() — the previous sheet's whenComplete
|
||||||
// fires as a microtask and restarts the timer, so schedule our cancel
|
// 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) {
|
String _formatSleepTimer(SleepTimerService sleepTimer) {
|
||||||
if (!sleepTimer.isActive) return 'Off';
|
if (!sleepTimer.isActive) return 'Off';
|
||||||
final remaining = sleepTimer.remainingTime;
|
final remaining = sleepTimer.remainingTime;
|
||||||
@@ -345,7 +342,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
|||||||
return _SettingsMenuItem(
|
return _SettingsMenuItem(
|
||||||
icon: Symbols.speed_rounded,
|
icon: Symbols.speed_rounded,
|
||||||
title: t.videoSettings.playbackSpeed,
|
title: t.videoSettings.playbackSpeed,
|
||||||
valueText: _formatSpeed(currentRate),
|
valueText: formatPlaybackRate(currentRate, normalAtOne: true),
|
||||||
onTap: () => _navigateTo(_SettingsView.speed),
|
onTap: () => _navigateTo(_SettingsView.speed),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -387,7 +384,11 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
|||||||
// HDR Toggle (iOS, macOS, and Windows)
|
// HDR Toggle (iOS, macOS, and Windows)
|
||||||
if (Platform.isIOS || Platform.isMacOS || Platform.isWindows)
|
if (Platform.isIOS || Platform.isMacOS || Platform.isWindows)
|
||||||
FocusableListTile(
|
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),
|
title: Text(t.videoSettings.hdr),
|
||||||
trailing: Switch(value: _enableHDR, onChanged: (_) => _toggleHDR(), activeThumbColor: Colors.amber),
|
trailing: Switch(value: _enableHDR, onChanged: (_) => _toggleHDR(), activeThumbColor: Colors.amber),
|
||||||
onTap: _toggleHDR,
|
onTap: _toggleHDR,
|
||||||
@@ -416,9 +417,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
|||||||
initialData: widget.player.state.audioDevice,
|
initialData: widget.player.state.audioDevice,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final currentDevice = snapshot.data ?? widget.player.state.audioDevice;
|
final currentDevice = snapshot.data ?? widget.player.state.audioDevice;
|
||||||
final deviceLabel = currentDevice.description.isEmpty
|
final deviceLabel = currentDevice.description.isEmpty ? currentDevice.name : currentDevice.description;
|
||||||
? currentDevice.name
|
|
||||||
: currentDevice.description;
|
|
||||||
|
|
||||||
return _SettingsMenuItem(
|
return _SettingsMenuItem(
|
||||||
icon: Symbols.speaker_rounded,
|
icon: Symbols.speaker_rounded,
|
||||||
@@ -553,7 +552,7 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
|||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final speed = speeds[index];
|
final speed = speeds[index];
|
||||||
final isSelected = (currentRate - speed).abs() < 0.01;
|
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;
|
final primary = Theme.of(context).colorScheme.primary;
|
||||||
return FocusableListTile(
|
return FocusableListTile(
|
||||||
@@ -578,7 +577,11 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
|||||||
Widget _buildSleepView() {
|
Widget _buildSleepView() {
|
||||||
final sleepTimer = SleepTimerService();
|
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()
|
// 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 {
|
Future<void> _importCustomShader(ShaderProvider shaderProvider) async {
|
||||||
final result = await FilePickerService.instance.pickFiles(
|
final result = await FilePickerService.instance.pickFiles(type: FileType.custom, allowedExtensions: ['glsl']);
|
||||||
type: FileType.custom,
|
|
||||||
allowedExtensions: ['glsl'],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result == null || result.files.isEmpty || !mounted) return;
|
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 '../../focus/key_event_utils.dart';
|
||||||
import '../../services/keyboard_shortcuts_service.dart';
|
import '../../services/keyboard_shortcuts_service.dart';
|
||||||
import '../../services/settings_service.dart';
|
import '../../services/settings_service.dart';
|
||||||
|
import '../../utils/formatters.dart';
|
||||||
import '../../utils/platform_detector.dart';
|
import '../../utils/platform_detector.dart';
|
||||||
import '../../utils/plex_cache_parser.dart';
|
import '../../utils/plex_cache_parser.dart';
|
||||||
import '../../utils/player_utils.dart';
|
import '../../utils/player_utils.dart';
|
||||||
@@ -44,6 +45,7 @@ import '../../theme/mono_tokens.dart';
|
|||||||
import '../../utils/provider_extensions.dart';
|
import '../../utils/provider_extensions.dart';
|
||||||
import '../../utils/snackbar_helper.dart';
|
import '../../utils/snackbar_helper.dart';
|
||||||
import 'icons.dart';
|
import 'icons.dart';
|
||||||
|
import 'widgets/player_toast_indicator.dart';
|
||||||
import '../../utils/app_logger.dart';
|
import '../../utils/app_logger.dart';
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
import '../../focus/input_mode_tracker.dart';
|
import '../../focus/input_mode_tracker.dart';
|
||||||
@@ -94,10 +96,12 @@ Widget plexVideoControlsBuilder(
|
|||||||
VoidCallback? onJumpToLive,
|
VoidCallback? onJumpToLive,
|
||||||
bool isAmbientLightingEnabled = false,
|
bool isAmbientLightingEnabled = false,
|
||||||
VoidCallback? onToggleAmbientLighting,
|
VoidCallback? onToggleAmbientLighting,
|
||||||
|
required PlayerToastController toastController,
|
||||||
}) {
|
}) {
|
||||||
return PlexVideoControls(
|
return PlexVideoControls(
|
||||||
player: player,
|
player: player,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
|
toastController: toastController,
|
||||||
onNext: onNext,
|
onNext: onNext,
|
||||||
onPrevious: onPrevious,
|
onPrevious: onPrevious,
|
||||||
availableVersions: availableVersions ?? [],
|
availableVersions: availableVersions ?? [],
|
||||||
@@ -205,10 +209,14 @@ class PlexVideoControls extends StatefulWidget {
|
|||||||
/// Called to toggle ambient lighting (passed to settings sheet)
|
/// Called to toggle ambient lighting (passed to settings sheet)
|
||||||
final VoidCallback? onToggleAmbientLighting;
|
final VoidCallback? onToggleAmbientLighting;
|
||||||
|
|
||||||
|
/// Toast controller for VLC-style in-player notifications (rate changes, backend switch).
|
||||||
|
final PlayerToastController toastController;
|
||||||
|
|
||||||
const PlexVideoControls({
|
const PlexVideoControls({
|
||||||
super.key,
|
super.key,
|
||||||
required this.player,
|
required this.player,
|
||||||
required this.metadata,
|
required this.metadata,
|
||||||
|
required this.toastController,
|
||||||
this.onNext,
|
this.onNext,
|
||||||
this.onPrevious,
|
this.onPrevious,
|
||||||
this.availableVersions = const [],
|
this.availableVersions = const [],
|
||||||
@@ -321,6 +329,11 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
late final FocusNode _skipMarkerFocusNode;
|
late final FocusNode _skipMarkerFocusNode;
|
||||||
double? _rateBeforeLongPress;
|
double? _rateBeforeLongPress;
|
||||||
bool _showSpeedIndicator = false;
|
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
|
// PiP support
|
||||||
bool _isPipSupported = false;
|
bool _isPipSupported = false;
|
||||||
@@ -368,11 +381,26 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
// Defer context-dependent initialization to after first build
|
// Defer context-dependent initialization to after first build
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
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();
|
_loadPlaybackExtras();
|
||||||
_focusPlayPauseIfKeyboardMode();
|
_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
|
/// Called when hasFirstFrame changes - start auto-hide timer when first frame is ready
|
||||||
void _onFirstFrameReady() {
|
void _onFirstFrameReady() {
|
||||||
if (widget.hasFirstFrame?.value == true) {
|
if (widget.hasFirstFrame?.value == true) {
|
||||||
@@ -493,8 +521,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
final marker = _currentMarker!;
|
final marker = _currentMarker!;
|
||||||
final endTime = marker.endTime;
|
final endTime = marker.endTime;
|
||||||
final duration = widget.player.state.duration;
|
final duration = widget.player.state.duration;
|
||||||
final isAtEnd = duration > Duration.zero &&
|
final isAtEnd = duration > Duration.zero && (duration - endTime).inMilliseconds <= 1000;
|
||||||
(duration - endTime).inMilliseconds <= 1000;
|
|
||||||
|
|
||||||
if (marker.isCredits && isAtEnd) {
|
if (marker.isCredits && isAtEnd) {
|
||||||
// Credits extend to end of video — don't seek (unreliable due to
|
// 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();
|
_playingSubscription?.cancel();
|
||||||
_completedSubscription?.cancel();
|
_completedSubscription?.cancel();
|
||||||
_positionSubscription?.cancel();
|
_positionSubscription?.cancel();
|
||||||
|
_rateSubscription?.cancel();
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
_skipMarkerFocusNode.dispose();
|
_skipMarkerFocusNode.dispose();
|
||||||
// Restore original rate if long-press was active when disposed
|
// 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 settings = await SettingsService.getInstance();
|
||||||
final introPattern = settings.getIntroPattern();
|
final introPattern = settings.getIntroPattern();
|
||||||
final creditsPattern = settings.getCreditsPattern();
|
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');
|
appLogger.d('_loadPlaybackExtras: got ${extras.chapters.length} chapters');
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -1118,9 +1151,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
isScreenLocked: _isScreenLocked,
|
isScreenLocked: _isScreenLocked,
|
||||||
isFullscreen: _isFullscreen,
|
isFullscreen: _isFullscreen,
|
||||||
isAlwaysOnTop: _isAlwaysOnTop,
|
isAlwaysOnTop: _isAlwaysOnTop,
|
||||||
onTogglePIPMode: (_isPipSupported && !PlatformDetector.isTV())
|
onTogglePIPMode: (_isPipSupported && !PlatformDetector.isTV()) ? widget.onTogglePIPMode : null,
|
||||||
? widget.onTogglePIPMode
|
|
||||||
: null,
|
|
||||||
onCycleBoxFitMode: widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null,
|
onCycleBoxFitMode: widget.player.playerType != 'exoplayer' ? widget.onCycleBoxFitMode : null,
|
||||||
onToggleRotationLock: _toggleRotationLock,
|
onToggleRotationLock: _toggleRotationLock,
|
||||||
onToggleScreenLock: _toggleScreenLock,
|
onToggleScreenLock: _toggleScreenLock,
|
||||||
@@ -1459,6 +1490,9 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
/// Handle long-press end - restore original speed
|
/// Handle long-press end - restore original speed
|
||||||
void _handleLongPressEnd() {
|
void _handleLongPressEnd() {
|
||||||
if (!_isLongPressing) return;
|
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);
|
widget.player.setRate(_rateBeforeLongPress ?? 1.0);
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLongPressing = false;
|
_isLongPressing = false;
|
||||||
@@ -1498,31 +1532,10 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the visual indicator for long-press 2x speed
|
/// Build the visual indicator for long-press 2x speed.
|
||||||
Widget _buildSpeedIndicator() {
|
/// Manual (persistent for duration of press) — separate from the stream-driven
|
||||||
return Align(
|
/// toast so it stays visible for the full long-press rather than auto-hiding.
|
||||||
alignment: Alignment.topCenter,
|
Widget _buildSpeedIndicator() => const PlayerToastIndicator(icon: Symbols.fast_forward_rounded, text: '2x');
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _toggleFullscreen() async {
|
Future<void> _toggleFullscreen() async {
|
||||||
if (!PlatformDetector.isMobile(context)) {
|
if (!PlatformDetector.isMobile(context)) {
|
||||||
@@ -2173,6 +2186,26 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
),
|
),
|
||||||
// Speed indicator overlay for long-press 2x
|
// Speed indicator overlay for long-press 2x
|
||||||
if (_showSpeedIndicator) Positioned.fill(child: IgnorePointer(child: _buildSpeedIndicator())),
|
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)
|
// Skip intro/credits button (auto-dismisses after 7s, then only shows with controls)
|
||||||
if (_currentMarker != null && (!_skipButtonDismissed || _showControls))
|
if (_currentMarker != null && (!_skipButtonDismissed || _showControls))
|
||||||
AnimatedPositioned(
|
AnimatedPositioned(
|
||||||
@@ -2230,7 +2263,11 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
t.videoControls.longPressToUnlock,
|
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;
|
final hasNextEpisode = widget.onNext != null;
|
||||||
|
|
||||||
// Show "Next Episode" only when credits extend to end AND there's a next episode
|
// 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 > Duration.zero &&
|
||||||
(widget.player.state.duration - _currentMarker!.endTime).inMilliseconds <= 1000;
|
(widget.player.state.duration - _currentMarker!.endTime).inMilliseconds <= 1000;
|
||||||
final bool showNextEpisode = creditsAtEnd && hasNextEpisode;
|
final bool showNextEpisode = creditsAtEnd && hasNextEpisode;
|
||||||
@@ -2442,10 +2480,7 @@ class _PlexVideoControlsState extends State<PlexVideoControls> with WindowListen
|
|||||||
if (token == null) return;
|
if (token == null) return;
|
||||||
|
|
||||||
// Find external subtitle tracks from the refreshed metadata
|
// Find external subtitle tracks from the refreshed metadata
|
||||||
final existingUris = widget.player.state.tracks.subtitle
|
final existingUris = widget.player.state.tracks.subtitle.where((t) => t.uri != null).map((t) => t.uri!).toSet();
|
||||||
.where((t) => t.uri != null)
|
|
||||||
.map((t) => t.uri!)
|
|
||||||
.toSet();
|
|
||||||
|
|
||||||
for (final plexTrack in data.mediaInfo!.subtitleTracks) {
|
for (final plexTrack in data.mediaInfo!.subtitleTracks) {
|
||||||
if (!plexTrack.isExternal) continue;
|
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