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.
This commit is contained in:
zarzet committed 2026-09-25 18:05:40 +07:00
1 parent 1d53609b51
commit 2ab4aaa8dc
7 files changed
+280 -2

No files matched your search

+1
View File
@@ -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"
+2
View File
@@ -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<GoRouter>((ref) {
return GoRouter(
navigatorKey: AppNavigationService.rootNavigatorKey,
observers: [AppOrientationObserver()],
initialLocation: initialLocation,
routes: [
GoRoute(path: '/', builder: (context, state) => const MainShell()),
+2
View File
@@ -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',
+9 -2
View File
@@ -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<void> {
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<void> {
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),
);
+71
View File
@@ -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<void> setAppOrientation({bool playerVisible = false}) =>
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
if (playerVisible) ...[
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
],
]);
class AppOrientationObserver extends NavigatorObserver {
final _routes = <Route<dynamic>>[];
bool? _playerVisible;
void _sync() {
final page = _routes.whereType<PageRoute<dynamic>>().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<dynamic> route, Route<dynamic>? previousRoute) {
_routes.add(route);
_sync();
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
_routes.remove(route);
_sync();
}
@override
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
_routes.remove(route);
_sync();
}
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? 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();
}
}
+156
View File
@@ -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<double> animation,
Animation<double> secondaryAnimation,
) => const SizedBox.expand();
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const portrait = ['DeviceOrientation.portraitUp'];
const player = [
'DeviceOrientation.portraitUp',
'DeviceOrientation.landscapeLeft',
'DeviceOrientation.landscapeRight',
];
final requests = <List<String>>[];
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<dynamic>).cast<String>());
}
return null;
});
});
tearDown(
() => messenger.setMockMethodCallHandler(SystemChannels.platform, null),
);
Future<NavigatorState> pumpApp(WidgetTester tester) async {
final key = GlobalKey<NavigatorState>();
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<void>(
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<void>(
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<void>(
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);
});
}
+39
View File
@@ -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<NavigatorState>();
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',