From 8b6052d97b2507f8353ad464472b147ac77d5eb0 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Wed, 5 Nov 2025 16:46:31 +0100 Subject: [PATCH] feat: rotation lock --- lib/screens/video_player_screen.dart | 83 ++++--- lib/services/settings_service.dart | 10 + .../video_controls/video_controls.dart | 232 ++++++++++++------ 3 files changed, 220 insertions(+), 105 deletions(-) diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 6698b3a0..1b965b13 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -35,10 +35,10 @@ class VideoPlayerScreen extends StatefulWidget { }); @override - State createState() => _VideoPlayerScreenState(); + State createState() => VideoPlayerScreenState(); } -class _VideoPlayerScreenState extends State { +class VideoPlayerScreenState extends State { Player? player; VideoController? controller; bool _isPlayerInitialized = false; @@ -52,6 +52,9 @@ class _VideoPlayerScreenState extends State { List _availableVersions = []; StreamSubscription? _logSubscription; StreamSubscription? _errorSubscription; + StreamSubscription? _playingSubscription; + StreamSubscription? _completedSubscription; + bool _isReplacingWithVideo = false; // Flag to skip orientation restoration during video-to-video navigation // BoxFit mode state: 0=contain (letterbox), 1=cover (fill screen), 2=fill (stretch) int _boxFitMode = 0; @@ -99,10 +102,6 @@ class _VideoPlayerScreenState extends State { } } - void _setLandscapeOrientation() { - OrientationHelper.setLandscapeOrientation(); - } - Future _initializePlayer() async { try { // Load buffer size from settings @@ -167,21 +166,31 @@ class _VideoPlayerScreenState extends State { // Load available media versions _loadMediaVersions(); - // Set fullscreen mode and landscape orientation + // Set fullscreen mode and orientation based on rotation lock setting if (mounted) { try { - _setLandscapeOrientation(); + // Check rotation lock setting before applying orientation + final isRotationLocked = settingsService.getRotationLocked(); + + if (isRotationLocked) { + // Locked: Apply landscape orientation only + OrientationHelper.setLandscapeOrientation(); + } else { + // Unlocked: Allow all orientations immediately + SystemChrome.setPreferredOrientations(DeviceOrientation.values); + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + } } catch (e) { - appLogger.w('Failed to set landscape orientation', error: e); + appLogger.w('Failed to set orientation', error: e); // Don't crash if orientation fails - video can still play } } // Listen to playback state changes - player!.stream.playing.listen(_onPlayingStateChanged); + _playingSubscription = player!.stream.playing.listen(_onPlayingStateChanged); // Listen to completion - player!.stream.completed.listen(_onVideoCompleted); + _completedSubscription = player!.stream.completed.listen(_onVideoCompleted); // Listen to MPV logs _logSubscription = player!.stream.log.listen(_onPlayerLog); @@ -357,34 +366,38 @@ class _VideoPlayerScreenState extends State { _progressTimer?.cancel(); // Cancel stream subscriptions + _playingSubscription?.cancel(); + _completedSubscription?.cancel(); _logSubscription?.cancel(); _errorSubscription?.cancel(); // Send final stopped state _sendProgress('stopped'); - // Restore system UI and orientation preferences - OrientationHelper.restoreSystemUI(); + // Restore system UI and orientation preferences (skip if navigating to another video) + if (!_isReplacingWithVideo) { + OrientationHelper.restoreSystemUI(); - // Restore orientation based on cached device type (no context needed) - try { - if (_isPhone) { - // Phone: portrait only - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]); - } else { - // Tablet/Desktop: all orientations - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ]); + // Restore orientation based on cached device type (no context needed) + try { + if (_isPhone) { + // Phone: portrait only + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + } else { + // Tablet/Desktop: all orientations + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } + } catch (e) { + appLogger.w('Failed to restore orientation in dispose', error: e); } - } catch (e) { - appLogger.w('Failed to restore orientation in dispose', error: e); } player?.dispose(); @@ -1028,8 +1041,16 @@ class _VideoPlayerScreenState extends State { await _navigateToEpisode(_previousEpisode!); } + /// Set flag to skip orientation restoration when replacing with another video + void setReplacingWithVideo() { + _isReplacingWithVideo = true; + } + /// Navigates to a new episode, preserving playback state and track selections Future _navigateToEpisode(PlexMetadata episodeMetadata) async { + // Set flag to skip orientation restoration in dispose() + _isReplacingWithVideo = true; + // If player isn't available, navigate without preserving settings if (player == null) { if (mounted) { diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 5e06f5dd..79e2932a 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -30,6 +30,7 @@ class SettingsService { static const String _keyAudioSyncOffset = 'audio_sync_offset'; static const String _keySubtitleSyncOffset = 'subtitle_sync_offset'; static const String _keyVolume = 'volume'; + static const String _keyRotationLocked = 'rotation_locked'; static SettingsService? _instance; late SharedPreferences _prefs; @@ -210,6 +211,15 @@ class SettingsService { return _prefs.getDouble(_keyVolume) ?? 100.0; // Default: full volume } + // Rotation Lock (mobile only) + Future setRotationLocked(bool locked) async { + await _prefs.setBool(_keyRotationLocked, locked); + } + + bool getRotationLocked() { + return _prefs.getBool(_keyRotationLocked) ?? true; // Default: locked (landscape only) + } + // Keyboard Shortcuts (Legacy String-based) Map getDefaultKeyboardShortcuts() { return { diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index e8928512..c1ad99e4 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:io' show Platform; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show SystemChrome, DeviceOrientation; import 'package:media_kit/media_kit.dart'; import 'package:window_manager/window_manager.dart'; import 'package:macos_window_utils/macos_window_utils.dart'; @@ -84,6 +85,7 @@ class _PlexVideoControlsState extends State int _seekTimeSmall = 10; // Default, loaded from settings int _audioSyncOffset = 0; // Default, loaded from settings int _subtitleSyncOffset = 0; // Default, loaded from settings + bool _isRotationLocked = true; // Default locked (landscape only) // Double-tap feedback state bool _showDoubleTapFeedback = false; double _doubleTapFeedbackOpacity = 0.0; @@ -117,7 +119,18 @@ class _PlexVideoControlsState extends State _seekTimeSmall = settingsService.getSeekTimeSmall(); _audioSyncOffset = settingsService.getAudioSyncOffset(); _subtitleSyncOffset = settingsService.getSubtitleSyncOffset(); + _isRotationLocked = settingsService.getRotationLocked(); }); + + // Apply rotation lock setting + if (_isRotationLocked) { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } else { + SystemChrome.setPreferredOrientations(DeviceOrientation.values); + } } } @@ -242,6 +255,27 @@ class _PlexVideoControlsState extends State } } + void _toggleRotationLock() async { + setState(() { + _isRotationLocked = !_isRotationLocked; + }); + + // Save to settings + final settingsService = await SettingsService.getInstance(); + await settingsService.setRotationLocked(_isRotationLocked); + + if (_isRotationLocked) { + // Locked: Allow landscape orientations only + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } else { + // Unlocked: Allow all orientations including portrait + SystemChrome.setPreferredOrientations(DeviceOrientation.values); + } + } + void _updateTrafficLightVisibility() async { if (Platform.isMacOS) { if (_showControls) { @@ -312,6 +346,28 @@ class _PlexVideoControlsState extends State } } + /// Conditionally wraps child with SafeArea only in portrait mode + Widget _conditionalSafeArea({ + required Widget child, + bool top = true, + bool bottom = true, + }) { + final orientation = MediaQuery.of(context).orientation; + final isPortrait = orientation == Orientation.portrait; + + // Only apply SafeArea in portrait mode + if (isPortrait) { + return SafeArea( + top: top, + bottom: bottom, + child: child, + ); + } + + // In landscape, return child without SafeArea + return child; + } + Widget _buildTrackAndChapterControls() { return StreamBuilder( stream: widget.player.stream.tracks, @@ -391,6 +447,16 @@ class _PlexVideoControlsState extends State tooltip: _getBoxFitTooltip(widget.boxFitMode), onPressed: widget.onCycleBoxFitMode, ), + // Rotation lock toggle (mobile only) + if (PlatformDetector.isMobile(context)) + IconButton( + icon: Icon( + _isRotationLocked ? Icons.screen_lock_rotation : Icons.screen_rotation, + color: Colors.white, + ), + tooltip: _isRotationLocked ? 'Unlock rotation' : 'Lock rotation', + onPressed: _toggleRotationLock, + ), // Fullscreen toggle (desktop only) if (Platform.isWindows || Platform.isLinux || Platform.isMacOS) IconButton( @@ -791,43 +857,46 @@ class _PlexVideoControlsState extends State // Mobile layout components Widget _buildMobileTopBar() { - final topBar = Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - AppBarBackButton( - style: BackButtonStyle.video, - onPressed: () => Navigator.of(context).pop(true), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - widget.metadata.grandparentTitle ?? widget.metadata.title, - style: const TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - if (widget.metadata.parentIndex != null && - widget.metadata.index != null) + final topBar = _conditionalSafeArea( + bottom: false, // Only respect top safe area when in portrait + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + AppBarBackButton( + style: BackButtonStyle.video, + onPressed: () => Navigator.of(context).pop(true), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - 'S${widget.metadata.parentIndex} · E${widget.metadata.index} · ${widget.metadata.title}', - style: const TextStyle(color: Colors.white70, fontSize: 14), + widget.metadata.grandparentTitle ?? widget.metadata.title, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), - ], + if (widget.metadata.parentIndex != null && + widget.metadata.index != null) + Text( + 'S${widget.metadata.parentIndex} · E${widget.metadata.index} · ${widget.metadata.title}', + style: const TextStyle(color: Colors.white70, fontSize: 14), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), ), - ), - // Track and chapter controls in top right - _buildTrackAndChapterControls(), - ], + // Track and chapter controls in top right + _buildTrackAndChapterControls(), + ], + ), ), ); @@ -846,6 +915,7 @@ class _PlexVideoControlsState extends State Widget _buildMobilePlaybackControls() { return StreamBuilder( stream: widget.player.stream.playing, + initialData: widget.player.state.playing, builder: (context, snapshot) { final isPlaying = snapshot.data ?? false; return Row( @@ -917,50 +987,55 @@ class _PlexVideoControlsState extends State } Widget _buildMobileBottomBar() { - return Padding( - padding: const EdgeInsets.all(16), - child: StreamBuilder( - stream: widget.player.stream.position, - builder: (context, positionSnapshot) { - return StreamBuilder( - stream: widget.player.stream.duration, - builder: (context, durationSnapshot) { - final position = positionSnapshot.data ?? Duration.zero; - final duration = durationSnapshot.data ?? Duration.zero; + return _conditionalSafeArea( + top: false, // Only respect bottom safe area when in portrait + child: Padding( + padding: const EdgeInsets.all(16), + child: StreamBuilder( + stream: widget.player.stream.position, + initialData: widget.player.state.position, + builder: (context, positionSnapshot) { + return StreamBuilder( + stream: widget.player.stream.duration, + initialData: widget.player.state.duration, + builder: (context, durationSnapshot) { + final position = positionSnapshot.data ?? Duration.zero; + final duration = durationSnapshot.data ?? Duration.zero; - return Column( - children: [ - _buildTimelineWithChapters( - position: position, - duration: duration, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _formatDuration(position), - style: const TextStyle( - color: Colors.white, - fontSize: 14, - ), - ), - Text( - _formatDuration(duration), - style: const TextStyle( - color: Colors.white, - fontSize: 14, - ), - ), - ], + return Column( + children: [ + _buildTimelineWithChapters( + position: position, + duration: duration, ), - ), - ], - ); - }, - ); - }, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + _formatDuration(position), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + Text( + _formatDuration(duration), + style: const TextStyle( + color: Colors.white, + fontSize: 14, + ), + ), + ], + ), + ), + ], + ); + }, + ); + }, + ), ), ); } @@ -1073,9 +1148,11 @@ class _PlexVideoControlsState extends State // Row 1: Timeline with time indicators StreamBuilder( stream: widget.player.stream.position, + initialData: widget.player.state.position, builder: (context, positionSnapshot) { return StreamBuilder( stream: widget.player.stream.duration, + initialData: widget.player.state.duration, builder: (context, durationSnapshot) { final position = positionSnapshot.data ?? Duration.zero; final duration = durationSnapshot.data ?? Duration.zero; @@ -1137,6 +1214,7 @@ class _PlexVideoControlsState extends State // Play/Pause StreamBuilder( stream: widget.player.stream.playing, + initialData: widget.player.state.playing, builder: (context, snapshot) { final isPlaying = snapshot.data ?? false; return IconButton( @@ -1336,12 +1414,18 @@ class _PlexVideoControlsState extends State // Save current playback position final currentPosition = widget.player.state.position; + // Get state reference before async operations + final videoPlayerState = context.findAncestorStateOfType(); + // Save the preference final settingsService = await SettingsService.getInstance(); final seriesKey = widget.metadata.grandparentRatingKey ?? widget.metadata.ratingKey; await settingsService.setMediaVersionPreference(seriesKey, newMediaIndex); + // Set flag on parent VideoPlayerScreen to skip orientation restoration + videoPlayerState?.setReplacingWithVideo(); + // Navigate to new player screen with the selected version // Use PageRouteBuilder with zero-duration transitions to prevent orientation reset if (mounted) {