feat: rotation lock
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user