feat: rate pill, fix snackbar placement

close #886
This commit is contained in:
edde746
2026-04-18 08:37:43 +02:00
parent d2b01100a5
commit 2bd9e6655f
11 changed files with 374 additions and 221 deletions
+22 -22
View File
@@ -225,15 +225,11 @@ 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
@@ -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();
} }
@@ -455,13 +452,13 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
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) {
@@ -596,10 +595,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
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),
+11 -8
View File
@@ -22,10 +22,8 @@ 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,
}) : _syncService = syncService,
_downloadProvider = downloadProvider { _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(
(_) {
showMainSnackBar(t.messages.autoRemovedWatchedDownload(title: meta.title ?? 'Unknown'));
},
onError: (e) {
appLogger.w('Failed to auto-delete locally-watched download $globalKey: $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.
+12 -11
View File
@@ -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,6 +1031,8 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
} }
return OverlaySheetHost( return OverlaySheetHost(
child: ScaffoldMessenger(
key: mainScaffoldMessengerKey,
child: Scaffold( child: Scaffold(
body: _buildTickerAwareStack(), body: _buildTickerAwareStack(),
bottomNavigationBar: Column( bottomNavigationBar: Column(
@@ -1096,6 +1096,7 @@ class _MainScreenState extends State<MainScreen> with RouteAware, WindowListener
], ],
), ),
), ),
),
); );
} }
} }
+17 -11
View File
@@ -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';
}
} }
+12
View File
@@ -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(
+10 -1
View File
@@ -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) {
+14
View File
@@ -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,7 +241,8 @@ 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
.show(
alignment: Alignment.topCenter, alignment: Alignment.topCenter,
constraints: const BoxConstraints(maxHeight: 80, maxWidth: 900), constraints: const BoxConstraints(maxHeight: 80, maxWidth: 900),
initialFocusNode: sliderFocusNode, initialFocusNode: sliderFocusNode,
@@ -262,7 +263,8 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
widget.onSyncOffsetChanged?.call(propertyName, offset); widget.onSyncOffsetChanged?.call(propertyName, offset);
}, },
), ),
).whenComplete(() { )
.whenComplete(() {
widget.onStartAutoHide?.call(); widget.onStartAutoHide?.call();
}); });
@@ -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;
+72 -37
View File
@@ -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();
}
}