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