diff --git a/lib/main.dart b/lib/main.dart index fe380d0c..67001f48 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'dart:io' show Platform; import 'package:media_kit/media_kit.dart'; import 'package:window_manager/window_manager.dart'; @@ -17,7 +16,7 @@ import 'providers/theme_provider.dart'; import 'utils/language_codes.dart'; import 'utils/app_logger.dart'; import 'utils/provider_extensions.dart'; -import 'utils/platform_detector.dart'; +import 'utils/orientation_helper.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -96,22 +95,7 @@ class _OrientationAwareSetupState extends State { } void _setOrientationPreferences() { - // Only lock orientation to portrait for phones - // Allow all orientations for tablets and desktop - if (PlatformDetector.isPhone(context)) { - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]); - } else { - // For tablets and desktop, allow all orientations - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ]); - } + OrientationHelper.restoreDefaultOrientations(context); } @override diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index cc0fe33f..09d66714 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -18,7 +18,7 @@ import '../mixins/refreshable.dart'; import '../mixins/item_updatable.dart'; import '../utils/app_logger.dart'; import '../utils/provider_extensions.dart'; -import 'video_player_screen.dart'; +import '../utils/video_player_navigation.dart'; import 'auth_screen.dart'; class DiscoverScreen extends StatefulWidget { @@ -569,14 +569,10 @@ class _DiscoverScreenState extends State if (client == null) return; appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}'); - Navigator.push( + navigateToVideoPlayer( context, - MaterialPageRoute( - builder: (context) => VideoPlayerScreen( - metadata: heroItem, - userProfile: widget.userProfile, - ), - ), + metadata: heroItem, + userProfile: widget.userProfile, ); }, child: Container( @@ -902,14 +898,10 @@ class _DiscoverScreenState extends State if (client == null) return; appLogger.d('Playing: ${heroItem.title}'); - Navigator.push( + navigateToVideoPlayer( context, - MaterialPageRoute( - builder: (context) => VideoPlayerScreen( - metadata: heroItem, - userProfile: widget.userProfile, - ), - ), + metadata: heroItem, + userProfile: widget.userProfile, ); }, borderRadius: BorderRadius.circular(24), diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index eb357d2a..83b10817 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -9,9 +9,9 @@ import '../widgets/app_bar_back_button.dart'; import '../widgets/media_context_menu.dart'; import '../utils/app_logger.dart'; import '../utils/provider_extensions.dart'; +import '../utils/video_player_navigation.dart'; import '../theme/theme_helper.dart'; import 'season_detail_screen.dart'; -import 'video_player_screen.dart'; class MediaDetailScreen extends StatefulWidget { final PlexMetadata metadata; @@ -214,14 +214,10 @@ class _MediaDetailScreenState extends State { if (client == null) return; appLogger.d('Playing first episode: ${firstEpisode.title}'); - await Navigator.push( + await navigateToVideoPlayer( context, - MaterialPageRoute( - builder: (context) => VideoPlayerScreen( - metadata: firstEpisode, - userProfile: widget.userProfile, - ), - ), + metadata: firstEpisode, + userProfile: widget.userProfile, ); appLogger.d('Returned from playback, refreshing metadata'); // Refresh metadata when returning from video player @@ -542,14 +538,10 @@ class _MediaDetailScreenState extends State { appLogger.d( 'Playing on deck episode: ${_onDeckEpisode!.title}', ); - await Navigator.push( + await navigateToVideoPlayer( context, - MaterialPageRoute( - builder: (context) => VideoPlayerScreen( - metadata: _onDeckEpisode!, - userProfile: widget.userProfile, - ), - ), + metadata: _onDeckEpisode!, + userProfile: widget.userProfile, ); appLogger.d( 'Returned from playback, refreshing metadata', @@ -567,14 +559,10 @@ class _MediaDetailScreenState extends State { appLogger.d('Playing: ${metadata.title}'); // For movies or episodes, play directly - await Navigator.push( + await navigateToVideoPlayer( context, - MaterialPageRoute( - builder: (context) => VideoPlayerScreen( - metadata: metadata, - userProfile: widget.userProfile, - ), - ), + metadata: metadata, + userProfile: widget.userProfile, ); appLogger.d( 'Returned from playback, refreshing metadata', diff --git a/lib/screens/season_detail_screen.dart b/lib/screens/season_detail_screen.dart index c86f2e49..622c6a40 100644 --- a/lib/screens/season_detail_screen.dart +++ b/lib/screens/season_detail_screen.dart @@ -6,11 +6,11 @@ import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; import '../providers/plex_client_provider.dart'; import '../utils/provider_extensions.dart'; +import '../utils/video_player_navigation.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/media_context_menu.dart'; import '../mixins/item_updatable.dart'; import '../theme/theme_helper.dart'; -import 'video_player_screen.dart'; class SeasonDetailScreen extends StatefulWidget { final PlexMetadata season; @@ -136,14 +136,10 @@ class _SeasonDetailScreenState extends State metadata: episode, onRefresh: updateItem, onTap: () async { - await Navigator.push( + await navigateToVideoPlayer( context, - MaterialPageRoute( - builder: (context) => VideoPlayerScreen( - metadata: episode, - userProfile: widget.userProfile, - ), - ), + metadata: episode, + userProfile: widget.userProfile, ); // Refresh episodes when returning from video player _loadEpisodes(); diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 38e3e171..6513249f 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import '../models/plex_metadata.dart'; @@ -11,6 +10,8 @@ import '../widgets/plex_video_controls.dart'; import '../utils/language_codes.dart'; import '../utils/app_logger.dart'; import '../services/settings_service.dart'; +import '../utils/orientation_helper.dart'; +import '../utils/video_player_navigation.dart'; class VideoPlayerScreen extends StatefulWidget { final PlexMetadata metadata; @@ -83,11 +84,7 @@ class _VideoPlayerScreenState extends State { } void _setLandscapeOrientation() { - SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); - SystemChrome.setPreferredOrientations([ - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - ]); + OrientationHelper.setLandscapeOrientation(); } Future _initializePlayer() async { @@ -208,13 +205,9 @@ class _VideoPlayerScreenState extends State { // Send final stopped state _sendProgress('stopped'); - // Restore system UI - SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); - // Restore portrait-only orientation - SystemChrome.setPreferredOrientations([ - DeviceOrientation.portraitUp, - DeviceOrientation.portraitDown, - ]); + // Restore system UI and orientation preferences + OrientationHelper.restoreSystemUI(); + OrientationHelper.restoreDefaultOrientations(context); player.dispose(); super.dispose(); @@ -678,19 +671,14 @@ class _VideoPlayerScreenState extends State { // Navigate to the episode using pushReplacement to destroy current player if (mounted) { final currentRate = player.state.rate; - Navigator.of(context).pushReplacement( - PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => - VideoPlayerScreen( - metadata: episodeMetadata, - preferredAudioTrack: currentAudioTrack, - preferredSubtitleTrack: currentSubtitleTrack, - preferredPlaybackRate: currentRate, - userProfile: widget.userProfile, - ), - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, - ), + navigateToVideoPlayer( + context, + metadata: episodeMetadata, + preferredAudioTrack: currentAudioTrack, + preferredSubtitleTrack: currentSubtitleTrack, + preferredPlaybackRate: currentRate, + userProfile: widget.userProfile, + usePushReplacement: true, ); } } diff --git a/lib/utils/orientation_helper.dart b/lib/utils/orientation_helper.dart new file mode 100644 index 00000000..ee085b38 --- /dev/null +++ b/lib/utils/orientation_helper.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'platform_detector.dart'; + +/// Helper class for managing device orientation preferences across the app. +class OrientationHelper { + /// Restores default orientation preferences based on device type. + /// + /// For phones: Locks to portrait-only (up and down) + /// For tablets/desktop: Allows all orientations + /// + /// This should be called when leaving full-screen experiences like + /// the video player to restore the app's default orientation behavior. + static void restoreDefaultOrientations(BuildContext context) { + final isPhone = PlatformDetector.isPhone(context); + + if (isPhone) { + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + } else { + // For tablets and desktop, allow all orientations + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } + } + + /// Sets orientation to landscape-only mode. + /// + /// Used by the video player to force landscape orientation during playback. + static void setLandscapeOrientation() { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + } + + /// Restores edge-to-edge system UI mode. + /// + /// Should be called when exiting full-screen mode. + static void restoreSystemUI() { + SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } +} diff --git a/lib/utils/video_player_navigation.dart b/lib/utils/video_player_navigation.dart new file mode 100644 index 00000000..de387300 --- /dev/null +++ b/lib/utils/video_player_navigation.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; +import '../models/plex_metadata.dart'; +import '../models/plex_user_profile.dart'; +import '../screens/video_player_screen.dart'; + +/// Navigates to the VideoPlayerScreen with instant transitions to prevent white flash. +/// +/// This utility function provides a consistent way to navigate to the video player +/// across the app, using PageRouteBuilder with zero-duration transitions to eliminate +/// the white flash that occurs with MaterialPageRoute. +/// +/// Parameters: +/// - [context]: The build context for navigation +/// - [metadata]: The Plex metadata for the content to play +/// - [userProfile]: Optional user profile for track selection preferences +/// - [preferredAudioTrack]: Optional audio track to select on playback start +/// - [preferredSubtitleTrack]: Optional subtitle track to select on playback start +/// - [preferredPlaybackRate]: Optional playback speed to set on playback start +/// - [usePushReplacement]: If true, replaces current route instead of pushing; +/// useful for episode-to-episode navigation. Defaults to false. +/// +/// Returns a Future that completes with a boolean indicating whether the content +/// was watched, or null if navigation was cancelled. +Future navigateToVideoPlayer( + BuildContext context, { + required PlexMetadata metadata, + PlexUserProfile? userProfile, + AudioTrack? preferredAudioTrack, + SubtitleTrack? preferredSubtitleTrack, + double? preferredPlaybackRate, + bool usePushReplacement = false, +}) async { + final route = PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => VideoPlayerScreen( + metadata: metadata, + userProfile: userProfile, + preferredAudioTrack: preferredAudioTrack, + preferredSubtitleTrack: preferredSubtitleTrack, + preferredPlaybackRate: preferredPlaybackRate, + ), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + ); + + if (usePushReplacement) { + return Navigator.of(context).pushReplacement(route); + } else { + return Navigator.push(context, route); + } +} diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index e7f1d9f1..6cc662ec 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -5,9 +5,9 @@ import '../models/plex_metadata.dart'; import '../models/plex_user_profile.dart'; import '../providers/plex_client_provider.dart'; import '../utils/provider_extensions.dart'; +import '../utils/video_player_navigation.dart'; import '../screens/media_detail_screen.dart'; import '../screens/season_detail_screen.dart'; -import '../screens/video_player_screen.dart'; import '../theme/theme_helper.dart'; import 'media_context_menu.dart'; @@ -44,14 +44,10 @@ class _MediaCardState extends State // For episodes, start playback directly if (itemType == 'episode') { - final result = await Navigator.push( + final result = await navigateToVideoPlayer( context, - MaterialPageRoute( - builder: (context) => VideoPlayerScreen( - metadata: widget.item, - userProfile: widget.userProfile, - ), - ), + metadata: widget.item, + userProfile: widget.userProfile, ); // Refresh parent screen if result indicates it's needed if (result == true) {