feat: rotation lock

This commit is contained in:
edde746
2025-11-05 16:46:31 +01:00
parent 155ac2042e
commit 8b6052d97b
3 changed files with 220 additions and 105 deletions
+52 -31
View File
@@ -35,10 +35,10 @@ class VideoPlayerScreen extends StatefulWidget {
});
@override
State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
State<VideoPlayerScreen> createState() => VideoPlayerScreenState();
}
class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
class VideoPlayerScreenState extends State<VideoPlayerScreen> {
Player? player;
VideoController? controller;
bool _isPlayerInitialized = false;
@@ -52,6 +52,9 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
List<PlexMediaVersion> _availableVersions = [];
StreamSubscription<PlayerLog>? _logSubscription;
StreamSubscription<String>? _errorSubscription;
StreamSubscription<bool>? _playingSubscription;
StreamSubscription<bool>? _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<VideoPlayerScreen> {
}
}
void _setLandscapeOrientation() {
OrientationHelper.setLandscapeOrientation();
}
Future<void> _initializePlayer() async {
try {
// Load buffer size from settings
@@ -167,21 +166,31 @@ class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
// 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<VideoPlayerScreen> {
_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<VideoPlayerScreen> {
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<void> _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) {
+10
View File
@@ -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<void> 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<String, String> getDefaultKeyboardShortcuts() {
return {
+158 -74
View File
@@ -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<PlexVideoControls>
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<PlexVideoControls>
_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<PlexVideoControls>
}
}
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<PlexVideoControls>
}
}
/// 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<Tracks>(
stream: widget.player.stream.tracks,
@@ -391,6 +447,16 @@ class _PlexVideoControlsState extends State<PlexVideoControls>
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<PlexVideoControls>
// 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<PlexVideoControls>
Widget _buildMobilePlaybackControls() {
return StreamBuilder<bool>(
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<PlexVideoControls>
}
Widget _buildMobileBottomBar() {
return Padding(
padding: const EdgeInsets.all(16),
child: StreamBuilder<Duration>(
stream: widget.player.stream.position,
builder: (context, positionSnapshot) {
return StreamBuilder<Duration>(
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<Duration>(
stream: widget.player.stream.position,
initialData: widget.player.state.position,
builder: (context, positionSnapshot) {
return StreamBuilder<Duration>(
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<PlexVideoControls>
// Row 1: Timeline with time indicators
StreamBuilder<Duration>(
stream: widget.player.stream.position,
initialData: widget.player.state.position,
builder: (context, positionSnapshot) {
return StreamBuilder<Duration>(
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<PlexVideoControls>
// Play/Pause
StreamBuilder<bool>(
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<PlexVideoControls>
// Save current playback position
final currentPosition = widget.player.state.position;
// Get state reference before async operations
final videoPlayerState = context.findAncestorStateOfType<VideoPlayerScreenState>();
// 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) {