From 2ab4aaa8dc93ac4acfe6634492f84978fa398d5c Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:05:40 +0700 Subject: [PATCH] fix(ui): allow landscape only in the full player Keep application pages in portrait and let player sheets inherit rotation from the full player. Restore portrait on navigation, replacement, removal, and completed dismissal; discard stale minimization geometry when the screen rotates on close. Validated with 12 Flutter tests and targeted analysis. iOS simulator build confirms portrait startup from a landscape device. Platform orientation restrictions still apply on large Android displays and multitasking iPads. --- android/app/src/main/AndroidManifest.xml | 1 + lib/app.dart | 2 + lib/main.dart | 2 + lib/screens/now_playing_screen.dart | 11 +- lib/services/app_orientation.dart | 71 +++++++++++ test/app_orientation_test.dart | 156 +++++++++++++++++++++++ test/now_playing_route_test.dart | 39 ++++++ 7 files changed, 280 insertions(+), 2 deletions(-) create mode 100644 lib/services/app_orientation.dart create mode 100644 test/app_orientation_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 956eaf1a..fdb17053 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -43,6 +43,7 @@ android:name=".MainActivity" android:exported="true" android:launchMode="singleTask" + android:screenOrientation="portrait" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" diff --git a/lib/app.dart b/lib/app.dart index 08cc3569..c8016693 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/screens/setup_screen.dart'; import 'package:spotiflac_android/screens/tutorial_screen.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/app_navigation_service.dart'; +import 'package:spotiflac_android/services/app_orientation.dart'; import 'package:spotiflac_android/theme/dynamic_color_wrapper.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; import 'package:spotiflac_android/l10n/supported_locales.dart'; @@ -39,6 +40,7 @@ final _routerProvider = Provider((ref) { return GoRouter( navigatorKey: AppNavigationService.rootNavigatorKey, + observers: [AppOrientationObserver()], initialLocation: initialLocation, routes: [ GoRoute(path: '/', builder: (context, state) => const MainShell()), diff --git a/lib/main.dart b/lib/main.dart index 94cf45dd..2dfa63fc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,6 +23,7 @@ import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/services/app_state_database.dart'; import 'package:spotiflac_android/services/app_navigation_service.dart'; +import 'package:spotiflac_android/services/app_orientation.dart'; import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/services/player_widget_service.dart'; import 'package:spotiflac_android/screens/now_playing_screen.dart'; @@ -42,6 +43,7 @@ void main() { runZonedGuarded( () async { WidgetsFlutterBinding.ensureInitialized(); + await setAppOrientation(); LicenseRegistry.addLicense(() async* { yield LicenseEntryWithLineBreaks(const [ 'Inter', diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index c7d63510..f76af672 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -17,6 +17,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/app_orientation.dart'; import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/theme/app_tokens.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; @@ -63,7 +64,11 @@ const _mornyeLyricFocusAlignment = 0.22; /// page follows the finger (via [startDrag]/[updateDrag]/[endDrag]) and /// settles open or pops based on release position and velocity. class NowPlayingRoute extends PageRoute { - NowPlayingRoute({this.miniPlayerGeometry}) : super(fullscreenDialog: true); + NowPlayingRoute({this.miniPlayerGeometry}) + : super( + fullscreenDialog: true, + settings: const RouteSettings(name: fullPlayerRouteName), + ); /// Read at dismissal so rotation and collapsing navigation cannot leave a /// stale destination from when the player was opened. @@ -188,7 +193,9 @@ class NowPlayingRoute extends PageRoute { Widget child, ) { final target = _dismissTarget; - if (target != null) { + // Restoring portrait can resize the window during dismissal. Geometry + // captured in landscape no longer points at the mini player in that case. + if (target != null && _dismissStart?.size == MediaQuery.sizeOf(context)) { final progress = Curves.easeInOutCubic.transform( (1 - animation.value / _dismissStartValue).clamp(0.0, 1.0), ); diff --git a/lib/services/app_orientation.dart b/lib/services/app_orientation.dart new file mode 100644 index 00000000..9fec3256 --- /dev/null +++ b/lib/services/app_orientation.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:spotiflac_android/utils/logger.dart'; + +const fullPlayerRouteName = '/full-player'; +final _log = AppLogger('Orientation'); + +/// Only the full player opts into rotation. Sheets and dialogs inherit the +/// underlying page's orientation, while another page restores portrait. +Future setAppOrientation({bool playerVisible = false}) => + SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + if (playerVisible) ...[ + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ], + ]); + +class AppOrientationObserver extends NavigatorObserver { + final _routes = >[]; + bool? _playerVisible; + + void _sync() { + final page = _routes.whereType>().lastOrNull; + final playerVisible = page?.settings.name == fullPlayerRouteName; + if (_playerVisible == playerVisible) return; + _playerVisible = playerVisible; + unawaited( + setAppOrientation(playerVisible: playerVisible).catchError(( + Object error, + ) { + _log.w('Could not update screen orientation: $error'); + }), + ); + } + + @override + void didPush(Route route, Route? previousRoute) { + _routes.add(route); + _sync(); + } + + @override + void didPop(Route route, Route? previousRoute) { + _routes.remove(route); + _sync(); + } + + @override + void didRemove(Route route, Route? previousRoute) { + _routes.remove(route); + _sync(); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + final index = oldRoute == null ? -1 : _routes.indexOf(oldRoute); + if (index >= 0) { + if (newRoute == null) { + _routes.removeAt(index); + } else { + _routes[index] = newRoute; + } + } else if (newRoute != null) { + _routes.add(newRoute); + } + _sync(); + } +} diff --git a/test/app_orientation_test.dart b/test/app_orientation_test.dart new file mode 100644 index 00000000..0b4cde91 --- /dev/null +++ b/test/app_orientation_test.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/screens/now_playing_screen.dart'; +import 'package:spotiflac_android/services/app_orientation.dart'; + +class _PlayerRoute extends NowPlayingRoute { + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) => const SizedBox.expand(); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + const portrait = ['DeviceOrientation.portraitUp']; + const player = [ + 'DeviceOrientation.portraitUp', + 'DeviceOrientation.landscapeLeft', + 'DeviceOrientation.landscapeRight', + ]; + final requests = >[]; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + + setUp(() { + requests.clear(); + messenger.setMockMethodCallHandler(SystemChannels.platform, (call) async { + if (call.method == 'SystemChrome.setPreferredOrientations') { + requests.add((call.arguments as List).cast()); + } + return null; + }); + }); + tearDown( + () => messenger.setMockMethodCallHandler(SystemChannels.platform, null), + ); + + Future pumpApp(WidgetTester tester) async { + final key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + navigatorKey: key, + navigatorObservers: [AppOrientationObserver()], + home: const Scaffold(body: Text('Library')), + ), + ); + await tester.pumpAndSettle(); + expect(requests, [portrait]); + return key.currentState!; + } + + test('startup requests portrait before the first route is built', () async { + await setAppOrientation(); + expect(requests, [portrait]); + }); + + testWidgets('only the full player enables landscape; sheets inherit it', ( + tester, + ) async { + final navigator = await pumpApp(tester); + navigator.push(_PlayerRoute()); + await tester.pumpAndSettle(); + expect(requests, [portrait, player]); + + showModalBottomSheet( + context: navigator.context, + builder: (_) => + const SizedBox(height: 100, child: Text('Player options')), + ); + await tester.pumpAndSettle(); + expect(requests, [portrait, player]); + navigator.pop(); + await tester.pumpAndSettle(); + expect(requests, [portrait, player]); + + navigator.push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Album')), + ), + ); + await tester.pumpAndSettle(); + expect(requests, [portrait, player, portrait]); + navigator.pop(); + await tester.pumpAndSettle(); + expect(requests, [portrait, player, portrait, player]); + await tester.binding.handlePopRoute(); + await tester.pumpAndSettle(); + expect(requests, [portrait, player, portrait, player, portrait]); + expect(tester.takeException(), isNull); + }); + + testWidgets('cancelled dismissal keeps rotation; completed drag locks it', ( + tester, + ) async { + final navigator = await pumpApp(tester); + final route = _PlayerRoute(); + navigator.push(route); + await tester.pumpAndSettle(); + route.startDrag(); + route.updateDrag( + DragUpdateDetails( + globalPosition: const Offset(0, 240), + delta: const Offset(0, 240), + primaryDelta: 240, + ), + 600, + ); + await tester.pump(); + route.cancelDrag(); + await tester.pumpAndSettle(); + expect(requests, [portrait, player]); + route.startDrag(); + route.endDrag( + DragEndDetails( + primaryVelocity: 1200, + velocity: const Velocity(pixelsPerSecond: Offset(0, 1200)), + ), + 600, + ); + await tester.pumpAndSettle(); + expect(requests, [portrait, player, portrait]); + expect(tester.takeException(), isNull); + }); + + testWidgets('removing and replacing routes cannot leave landscape enabled', ( + tester, + ) async { + final navigator = await pumpApp(tester); + final firstPlayer = _PlayerRoute(); + navigator.push(firstPlayer); + await tester.pumpAndSettle(); + final secondPlayer = _PlayerRoute(); + navigator.push(secondPlayer); + await tester.pumpAndSettle(); + navigator.removeRoute(firstPlayer); + await tester.pumpAndSettle(); + expect(requests, [portrait, player]); + navigator.pushReplacement( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Settings')), + ), + ); + await tester.pumpAndSettle(); + expect(requests, [portrait, player, portrait]); + final thirdPlayer = _PlayerRoute(); + navigator.push(thirdPlayer); + await tester.pumpAndSettle(); + navigator.removeRoute(thirdPlayer); + await tester.pumpAndSettle(); + expect(requests, [portrait, player, portrait, player, portrait]); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/now_playing_route_test.dart b/test/now_playing_route_test.dart index 2575eb58..17005353 100644 --- a/test/now_playing_route_test.dart +++ b/test/now_playing_route_test.dart @@ -12,6 +12,8 @@ import 'package:spotiflac_android/theme/mornye_theme.dart'; const _sheet = Key('player-sheet'); class _TestPlayerRoute extends NowPlayingRoute { + _TestPlayerRoute({super.miniPlayerGeometry}); + @override Widget buildPage( BuildContext context, @@ -21,6 +23,43 @@ class _TestPlayerRoute extends NowPlayingRoute { } void main() { + testWidgets('rotation on close discards stale mini-player bounds', ( + tester, + ) async { + tester.view.physicalSize = const Size(852, 393); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final navigator = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + navigatorKey: navigator, + theme: MornyeTheme.build(Brightness.dark), + home: const SizedBox(), + ), + ); + navigator.currentState!.push( + _TestPlayerRoute( + miniPlayerGeometry: () => ( + surface: const Rect.fromLTWH(80, 310, 700, 52), + artwork: const Rect.fromLTWH(88, 314, 44, 44), + ), + ), + ); + await tester.pumpAndSettle(); + navigator.currentState!.pop(); + await tester.pump(); + expect( + find.byKey(const ValueKey('player-minimize-surface')), + findsOneWidget, + ); + tester.view.physicalSize = const Size(393, 852); + await tester.pump(const Duration(milliseconds: 16)); + expect(find.byKey(const ValueKey('player-minimize-surface')), findsNothing); + await tester.pumpAndSettle(); + expect(find.byKey(_sheet), findsNothing); + expect(tester.takeException(), isNull); + }); + for (final brightness in Brightness.values) { testWidgets( 'player fills safe areas and reveals the page only when dragged in $brightness',