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',