diff --git a/lib/app/app.dart b/lib/app/app.dart index 74866a1a7..5aa62b381 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -37,6 +37,7 @@ import 'package:flick/features/onboarding/screens/onboarding_screen.dart'; import 'package:flick/features/onboarding/tutorial_targets.dart'; import 'package:flick/features/onboarding/widgets/tutorial_overlay.dart'; import 'package:flick/widgets/common/embedded_mini_player.dart'; +import 'package:flick/widgets/common/landscape_mini_player_dock.dart'; import 'package:flick/widgets/common/landscape_side_mini_player.dart'; import 'package:flick/widgets/navigation/bottom_bar_geometry.dart'; import 'package:flick/providers/mini_player_config_provider.dart'; @@ -794,11 +795,13 @@ class _MainShellState extends ConsumerState math.max(0.0, screenSize.width - railWidth), screenSize.height, ); + final useCompactLandscapeDock = + useSideNavigation && + AdaptiveLayout.useCompactLandscapeMiniPlayer(contentSize); final currentIndex = ref.watch(navigationIndexProvider); final songsViewMode = ref.watch(songsViewModeProvider); final sideMiniPlayerCandidate = - useSideNavigation && - AdaptiveLayout.useCompactLandscapeMiniPlayer(contentSize) && + useCompactLandscapeDock && currentIndex == NavBarButton.songs.pageIndex && songsViewMode == SongViewMode.orbit; // Only listen to the auto-disposed library provider while the orbit is on @@ -912,63 +915,35 @@ class _MainShellState extends ConsumerState navBarConfigProvider, ); final order = _getPageOrder(cfg); - return Padding( - padding: EdgeInsets.only( - bottom: nestedBottomInset, - ), - child: PageView( - controller: _pageController, - physics: - const ClampingScrollPhysics(), - onPageChanged: (position) { - final currentConfig = ref.read( - navBarConfigProvider, - ); - final currentOrder = - _getPageOrder( - currentConfig, - ); - if (position < 0 || - position >= - currentOrder.length) { - return; - } + return PageView( + controller: _pageController, + physics: + const ClampingScrollPhysics(), + onPageChanged: (position) { + final currentConfig = ref.read( + navBarConfigProvider, + ); + final currentOrder = + _getPageOrder(currentConfig); + if (position < 0 || + position >= + currentOrder.length) { + return; + } + if (_programmaticPageTarget == + null && + position != _lastHapticPage) { + AppHaptics.selection(); + } + _lastHapticPage = position; + final enabledCount = currentConfig + .orderedButtons + .length; + if (position >= enabledCount) { if (_programmaticPageTarget == - null && - position != - _lastHapticPage) { - AppHaptics.selection(); - } - _lastHapticPage = position; - final enabledCount = - currentConfig - .orderedButtons - .length; - if (position >= enabledCount) { - if (_programmaticPageTarget == - position) { - _programmaticPageTarget = - null; - final pageIndex = - currentOrder[position] - .pageIndex; - if (ref.read( - navigationIndexProvider, - ) != - pageIndex) { - ref - .read( - navigationIndexProvider - .notifier, - ) - .setIndex(pageIndex); - } - return; - } - if (_programmaticPageTarget != - null) { - return; - } + position) { + _programmaticPageTarget = + null; final pageIndex = currentOrder[position] .pageIndex; @@ -985,10 +960,9 @@ class _MainShellState extends ConsumerState } return; } - if (_programmaticPageTarget == - position) { - _programmaticPageTarget = - null; + if (_programmaticPageTarget != + null) { + return; } final pageIndex = currentOrder[position] @@ -1004,15 +978,43 @@ class _MainShellState extends ConsumerState ) .setIndex(pageIndex); } - }, - children: order.map((button) { - return _buildTab( + return; + } + if (_programmaticPageTarget == + position) { + _programmaticPageTarget = null; + } + final pageIndex = + currentOrder[position] + .pageIndex; + if (ref.read( + navigationIndexProvider, + ) != + pageIndex) { + ref + .read( + navigationIndexProvider + .notifier, + ) + .setIndex(pageIndex); + } + }, + children: order.map((button) { + return Padding( + padding: EdgeInsets.only( + bottom: nestedBottomInset, + ), + child: _buildTab( tabIndex: button.pageIndex, currentIndex: currentIdx, - child: _buildScreen(button), - ); - }).toList(), - ), + child: _buildScreen( + button, + pathOverflowBottom: + nestedBottomInset, + ), + ), + ); + }).toList(), ); }, ), @@ -1028,6 +1030,18 @@ class _MainShellState extends ConsumerState ), ), + // Tab pages are full-height so the orbit ribbon can continue + // behind the bottom overlays. Keep the newly exposed strip + // from paging the PageView; the bars sit above this blocker. + if (nestedBottomInset > 0) + Positioned( + left: useSideNavigation ? railWidth : 0, + right: 0, + bottom: 0, + height: nestedBottomInset, + child: const AbsorbPointer(child: SizedBox.expand()), + ), + // Wide windows use a side rail; portrait keeps the bottom bar. if (useSideNavigation) Positioned( @@ -1057,9 +1071,30 @@ class _MainShellState extends ConsumerState ), ), - // In rail mode the mini player owns the bottom of the content - // area, not the full window width. - if (useSideNavigation && !useOrbitSideMiniPlayer) + // Only short landscape windows animate between the bottom + // bar and orbit strip. Keep the dock mounted across tabs. + if (useCompactLandscapeDock) + Positioned.fill( + key: const ValueKey('landscape_mini_player_dock'), + left: railWidth, + child: LandscapeMiniPlayerDock( + sideDocked: useOrbitSideMiniPlayer, + bottomPlayerHeight: + BottomBarGeometry.miniPlayerHeight( + context, + miniConfig, + ) + + 12 + + (separated ? miniConfig.navigationGap : 8), + bottomPlayer: EmbeddedMiniPlayer( + collapsed: false, + separated: separated, + ), + sidePlayer: const LandscapeSideMiniPlayer(), + ), + ) + else if (useSideNavigation) + // Larger windows retain their static bottom mini player. Positioned( left: railWidth, right: 0, @@ -1077,22 +1112,6 @@ class _MainShellState extends ConsumerState ), ), - // Compact landscape orbit moves the mini player to the right - // edge so the orbit stage keeps the full window height. - if (useOrbitSideMiniPlayer) - Positioned( - right: 6, - top: 0, - bottom: 0, - child: SafeArea( - left: false, - child: TutorialTargetAnchor( - target: TutorialTarget.miniPlayer, - child: const LandscapeSideMiniPlayer(), - ), - ), - ), - // Floating island mini-player (draggable pill) const FloatingMiniPlayer(), @@ -1123,7 +1142,7 @@ class _MainShellState extends ConsumerState return [...config.orderedButtons, ...disabledEssentials]; } - Widget _buildScreen(NavBarButton button) { + Widget _buildScreen(NavBarButton button, {double pathOverflowBottom = 0}) { return switch (button) { NavBarButton.menu => MenuScreen( key: const ValueKey('menu'), @@ -1133,6 +1152,7 @@ class _MainShellState extends ConsumerState ), NavBarButton.songs => SongsScreen( key: const ValueKey('songs'), + pathOverflowBottom: pathOverflowBottom, onNavigationRequested: (index) { ref.read(navigationIndexProvider.notifier).setIndex(index); }, diff --git a/lib/features/settings/data/settings_search_catalog.dart b/lib/features/settings/data/settings_search_catalog.dart index 0002b099d..b0cf5d8ab 100644 --- a/lib/features/settings/data/settings_search_catalog.dart +++ b/lib/features/settings/data/settings_search_catalog.dart @@ -572,6 +572,10 @@ List buildSettingsSearchCatalog() { LucideIcons.orbit, (id) => OrbitSettingsScreen(initialSection: id), sections: [ + _section('orbitLayoutProfile', l10n.orbitLayoutProfile, [ + ('portrait', l10n.portrait), + ('landscape', l10n.landscape), + ]), _section('geometry', l10n.geometry, [ ('curvature', l10n.curvature), ('verticalPosition', l10n.verticalPosition), diff --git a/lib/features/settings/screens/orbit_settings_screen.dart b/lib/features/settings/screens/orbit_settings_screen.dart index 7437de04b..25f8d10b0 100644 --- a/lib/features/settings/screens/orbit_settings_screen.dart +++ b/lib/features/settings/screens/orbit_settings_screen.dart @@ -3,143 +3,246 @@ import 'package:flick/widgets/common/flick_snackbar.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:flick/core/constants/app_constants.dart'; +import 'package:flick/core/utils/adaptive_layout.dart'; import 'package:flick/providers/app_preferences_provider.dart'; import 'package:flick/features/settings/widgets/settings_widgets.dart'; import 'package:flick/l10n/settings_l10n.dart'; -class OrbitSettingsScreen extends ConsumerWidget { +enum _OrbitLayoutProfile { portrait, landscape } + +class OrbitSettingsScreen extends ConsumerStatefulWidget { const OrbitSettingsScreen({super.key, this.initialSection}); final String? initialSection; @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => + _OrbitSettingsScreenState(); +} + +class _OrbitSettingsScreenState extends ConsumerState { + _OrbitLayoutProfile _profile = _OrbitLayoutProfile.portrait; + bool _profileInitialized = false; + + bool get _landscape => _profile == _OrbitLayoutProfile.landscape; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_profileInitialized) return; + _profileInitialized = true; + _profile = AdaptiveLayout.isWide(MediaQuery.sizeOf(context)) + ? _OrbitLayoutProfile.landscape + : _OrbitLayoutProfile.portrait; + } + + @override + Widget build(BuildContext context) { final prefs = ref.watch(appPreferencesProvider); final notifier = ref.read(appPreferencesProvider.notifier); + final radiusRatio = _landscape + ? prefs.orbitLandscapeRadiusRatio + : prefs.orbitRadiusRatio; + final centerYRatio = _landscape + ? prefs.orbitLandscapeCenterYRatio + : prefs.orbitCenterYRatio; + final itemSpacing = _landscape + ? prefs.orbitLandscapeItemSpacing + : prefs.orbitItemSpacing; + final selectedScale = _landscape + ? prefs.orbitLandscapeSelectedScale + : prefs.orbitSelectedScale; + final depth = _landscape ? prefs.orbitLandscapeDepth : prefs.orbitDepth; + final visibleItems = _landscape + ? prefs.orbitLandscapeVisibleItems + : prefs.orbitVisibleItems; + final cardWidthRatio = _landscape + ? prefs.orbitLandscapeCardWidthRatio + : prefs.orbitCardWidthRatio; + return SettingsScaffold( title: l10n.englishForFilipino.customizeOrbital, - initialSection: initialSection, + initialSection: widget.initialSection, body: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SettingsSectionHeader(l10n.englishForFilipino.geometry, sectionId: 'geometry'), + SettingsSectionHeader( + l10n.englishForFilipino.orbitLayoutProfile, + sectionId: 'orbitLayoutProfile', + ), + SettingsCard( + children: [ + SelectionSetting( + icon: LucideIcons.rectangleVertical, + title: l10n.englishForFilipino.portrait, + subtitle: l10n.orbitPortraitHint, + selected: !_landscape, + onTap: () { + setState(() => _profile = _OrbitLayoutProfile.portrait); + }, + ), + const SettingsDivider(), + SelectionSetting( + icon: LucideIcons.rectangleHorizontal, + title: l10n.englishForFilipino.landscape, + subtitle: l10n.orbitLandscapeHint, + selected: _landscape, + onTap: () { + setState(() => _profile = _OrbitLayoutProfile.landscape); + }, + ), + ], + ), + const SizedBox(height: AppConstants.spacingLg), + SettingsSectionHeader( + l10n.englishForFilipino.geometry, + sectionId: 'geometry', + ), SettingsCard( children: [ SliderSetting( icon: LucideIcons.radius, title: l10n.englishForFilipino.curvature, subtitle: l10n.curveOfTheOrbitArcHigher, - value: prefs.orbitRadiusRatio, - displayValue: prefs.orbitRadiusRatio.toStringAsFixed(2), + value: radiusRatio, + displayValue: radiusRatio.toStringAsFixed(2), min: 0.5, max: 2.0, divisions: 30, - onChanged: notifier.setOrbitRadiusRatio, + onChanged: _landscape + ? notifier.setOrbitLandscapeRadiusRatio + : notifier.setOrbitRadiusRatio, ), const SettingsDivider(), SliderSetting( icon: LucideIcons.moveVertical, title: l10n.englishForFilipino.verticalPosition, subtitle: l10n.whereTheFocalSongSitsVertically, - value: prefs.orbitCenterYRatio, - displayValue: '${(prefs.orbitCenterYRatio * 100).round()}%', + value: centerYRatio, + displayValue: '${(centerYRatio * 100).round()}%', min: 0.30, max: 0.60, divisions: 30, - onChanged: notifier.setOrbitCenterYRatio, - ), - const SettingsDivider(), - SliderSetting( - icon: LucideIcons.moveHorizontal, - title: l10n.englishForFilipino.horizontalReach, - subtitle: l10n.howFarTheArcOpensFrom, - value: prefs.orbitCenterOffsetRatio, - displayValue: prefs.orbitCenterOffsetRatio.toStringAsFixed(2), - min: -1.0, - max: 0.0, - divisions: 20, - onChanged: notifier.setOrbitCenterOffsetRatio, + onChanged: _landscape + ? notifier.setOrbitLandscapeCenterYRatio + : notifier.setOrbitCenterYRatio, ), + if (!_landscape) ...[ + const SettingsDivider(), + SliderSetting( + icon: LucideIcons.moveHorizontal, + title: l10n.englishForFilipino.horizontalReach, + subtitle: l10n.howFarTheArcOpensFrom, + value: prefs.orbitCenterOffsetRatio, + displayValue: prefs.orbitCenterOffsetRatio.toStringAsFixed(2), + min: -1.0, + max: 0.0, + divisions: 20, + onChanged: notifier.setOrbitCenterOffsetRatio, + ), + ], const SettingsDivider(), SliderSetting( icon: LucideIcons.ruler, title: l10n.englishForFilipino.itemSpacing, subtitle: l10n.distanceBetweenSongsAlongTheArc, - value: prefs.orbitItemSpacing, - displayValue: prefs.orbitItemSpacing.toStringAsFixed(2), + value: itemSpacing, + displayValue: itemSpacing.toStringAsFixed(2), min: 0.15, max: 0.50, divisions: 35, - onChanged: notifier.setOrbitItemSpacing, + onChanged: _landscape + ? notifier.setOrbitLandscapeItemSpacing + : notifier.setOrbitItemSpacing, ), ], ), const SizedBox(height: AppConstants.spacingLg), - SettingsSectionHeader(l10n.englishForFilipino.sizing, sectionId: 'sizing'), + SettingsSectionHeader( + l10n.englishForFilipino.sizing, + sectionId: 'sizing', + ), SettingsCard( children: [ - SliderSetting( - icon: LucideIcons.expand, - title: l10n.englishForFilipino.cardSize, - subtitle: l10n.baseAlbumArtSizeForEach, - value: prefs.orbitCardArtSize, - displayValue: '${prefs.orbitCardArtSize.round()}px', - min: 48, - max: 120, - divisions: 72, - onChanged: notifier.setOrbitCardArtSize, - ), - const SettingsDivider(), + if (!_landscape) ...[ + SliderSetting( + icon: LucideIcons.expand, + title: l10n.englishForFilipino.cardSize, + subtitle: l10n.baseAlbumArtSizeForEach, + value: prefs.orbitCardArtSize, + displayValue: '${prefs.orbitCardArtSize.round()}px', + min: 48, + max: 120, + divisions: 72, + onChanged: notifier.setOrbitCardArtSize, + ), + const SettingsDivider(), + ], SliderSetting( icon: LucideIcons.arrowLeftRight, title: l10n.englishForFilipino.cardWidth, subtitle: l10n.howWideEachCardSpansAcross, - value: prefs.orbitCardWidthRatio, - displayValue: '${(prefs.orbitCardWidthRatio * 100).round()}%', + value: cardWidthRatio, + displayValue: '${(cardWidthRatio * 100).round()}%', min: 0.5, max: 0.85, divisions: 35, - onChanged: notifier.setOrbitCardWidthRatio, + onChanged: _landscape + ? notifier.setOrbitLandscapeCardWidthRatio + : notifier.setOrbitCardWidthRatio, ), const SettingsDivider(), _VisibleItemsRow( - value: prefs.orbitVisibleItems, - onChanged: notifier.setOrbitVisibleItems, + value: visibleItems, + onChanged: _landscape + ? notifier.setOrbitLandscapeVisibleItems + : notifier.setOrbitVisibleItems, ), ], ), const SizedBox(height: AppConstants.spacingLg), - SettingsSectionHeader(l10n.englishForFilipino.depth, sectionId: 'depth'), + SettingsSectionHeader( + l10n.englishForFilipino.depth, + sectionId: 'depth', + ), SettingsCard( children: [ SliderSetting( icon: LucideIcons.maximize, title: l10n.englishForFilipino.selectedSize, subtitle: l10n.scaleOfTheCenteredFocusedSong, - value: prefs.orbitSelectedScale, - displayValue: prefs.orbitSelectedScale.toStringAsFixed(2), + value: selectedScale, + displayValue: selectedScale.toStringAsFixed(2), min: 1.0, max: 1.8, divisions: 16, - onChanged: notifier.setOrbitSelectedScale, + onChanged: _landscape + ? notifier.setOrbitLandscapeSelectedScale + : notifier.setOrbitSelectedScale, ), const SettingsDivider(), SliderSetting( icon: LucideIcons.chevronsDown, title: l10n.englishForFilipino.depth, subtitle: l10n.howMuchSideCardsShrinkAway, - value: prefs.orbitDepth, - displayValue: '${(prefs.orbitDepth * 100).round()}%', + value: depth, + displayValue: '${(depth * 100).round()}%', min: 0.0, max: 1.0, divisions: 20, - onChanged: notifier.setOrbitDepth, + onChanged: _landscape + ? notifier.setOrbitLandscapeDepth + : notifier.setOrbitDepth, ), ], ), const SizedBox(height: AppConstants.spacingLg), - SettingsSectionHeader(l10n.englishForFilipino.artResolution, sectionId: 'artResolution'), + SettingsSectionHeader( + l10n.englishForFilipino.artResolution, + tag: l10n.englishForFilipino.orbitSharedVisuals, + sectionId: 'artResolution', + ), SettingsCard( children: [ SelectionSetting( @@ -176,7 +279,11 @@ class OrbitSettingsScreen extends ConsumerWidget { ], ), const SizedBox(height: AppConstants.spacingLg), - SettingsSectionHeader(l10n.englishForFilipino.visuals, sectionId: 'visuals'), + SettingsSectionHeader( + l10n.englishForFilipino.visuals, + tag: l10n.englishForFilipino.orbitSharedVisuals, + sectionId: 'visuals', + ), SettingsCard( children: [ ToggleSetting( @@ -202,13 +309,18 @@ class OrbitSettingsScreen extends ConsumerWidget { NavigationSetting( icon: LucideIcons.refreshCw, title: l10n.englishForFilipino.resetToDefaults, - subtitle: l10n.restoreTheOriginalOrbitalLayout, + subtitle: l10n.restoreThisProfilesOrbitLayout, onTap: () { - notifier.resetOrbitSettings(); + final landscape = _landscape; + notifier.resetOrbitLayout(landscape: landscape); ScaffoldMessenger.of(context).showFlickSnackBar( SnackBar( - content: Text(l10n.orbitalSettingsReset), - duration: Duration(seconds: 2), + content: Text( + landscape + ? l10n.landscapeLayoutReset + : l10n.portraitLayoutReset, + ), + duration: const Duration(seconds: 2), ), ); }, diff --git a/lib/features/songs/screens/songs_screen.dart b/lib/features/songs/screens/songs_screen.dart index 778956ef2..76bbe8d3a 100644 --- a/lib/features/songs/screens/songs_screen.dart +++ b/lib/features/songs/screens/songs_screen.dart @@ -53,7 +53,16 @@ class SongsScreen extends ConsumerStatefulWidget { /// Callback when navigation to a different tab is requested from full player final ValueChanged? onNavigationRequested; - const SongsScreen({super.key, this.onNavigationRequested}); + /// Height of the shell's bottom overlay that the orbit ribbon may paint + /// through. Kept separate from the card stage so the cards stay clear of the + /// mini player and navigation bar. + final double pathOverflowBottom; + + const SongsScreen({ + super.key, + this.onNavigationRequested, + this.pathOverflowBottom = 0, + }); @override ConsumerState createState() => _SongsScreenState(); @@ -142,6 +151,15 @@ class _SongsScreenState extends ConsumerState AdaptiveLayout.useCompactLandscapeMiniPlayer( MediaQuery.sizeOf(context), ); + final safePadding = MediaQuery.paddingOf(context); + final orbitSideOverflow = EdgeInsets.only( + left: safePadding.left, + right: + safePadding.right + + (reserveSideMiniPlayer + ? AdaptiveLayout.sideMiniPlayerContentInset + : 0), + ); final swipeActionsEnabled = ref .watch(appPreferencesProvider) .swipeActionsEnabled; @@ -338,6 +356,7 @@ class _SongsScreenState extends ConsumerState navBarVisible, swipeActionsEnabled, appPrefs.fastIndexEnabled, + orbitSideOverflow, ); }, ), @@ -357,11 +376,12 @@ class _SongsScreenState extends ConsumerState bool isBottomBarVisible, bool swipeActionsEnabled, bool fastIndexEnabled, + EdgeInsets orbitSideOverflow, ) { final overlayRailVisible = fastIndexEnabled && _fastIndexVisible; final content = viewMode == SongViewMode.list ? _buildListView(songs, swipeActionsEnabled, overlayRailVisible) - : _buildOrbitView(songs, swipeActionsEnabled); + : _buildOrbitView(songs, swipeActionsEnabled, orbitSideOverflow); if (tokenToIndexMap.isEmpty) { return content; @@ -430,8 +450,13 @@ class _SongsScreenState extends ConsumerState ); } - Widget _buildOrbitView(List songs, bool swipeActionsEnabled) { + Widget _buildOrbitView( + List songs, + bool swipeActionsEnabled, + EdgeInsets sideOverflow, + ) { final orbit = ref.watch(appPreferencesProvider); + final landscape = AdaptiveLayout.isWide(MediaQuery.sizeOf(context)); return GestureDetector( onLongPress: () { if (_selectionMode) return; @@ -444,28 +469,42 @@ class _SongsScreenState extends ConsumerState } }, child: OrbitScroll( - scrollDirection: - MediaQuery.sizeOf(context).width > MediaQuery.sizeOf(context).height - ? Axis.horizontal - : Axis.vertical, + scrollDirection: landscape ? Axis.horizontal : Axis.vertical, controller: _orbitScrollController, songs: songs, selectedIndex: _selectedIndex.clamp(0, songs.length - 1).toInt(), swipeActionsEnabled: swipeActionsEnabled && !_selectionMode, isSelectionMode: _selectionMode, selectedIds: _selectedIds, - radiusRatio: orbit.orbitRadiusRatio, + radiusRatio: landscape + ? orbit.orbitLandscapeRadiusRatio + : orbit.orbitRadiusRatio, centerOffsetRatio: orbit.orbitCenterOffsetRatio, - centerYRatio: orbit.orbitCenterYRatio, - itemSpacing: orbit.orbitItemSpacing, - selectedScale: orbit.orbitSelectedScale, - depth: orbit.orbitDepth, - visibleItems: orbit.orbitVisibleItems, + centerYRatio: landscape + ? orbit.orbitLandscapeCenterYRatio + : orbit.orbitCenterYRatio, + itemSpacing: landscape + ? orbit.orbitLandscapeItemSpacing + : orbit.orbitItemSpacing, + selectedScale: landscape + ? orbit.orbitLandscapeSelectedScale + : orbit.orbitSelectedScale, + depth: landscape ? orbit.orbitLandscapeDepth : orbit.orbitDepth, + visibleItems: landscape + ? orbit.orbitLandscapeVisibleItems + : orbit.orbitVisibleItems, cardArtSize: orbit.orbitCardArtSize, - cardWidthRatio: orbit.orbitCardWidthRatio, + cardWidthRatio: landscape + ? orbit.orbitLandscapeCardWidthRatio + : orbit.orbitCardWidthRatio, artResolutionMultiplier: orbit.orbitArtResolutionMultiplier, showPath: orbit.orbitShowPath, showGlow: orbit.orbitShowGlow, + // The shell overlays the mini player and navigation bar on the stage. + // The ribbon may continue through that reserved strip; the card stage + // stays above it. + pathOverflowBottom: widget.pathOverflowBottom, + horizontalPaintOverflow: sideOverflow, onSelectedIndexChanged: (index) { if (!mounted) return; _showFastIndexOverlay(); diff --git a/lib/features/songs/widgets/orbit_scroll.dart b/lib/features/songs/widgets/orbit_scroll.dart index 009ba9dbd..77e1c9c3f 100644 --- a/lib/features/songs/widgets/orbit_scroll.dart +++ b/lib/features/songs/widgets/orbit_scroll.dart @@ -75,6 +75,15 @@ class OrbitScroll extends StatefulWidget { final bool showPath; final bool showGlow; + /// Extra bottom room the path ribbon may paint through without letting the + /// card stage or gesture surface move into the bottom bars. + final double pathOverflowBottom; + + /// Side space reserved by the parent for safe areas and the mini player. + /// Landscape cards and ribbon paint through it, but gestures stay in the + /// inset music viewport so navigation and player controls keep their targets. + final EdgeInsets horizontalPaintOverflow; + const OrbitScroll({ super.key, required this.songs, @@ -100,6 +109,8 @@ class OrbitScroll extends StatefulWidget { this.artResolutionMultiplier = 2.0, this.showPath = true, this.showGlow = true, + this.pathOverflowBottom = 0.0, + this.horizontalPaintOverflow = EdgeInsets.zero, }); @override @@ -108,6 +119,8 @@ class OrbitScroll extends StatefulWidget { class _OrbitScrollState extends State with SingleTickerProviderStateMixin { + static const double _landscapeNavigationGutter = 28; + late AnimationController _controller; // The physics state — notifier drives only the song items rebuild @@ -419,8 +432,20 @@ class _OrbitScrollState extends State @override Widget build(BuildContext context) { final horizontal = widget.scrollDirection == Axis.horizontal; + final sideOverflow = horizontal + ? EdgeInsets.only( + left: + _landscapeNavigationGutter + + math.max(0.0, widget.horizontalPaintOverflow.left), + right: + _landscapeNavigationGutter + + math.max(0.0, widget.horizontalPaintOverflow.right), + ) + : EdgeInsets.zero; return Padding( - padding: EdgeInsets.symmetric(horizontal: horizontal ? 28 : 0), + padding: EdgeInsets.symmetric( + horizontal: horizontal ? _landscapeNavigationGutter : 0, + ), child: LayoutBuilder( builder: (context, constraints) { final size = constraints.biggest; @@ -431,16 +456,32 @@ class _OrbitScrollState extends State } // Phone landscape leaves a much shorter stage than a tablet. Keep // the same orbit metaphor, but lift and shrink it so the flanking - // cards are not pushed off the bottom of the window. + // cards are not pushed off the bottom of the window. The lift is a + // runtime fit only; the saved landscape preference is never rewritten. final compactHorizontal = horizontal && size.height < 420; final orbitRadius = horizontal - ? size.width * widget.radiusRatio.clamp(0.6, 1.4) + ? size.width * widget.radiusRatio.clamp(0.5, 2.0) : size.width * widget.radiusRatio; final orbitCenterX = horizontal ? size.width / 2 : size.width * widget.centerOffsetRatio; + final landscapeCardHeight = horizontal + ? _landscapeCardHeight(size, compactHorizontal) + : 0.0; + final landscapeCardWidth = horizontal + ? _landscapeCardWidth( + size, + landscapeCardHeight, + compactHorizontal, + ) + : 0.0; final orbitCenterY = horizontal - ? size.height * (compactHorizontal ? 0.44 : 0.48) + orbitRadius + ? _landscapeFocalCenterY( + size, + orbitRadius, + landscapeCardHeight, + compactHorizontal, + ) : size.height * widget.centerYRatio; _dragExtent = horizontal ? (size.width * 0.28).clamp(140.0, 280.0) @@ -456,40 +497,66 @@ class _OrbitScrollState extends State onHorizontalDragEnd: horizontal ? _onDragEnd : null, onHorizontalDragCancel: horizontal ? _onDragCancel : null, behavior: HitTestBehavior.opaque, - child: Container( - width: double.infinity, - height: double.infinity, - color: Colors.transparent, + child: SizedBox.expand( child: Stack( - clipBehavior: Clip.hardEdge, + // Paint through the side gutters, not above the stage. Only + // the ribbon also continues into the bottom-bar inset. + clipBehavior: Clip.none, children: [ - // Background glow - if (widget.showGlow) - _buildSelectionGlow( + const Positioned.fill( + child: ColoredBox(color: Colors.transparent), + ), + + // Path + if (widget.showPath) + _buildOrbitPath( orbitCenterX, orbitCenterY, orbitRadius, + sideOverflow, ), - // Path - if (widget.showPath) - _buildOrbitPath(orbitCenterX, orbitCenterY, orbitRadius), - - // Songs — only rebuilds when scroll offset changes - ValueListenableBuilder( - valueListenable: _scrollOffset, - builder: (context, _, __) { - return SizedBox.expand( - child: Stack( - clipBehavior: Clip.none, - children: _buildSongItems( - orbitCenterX, - orbitCenterY, - orbitRadius, + // Cards reach the viewport's side edges while keeping the + // original top/bottom limits and inset gesture surface. + Positioned.fill( + child: ClipRect( + clipper: _OrbitStageClipper(sideOverflow), + child: Stack( + clipBehavior: Clip.none, + children: [ + if (widget.showGlow) + _buildSelectionGlow( + orbitCenterX, + orbitCenterY, + orbitRadius, + ), + // Songs — only rebuilds when scroll offset changes + Positioned.fill( + child: ValueListenableBuilder( + valueListenable: _scrollOffset, + builder: (context, _, __) { + return SizedBox.expand( + child: Stack( + clipBehavior: Clip.none, + children: _buildSongItems( + orbitCenterX, + orbitCenterY, + orbitRadius, + cardWidth: horizontal + ? landscapeCardWidth + : null, + cardHeight: horizontal + ? landscapeCardHeight + : null, + ), + ), + ); + }, + ), ), - ), - ); - }, + ], + ), + ), ), ], ), @@ -536,41 +603,85 @@ class _OrbitScrollState extends State ); } - Widget _buildOrbitPath(double centerX, double centerY, double radius) { - return RepaintBoundary( + Widget _buildOrbitPath( + double centerX, + double centerY, + double radius, + EdgeInsets sideOverflow, + ) { + final path = RepaintBoundary( child: CustomPaint( size: Size.infinite, painter: _OrbitPathPainter( - centerX: centerX, + centerX: centerX + sideOverflow.left, centerY: centerY, radius: radius, horizontal: widget.scrollDirection == Axis.horizontal, ), ), ); + final overflow = math.max(0.0, widget.pathOverflowBottom); + // Give the cached path the full paint width, not the padded gesture width. + // Its top still starts below the header/search; only its bottom overflows. + return Positioned( + left: -sideOverflow.left, + top: 0, + right: -sideOverflow.right, + bottom: -overflow, + child: ClipRect(child: path), + ); } - List _buildSongItems(double centerX, double centerY, double radius) { - final List items = []; + /// Unscaled card height for a horizontal (landscape) orbit. The selected + /// card is multiplied by [OrbitScroll.selectedScale] at paint time, so this + /// value is divided by that scale to keep the focused card fitting the stage. + double _landscapeCardHeight(Size size, bool compact) { + final cardScale = math.max(1.0, widget.selectedScale); + return compact + ? size.height * 0.72 / cardScale + : math.min(380.0, size.height * 0.85) / cardScale; + } - final horizontal = widget.scrollDirection == Axis.horizontal; - final compactHorizontal = horizontal && _layoutSize!.height < 420; + double _landscapeCardWidth(Size size, double cardHeight, bool compact) { final cardScale = math.max(1.0, widget.selectedScale); final viewportCardWidth = - (_layoutSize!.width * widget.cardWidthRatio * 0.5) / cardScale; - final compactCardHeight = _layoutSize!.height * 0.72 / cardScale; - final cardWidth = horizontal - ? compactHorizontal - ? math - .min(viewportCardWidth, compactCardHeight * 1.05) - .clamp(88.0, 280.0) - : viewportCardWidth.clamp(160.0, 280.0) - : null; - final cardHeight = horizontal - ? compactHorizontal - ? compactCardHeight - : math.min(380.0, _layoutSize!.height * 0.85) / cardScale - : null; + size.width * widget.cardWidthRatio * 0.5 / cardScale; + return compact + ? math.min(viewportCardWidth, cardHeight * 1.05).clamp(88.0, 280.0) + : viewportCardWidth.clamp(160.0, 280.0); + } + + /// The horizontal orbit circle sits below the visible arc. `centerYRatio` + /// names the focal song's vertical position, then the runtime clamp keeps + /// that song's full scaled height inside the stage. + double _landscapeFocalCenterY( + Size size, + double radius, + double cardHeight, + bool compact, + ) { + final cardScale = math.max(1.0, widget.selectedScale); + final selectedCardHeight = cardHeight * cardScale; + final focalRatio = widget.centerYRatio - (compact ? 0.04 : 0.0); + final desiredFocalY = size.height * focalRatio; + final minFocalY = selectedCardHeight / 2 + 8; + final maxFocalY = size.height - selectedCardHeight / 2 - 8; + final focalY = maxFocalY < minFocalY + ? size.height / 2 + : desiredFocalY.clamp(minFocalY, maxFocalY); + return focalY + radius; + } + + List _buildSongItems( + double centerX, + double centerY, + double radius, { + double? cardWidth, + double? cardHeight, + }) { + final List items = []; + + final horizontal = widget.scrollDirection == Axis.horizontal; final centerIndex = _scrollOffset.value.round(); @@ -606,7 +717,7 @@ class _OrbitScrollState extends State } else { scale = distantScale - (distanceFromCenter - 2.0) * 0.12; } - scale = scale.clamp(0.0, 1.25); + scale = scale.clamp(0.0, math.max(1.25, widget.selectedScale)); if (scale < 0.1) continue; @@ -625,12 +736,16 @@ class _OrbitScrollState extends State items.add( Positioned( + // Key the direct Stack child, not SongCard: as the visible window + // and back-to-front order advance, keeping the key here lets a + // surviving song's card State (and retained artwork) move between + // position slots instead of being disposed and rebuilt. + key: ValueKey(widget.songs[actualIndex].id), left: transform.position.x, top: transform.position.y, child: FractionalTranslation( translation: const Offset(-0.5, -0.5), child: SongCard( - key: ValueKey(widget.songs[actualIndex].id), song: widget.songs[actualIndex], scale: transform.scale, opacity: transform.opacity, @@ -703,6 +818,26 @@ class _OrbitScrollState extends State } } +/// Expand only the horizontal paint clip, without changing card geometry or +/// hit testing in the inset music viewport. +class _OrbitStageClipper extends CustomClipper { + final EdgeInsets overflow; + + const _OrbitStageClipper(this.overflow); + + @override + Rect getClip(Size size) => Rect.fromLTRB( + -overflow.left, + 0, + size.width + overflow.right, + size.height, + ); + + @override + bool shouldReclip(covariant _OrbitStageClipper oldClipper) => + overflow != oldClipper.overflow; +} + class _Position { final double x; final double y; @@ -737,25 +872,56 @@ class _OrbitPathPainter extends CustomPainter { this.horizontal = false, }); + /// Painted once per geometry change: this painter has no animation input, so + /// scrolling never repaints the ribbon. It is a full ring rather than a fixed + /// arc, so the visible ribbon always runs out of the viewport instead of + /// stopping at a chosen angle. The inner edge starts fully transparent, which + /// removes the hard cut where the shading meets the page background. @override void paint(Canvas canvas, Size size) { - final paint = Paint() - ..color = AppColors.glassBorder.withValues(alpha: 0.15) + if (radius <= 0) return; + + final center = Offset(centerX, centerY); + + // Band width tracks the orbit so the treatment holds together from the + // compact portrait arc to the wide tablet sweep. + final bandWidth = (radius * 0.18).clamp(56.0, 120.0); + final outerRadius = radius + bandWidth / 2; + final innerRadius = math.max(0.0, radius - bandWidth / 2); + final outerRect = Rect.fromCircle(center: center, radius: outerRadius); + final innerStop = outerRadius == 0 + ? 0.0 + : (innerRadius / outerRadius).clamp(0.0, 1.0); + + // Radial, not linear: the band is a surface, so it reads brightest along + // its outer rim and dissolves to nothing toward the orbit's interior. The + // leading transparent stop is the difference between a surface and a cut. + final bandPaint = Paint() ..style = PaintingStyle.stroke - ..strokeWidth = 1.0; - - final rect = Rect.fromCircle( - center: Offset(centerX, centerY), - radius: radius, - ); - - canvas.drawArc( - rect, - horizontal ? -math.pi / 2 - math.pi / 2.5 : -math.pi / 2.5, - 2 * math.pi / 2.5, - false, - paint, - ); + ..strokeWidth = outerRadius - innerRadius + ..shader = RadialGradient( + colors: [ + AppColors.accent.withValues(alpha: 0.0), + if (innerStop > 0) AppColors.accent.withValues(alpha: 0.0), + AppColors.accent.withValues(alpha: 0.04), + AppColors.accent.withValues(alpha: 0.07), + ], + stops: [ + 0.0, + if (innerStop > 0) innerStop, + innerStop + (1 - innerStop) * 0.55, + 1.0, + ], + ).createShader(outerRect); + canvas.drawCircle(center, (innerRadius + outerRadius) / 2, bandPaint); + + // A single lit rim defines the edge the shading falls away from. It is + // drawn on a full circle for the same reason as the band. + final rimPaint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1.0 + ..color = AppColors.accent.withValues(alpha: 0.24); + canvas.drawCircle(center, outerRadius - 0.5, rimPaint); } @override diff --git a/lib/features/songs/widgets/song_card.dart b/lib/features/songs/widgets/song_card.dart index 9f96d3f65..7962054d3 100644 --- a/lib/features/songs/widgets/song_card.dart +++ b/lib/features/songs/widgets/song_card.dart @@ -3,6 +3,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flick/core/theme/app_colors.dart'; +import 'package:flick/core/theme/app_theme.dart'; import 'package:flick/core/constants/app_constants.dart'; import 'package:flick/core/utils/app_haptics.dart'; import 'package:flick/models/song.dart'; @@ -87,10 +88,40 @@ class _SongCardState extends State { bool _queuedFlash = false; bool _favoriteFlash = false; + /// Retained card face. Orbit motion changes only scale/opacity, so keeping + /// this subtree identical between animation ticks avoids rebuilding the + /// artwork, gradients and text on every frame. + Widget? _cardFace; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Inherited widgets may have changed (theme, media query, etc.). + _cardFace = null; + } + + @override + void didUpdateWidget(covariant SongCard oldWidget) { + super.didUpdateWidget(oldWidget); + // Song equality compares only the id, so use identity here: metadata or + // artwork for the same id must refresh the retained face. + if (!identical(oldWidget.song, widget.song) || + oldWidget.isSelected != widget.isSelected || + oldWidget.isSelectionMode != widget.isSelectionMode || + oldWidget.isMultiSelected != widget.isMultiSelected || + oldWidget.artSizeBase != widget.artSizeBase || + oldWidget.artSizeLarge != widget.artSizeLarge || + oldWidget.artResolutionMultiplier != widget.artResolutionMultiplier || + oldWidget.artworkAbove != widget.artworkAbove) { + _cardFace = null; + } + } + @override Widget build(BuildContext context) { final artSize = widget.isSelected ? widget.artSizeLarge : widget.artSizeBase; + final cardFace = _cardFace ??= _buildCardFace(artSize); final cardWidth = widget.width ?? MediaQuery.sizeOf(context).width * widget.cardWidthRatio; @@ -172,17 +203,19 @@ class _SongCardState extends State { onHorizontalDragUpdate: dragUpdate, onHorizontalDragEnd: dragEnd, onHorizontalDragCancel: dragCancel, - child: RepaintBoundary( - child: Transform.scale( - scale: widget.scale.isFinite ? widget.scale : 1.0, - child: Opacity( - opacity: widget.opacity.isFinite ? widget.opacity.clamp(0.0, 1.0) : 1.0, + // Keep the changing scale/opacity outside the retained artwork: the + // RepaintBoundary caches the card face while the motion above it moves. + child: Transform.scale( + scale: widget.scale.isFinite ? widget.scale : 1.0, + child: Opacity( + opacity: widget.opacity.isFinite ? widget.opacity.clamp(0.0, 1.0) : 1.0, + child: RepaintBoundary( child: SizedBox( width: cardWidth, height: cardHeight, child: showSwipeOverlay - ? _buildSwipeStack(cardWidth, artSize) - : _buildCardFace(artSize), + ? _buildSwipeStack(cardWidth, cardFace) + : cardFace, ), ), ), @@ -247,129 +280,113 @@ class _SongCardState extends State { Widget _buildArtworkCard(double artSize) { return Semantics( selected: widget.isSelected || widget.isMultiSelected, - child: ClipRRect( - borderRadius: BorderRadius.circular(AppConstants.radiusLg), - child: ColoredBox( + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( color: AppColors.surface, - child: LayoutBuilder( - builder: (context, constraints) { - final enlargedText = - MediaQuery.textScalerOf(context).scale(14) > 19; - final showArtwork = - constraints.maxHeight > (enlargedText ? 150 : 100); - final text = Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, + borderRadius: BorderRadius.circular(AppConstants.radiusLg), + border: Border.all(color: AppColors.glassBorder), + ), + padding: const EdgeInsets.all(8), + child: LayoutBuilder( + builder: (context, constraints) { + // Short stages keep the artwork instead of collapsing to a text + // overlay. The footer stays one line per field and scrolls only + // when enlarged text needs more room than the card can give it. + final footerMaxHeight = constraints.maxHeight * 0.45; + return Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text( - widget.song.title, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular( + AppConstants.radiusMd, + ), + child: _buildRawImage( + widget.song.albumArt ?? '', + audioSourcePath: widget.song.filePath, + artSize: math.max( + artSize, + constraints.maxWidth, + ), + ), ), ), - const SizedBox(height: 4), - Text( - widget.song.artist, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'GoogleSansFlex', - fontSize: 12, - color: AppColors.textSecondary, + const SizedBox(height: 10), + ConstrainedBox( + constraints: BoxConstraints(maxHeight: footerMaxHeight), + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 4), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.song.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'GoogleSansFlex', + fontFamilyFallback: AppTheme.fontFamilyFallback, + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.15, + letterSpacing: -0.1, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + widget.song.artist, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'GoogleSansFlex', + fontFamilyFallback: AppTheme.fontFamilyFallback, + fontSize: 12, + height: 1.2, + color: AppColors.textSecondary, + ), + ), + ], + ), ), ), ], ), - ); - if (!showArtwork) { - // A short window still shows the music, not a row of text-only - // cards. Overlay the identity instead of squeezing out the art. - return Stack( - fit: StackFit.expand, - children: [ - _buildAlbumWithGradient(artSize), - const DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0x33000000), Color(0xE6000000)], - ), + if (widget.isSelectionMode) + Positioned( + top: 4, + right: 4, + child: Container( + width: 26, + height: 26, + decoration: const BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, ), - ), - Align( - alignment: Alignment.bottomLeft, - child: SingleChildScrollView(child: text), - ), - if (widget.isSelectionMode) - Positioned( - top: 8, - right: 8, - child: Icon( - widget.isMultiSelected - ? Icons.check_circle_rounded - : Icons.radio_button_unchecked_rounded, - color: AppColors.textPrimary, - ), - ), - ], - ); - } - return Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (showArtwork) - Expanded( - child: _buildRawImage( - widget.song.albumArt ?? '', - audioSourcePath: widget.song.filePath, - artSize: math.max(artSize, constraints.maxWidth), - ), - ), - if (showArtwork) - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: constraints.maxHeight * 0.55, - ), - child: SingleChildScrollView(child: text), - ) - else - Expanded(child: SingleChildScrollView(child: text)), - ], - ), - if (widget.isSelectionMode) - Positioned( - top: 8, - right: 8, child: Icon( widget.isMultiSelected ? Icons.check_circle_rounded : Icons.radio_button_unchecked_rounded, - color: AppColors.textPrimary, + color: widget.isMultiSelected + ? AppColors.accent + : AppColors.textPrimary, + size: 18, ), ), - ], - ); - }, - ), + ), + ], + ); + }, ), ), ); } - Widget _buildSwipeStack(double cardWidth, double artSize) { + Widget _buildSwipeStack(double cardWidth, Widget cardFace) { final queueProgress = (-_dragDx / 110).clamp(0.0, 1.0); final favoriteProgress = (_dragDx / 110).clamp(0.0, 1.0); final isFlashing = _queuedFlash || _favoriteFlash; @@ -458,7 +475,7 @@ class _SongCardState extends State { ] : null, ), - child: _buildCardFace(artSize), + child: cardFace, ), ), ), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index b2b580a1f..34c1f36be 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -5542,6 +5542,14 @@ "@labelOrganization": { "description": "lib/features/songs/widgets/song_actions_bottom_sheet.dart:699" }, + "landscape": "Landscape", + "@landscape": { + "description": "Orbit settings profile for windows that are wider than they are tall." + }, + "landscapeLayoutReset": "Landscape layout reset", + "@landscapeLayoutReset": { + "description": "Confirmation shown after resetting the landscape orbit layout." + }, "language": "Language", "@language": { "description": "Settings entry label and the title of the language picker." @@ -7159,9 +7167,21 @@ "@orbital": { "description": "lib/models/song_view_mode.dart:17" }, - "orbitalSettingsReset": "Orbital settings reset", - "@orbitalSettingsReset": { - "description": "lib/features/settings/screens/orbit_settings_screen.dart:205" + "orbitLandscapeHint": "Used when the app window is wider than it is tall.", + "@orbitLandscapeHint": { + "description": "Explains when the landscape orbit profile is applied." + }, + "orbitLayoutProfile": "Layout Profile", + "@orbitLayoutProfile": { + "description": "Header for choosing the portrait or landscape orbit profile." + }, + "orbitPortraitHint": "Used when the app window is taller than it is wide.", + "@orbitPortraitHint": { + "description": "Explains when the portrait orbit profile is applied." + }, + "orbitSharedVisuals": "Shared Visuals", + "@orbitSharedVisuals": { + "description": "Header for orbit visuals shared by both orientation profiles." }, "otherPlaylists": "Other Playlists", "@otherPlaylists": { @@ -7620,6 +7640,14 @@ "@pleaseEnterYourListenbrainzUserToken": { "description": "lib/features/settings/widgets/listenbrainz_settings_tile.dart:584" }, + "portrait": "Portrait", + "@portrait": { + "description": "Orbit settings profile for windows that are taller than they are wide." + }, + "portraitLayoutReset": "Portrait layout reset", + "@portraitLayoutReset": { + "description": "Confirmation shown after resetting the portrait orbit layout." + }, "posterPreviewPlays": "{arg1} poster preview. {arg2} plays.", "@posterPreviewPlays": { "description": "lib/features/recap/screens/replay_studio_screen.dart:423", @@ -8360,9 +8388,9 @@ "@restoreDefaultProgressBarSize": { "description": "lib/features/settings/screens/ui_customization_settings_screen.dart" }, - "restoreTheOriginalOrbitalLayout": "Restore the original orbital layout", - "@restoreTheOriginalOrbitalLayout": { - "description": "lib/features/settings/screens/orbit_settings_screen.dart:200" + "restoreThisProfilesOrbitLayout": "Restore this profile's original orbit layout", + "@restoreThisProfilesOrbitLayout": { + "description": "Subtitle for resetting only the selected orbit profile." }, "results": "Results ({arg1})", "@results": { diff --git a/lib/l10n/app_tl.arb b/lib/l10n/app_tl.arb index 52e376796..972f210de 100644 --- a/lib/l10n/app_tl.arb +++ b/lib/l10n/app_tl.arb @@ -1039,6 +1039,8 @@ "koFi": "Ko-fi", "label": "Label", "labelOrganization": "Label / Organisasyon", + "landscape": "Landscape", + "landscapeLayoutReset": "Na-reset ang landscape layout", "language": "Wika", "languageSectionDescription": "Piliin ang wikang gagamitin sa aplikasyon.", "languageSystemDefault": "Wika ng aparato", @@ -1386,7 +1388,10 @@ "openslEsLegacy": "OpenSL ES (luma)", "orRemoveAllUsingRecommendedPicks": "O alisin lahat gamit ang mga inirekomendang piliin", "orbital": "Orbital", - "orbitalSettingsReset": "Na-reset ang mga pagsasaayos ng Orbital", + "orbitLandscapeHint": "Ginagamit kapag mas malapad kaysa matangkad ang window ng app.", + "orbitLayoutProfile": "Profile ng layout", + "orbitPortraitHint": "Ginagamit kapag mas matangkad kaysa lapad ang window ng app.", + "orbitSharedVisuals": "Mga shared na visual", "otherPlaylists": "Iba pang talaan ng kanta", "ourRustPoweredEngineCleanerSound": "Ang aming engine na pinapagana ng Rust. Mas malinaw na tunog at mas makinis na pagtugtog — inirerekomenda sa araw-araw na pakikinig.", "outlineTheMiniPlayer": "Lagyan ng guhit sa gilid ang mini player", @@ -1492,6 +1497,8 @@ "playsInRotation": "pagtugtog sa pag-ikot", "pleaseEnterAPlaylistName": "Maglagay ng pangalan ng talaan ng kanta", "pleaseEnterYourListenbrainzUserToken": "Ilagay ang iyong ListenBrainz user token.", + "portrait": "Portrait", + "portraitLayoutReset": "Na-reset ang portrait layout", "posterPreviewPlays": "Paunang tingin ng poster ng {arg1}. {arg2} pagtugtog.", "preAmp": "Pre-amp", "preamp": "Preamp", @@ -1644,7 +1651,7 @@ "restartYourDeviceToApplyOutput": "Simulan muli ang aparato upang mailapat ang mga pagbabago sa format ng output.", "restartingFlick": "Muling sinisimulan ang Flick", "restoreDefaultProgressBarSize": "Restore the selected progress bar style to its default size.", - "restoreTheOriginalOrbitalLayout": "Ibalik ang orihinal na ayos ng Orbital", + "restoreThisProfilesOrbitLayout": "Ibalik ang orihinal na orbit layout ng profile na ito", "results": "Mga resulta ({arg1})", "resumeAFamiliarFavoriteOrShuffle": "Balikan ang pamilyar na paborito o ihalo ang isa sa mga halong binuo mula sa iyong pakikinig.", "resumeOnReconnect": "Ituloy kapag muling nakakonekta", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 706ffb761..ef82a72f4 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1191,6 +1191,8 @@ "koFi": "Ko-fi", "label": "标签", "labelOrganization": "厂牌 / 组织", + "landscape": "横屏", + "landscapeLayoutReset": "横屏布局已重置", "language": "语言", "languageSectionDescription": "选择应用界面使用的语言。", "languageSystemDefault": "跟随系统", @@ -1539,7 +1541,10 @@ "openslEsLegacy": "OpenSL ES(旧版)", "orRemoveAllUsingRecommendedPicks": "或使用推荐选择移除全部", "orbital": "Orbital", - "orbitalSettingsReset": "轨道设置已重置", + "orbitLandscapeHint": "当应用窗口宽大于高时使用。", + "orbitLayoutProfile": "布局配置", + "orbitPortraitHint": "当应用窗口高大于宽时使用。", + "orbitSharedVisuals": "共享视觉效果", "otherPlaylists": "其他播放列表", "ourRustPoweredEngineCleanerSound": "我们基于 Rust 的引擎。声音更干净、播放更流畅,推荐日常聆听。", "outlineTheMiniPlayer": "为迷你播放器描边", @@ -1643,6 +1648,8 @@ "playsInRotation": "循环播放次数", "pleaseEnterAPlaylistName": "请输入播放列表名称", "pleaseEnterYourListenbrainzUserToken": "请输入你的 ListenBrainz 用户令牌。", + "portrait": "竖屏", + "portraitLayoutReset": "竖屏布局已重置", "posterPreviewPlays": "{arg1} 海报预览。{arg2} 次播放。", "preAmp": "前置放大", "preamp": "前置放大", @@ -1795,7 +1802,7 @@ "restartYourDeviceToApplyOutput": "重启设备以应用输出格式更改。", "restartingFlick": "正在重启 Flick", "restoreDefaultProgressBarSize": "将所选进度条样式恢复为默认大小。", - "restoreTheOriginalOrbitalLayout": "恢复原始轨道布局", + "restoreThisProfilesOrbitLayout": "恢复此配置的原始轨道布局", "results": "结果({arg1})", "resumeAFamiliarFavoriteOrShuffle": "继续播放熟悉的收藏,或随机播放由你的收听生成的混音。", "resumeOnReconnect": "重新连接时继续", diff --git a/lib/l10n/generated/app_localizations.dart b/lib/l10n/generated/app_localizations.dart index 98d1a9a2c..fdf2731ea 100644 --- a/lib/l10n/generated/app_localizations.dart +++ b/lib/l10n/generated/app_localizations.dart @@ -7199,6 +7199,18 @@ abstract class AppLocalizations { /// **'Label / Organization'** String get labelOrganization; + /// Orbit settings profile for windows that are wider than they are tall. + /// + /// In en, this message translates to: + /// **'Landscape'** + String get landscape; + + /// Confirmation shown after resetting the landscape orbit layout. + /// + /// In en, this message translates to: + /// **'Landscape layout reset'** + String get landscapeLayoutReset; + /// Settings entry label and the title of the language picker. /// /// In en, this message translates to: @@ -9286,11 +9298,29 @@ abstract class AppLocalizations { /// **'Orbital'** String get orbital; - /// lib/features/settings/screens/orbit_settings_screen.dart:205 + /// Explains when the landscape orbit profile is applied. /// /// In en, this message translates to: - /// **'Orbital settings reset'** - String get orbitalSettingsReset; + /// **'Used when the app window is wider than it is tall.'** + String get orbitLandscapeHint; + + /// Header for choosing the portrait or landscape orbit profile. + /// + /// In en, this message translates to: + /// **'Layout Profile'** + String get orbitLayoutProfile; + + /// Explains when the portrait orbit profile is applied. + /// + /// In en, this message translates to: + /// **'Used when the app window is taller than it is wide.'** + String get orbitPortraitHint; + + /// Header for orbit visuals shared by both orientation profiles. + /// + /// In en, this message translates to: + /// **'Shared Visuals'** + String get orbitSharedVisuals; /// lib/features/playlists/screens/playlist_detail_screen.dart:604 /// @@ -9910,6 +9940,18 @@ abstract class AppLocalizations { /// **'Please enter your ListenBrainz user token.'** String get pleaseEnterYourListenbrainzUserToken; + /// Orbit settings profile for windows that are taller than they are wide. + /// + /// In en, this message translates to: + /// **'Portrait'** + String get portrait; + + /// Confirmation shown after resetting the portrait orbit layout. + /// + /// In en, this message translates to: + /// **'Portrait layout reset'** + String get portraitLayoutReset; + /// lib/features/recap/screens/replay_studio_screen.dart:423 /// /// In en, this message translates to: @@ -10822,11 +10864,11 @@ abstract class AppLocalizations { /// **'Restore the selected progress bar style to its default size.'** String get restoreDefaultProgressBarSize; - /// lib/features/settings/screens/orbit_settings_screen.dart:200 + /// Subtitle for resetting only the selected orbit profile. /// /// In en, this message translates to: - /// **'Restore the original orbital layout'** - String get restoreTheOriginalOrbitalLayout; + /// **'Restore this profile\'s original orbit layout'** + String get restoreThisProfilesOrbitLayout; /// lib/features/artists/screens/artists_screen.dart:510 /// diff --git a/lib/l10n/generated/app_localizations_en.dart b/lib/l10n/generated/app_localizations_en.dart index e38e5aebd..b5209e94a 100644 --- a/lib/l10n/generated/app_localizations_en.dart +++ b/lib/l10n/generated/app_localizations_en.dart @@ -4230,6 +4230,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get labelOrganization => 'Label / Organization'; + @override + String get landscape => 'Landscape'; + + @override + String get landscapeLayoutReset => 'Landscape layout reset'; + @override String get language => 'Language'; @@ -5427,7 +5433,18 @@ class AppLocalizationsEn extends AppLocalizations { String get orbital => 'Orbital'; @override - String get orbitalSettingsReset => 'Orbital settings reset'; + String get orbitLandscapeHint => + 'Used when the app window is wider than it is tall.'; + + @override + String get orbitLayoutProfile => 'Layout Profile'; + + @override + String get orbitPortraitHint => + 'Used when the app window is taller than it is wide.'; + + @override + String get orbitSharedVisuals => 'Shared Visuals'; @override String get otherPlaylists => 'Other Playlists'; @@ -5812,6 +5829,12 @@ class AppLocalizationsEn extends AppLocalizations { String get pleaseEnterYourListenbrainzUserToken => 'Please enter your ListenBrainz user token.'; + @override + String get portrait => 'Portrait'; + + @override + String get portraitLayoutReset => 'Portrait layout reset'; + @override String posterPreviewPlays(Object arg1, Object arg2) { return '$arg1 poster preview. $arg2 plays.'; @@ -6342,8 +6365,8 @@ class AppLocalizationsEn extends AppLocalizations { 'Restore the selected progress bar style to its default size.'; @override - String get restoreTheOriginalOrbitalLayout => - 'Restore the original orbital layout'; + String get restoreThisProfilesOrbitLayout => + 'Restore this profile\'s original orbit layout'; @override String results(Object arg1) { diff --git a/lib/l10n/generated/app_localizations_tl.dart b/lib/l10n/generated/app_localizations_tl.dart index df08a03c8..a79078d26 100644 --- a/lib/l10n/generated/app_localizations_tl.dart +++ b/lib/l10n/generated/app_localizations_tl.dart @@ -4282,6 +4282,12 @@ class AppLocalizationsTl extends AppLocalizations { @override String get labelOrganization => 'Label / Organisasyon'; + @override + String get landscape => 'Landscape'; + + @override + String get landscapeLayoutReset => 'Na-reset ang landscape layout'; + @override String get language => 'Wika'; @@ -5486,7 +5492,18 @@ class AppLocalizationsTl extends AppLocalizations { String get orbital => 'Orbital'; @override - String get orbitalSettingsReset => 'Na-reset ang mga pagsasaayos ng Orbital'; + String get orbitLandscapeHint => + 'Ginagamit kapag mas malapad kaysa matangkad ang window ng app.'; + + @override + String get orbitLayoutProfile => 'Profile ng layout'; + + @override + String get orbitPortraitHint => + 'Ginagamit kapag mas matangkad kaysa lapad ang window ng app.'; + + @override + String get orbitSharedVisuals => 'Mga shared na visual'; @override String get otherPlaylists => 'Iba pang talaan ng kanta'; @@ -5876,6 +5893,12 @@ class AppLocalizationsTl extends AppLocalizations { String get pleaseEnterYourListenbrainzUserToken => 'Ilagay ang iyong ListenBrainz user token.'; + @override + String get portrait => 'Portrait'; + + @override + String get portraitLayoutReset => 'Na-reset ang portrait layout'; + @override String posterPreviewPlays(Object arg1, Object arg2) { return 'Paunang tingin ng poster ng $arg1. $arg2 pagtugtog.'; @@ -6412,8 +6435,8 @@ class AppLocalizationsTl extends AppLocalizations { 'Restore the selected progress bar style to its default size.'; @override - String get restoreTheOriginalOrbitalLayout => - 'Ibalik ang orihinal na ayos ng Orbital'; + String get restoreThisProfilesOrbitLayout => + 'Ibalik ang orihinal na orbit layout ng profile na ito'; @override String results(Object arg1) { diff --git a/lib/l10n/generated/app_localizations_zh.dart b/lib/l10n/generated/app_localizations_zh.dart index e2d85c925..5023d9c87 100644 --- a/lib/l10n/generated/app_localizations_zh.dart +++ b/lib/l10n/generated/app_localizations_zh.dart @@ -3930,6 +3930,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get labelOrganization => '厂牌 / 组织'; + @override + String get landscape => '横屏'; + + @override + String get landscapeLayoutReset => '横屏布局已重置'; + @override String get language => '语言'; @@ -5070,7 +5076,16 @@ class AppLocalizationsZh extends AppLocalizations { String get orbital => 'Orbital'; @override - String get orbitalSettingsReset => '轨道设置已重置'; + String get orbitLandscapeHint => '当应用窗口宽大于高时使用。'; + + @override + String get orbitLayoutProfile => '布局配置'; + + @override + String get orbitPortraitHint => '当应用窗口高大于宽时使用。'; + + @override + String get orbitSharedVisuals => '共享视觉效果'; @override String get otherPlaylists => '其他播放列表'; @@ -5404,6 +5419,12 @@ class AppLocalizationsZh extends AppLocalizations { @override String get pleaseEnterYourListenbrainzUserToken => '请输入你的 ListenBrainz 用户令牌。'; + @override + String get portrait => '竖屏'; + + @override + String get portraitLayoutReset => '竖屏布局已重置'; + @override String posterPreviewPlays(Object arg1, Object arg2) { return '$arg1 海报预览。$arg2 次播放。'; @@ -5911,7 +5932,7 @@ class AppLocalizationsZh extends AppLocalizations { String get restoreDefaultProgressBarSize => '将所选进度条样式恢复为默认大小。'; @override - String get restoreTheOriginalOrbitalLayout => '恢复原始轨道布局'; + String get restoreThisProfilesOrbitLayout => '恢复此配置的原始轨道布局'; @override String results(Object arg1) { diff --git a/lib/providers/app_preferences_provider.dart b/lib/providers/app_preferences_provider.dart index 83d6fb5de..48c06697a 100644 --- a/lib/providers/app_preferences_provider.dart +++ b/lib/providers/app_preferences_provider.dart @@ -94,9 +94,7 @@ class AppPreferencesNotifier extends Notifier { Future setShowUsbVolumeOnMenu(bool value) async { if (state.showUsbVolumeOnMenu == value) return; state = state.copyWith(showUsbVolumeOnMenu: value); - await ref - .read(appPreferencesServiceProvider) - .setShowUsbVolumeOnMenu(value); + await ref.read(appPreferencesServiceProvider).setShowUsbVolumeOnMenu(value); } Future setShowUsbVolumeOnSettings(bool value) async { @@ -408,7 +406,9 @@ class AppPreferencesNotifier extends Notifier { Future setWidgetCompactBgOpacity(int value) async { if (state.widgetCompactBgOpacity == value) return; state = state.copyWith(widgetCompactBgOpacity: value); - await ref.read(appPreferencesServiceProvider).setWidgetCompactBgOpacity(value); + await ref + .read(appPreferencesServiceProvider) + .setWidgetCompactBgOpacity(value); } Future setWidgetCompactShowAlbumArt(bool value) async { @@ -492,9 +492,7 @@ class AppPreferencesNotifier extends Notifier { Future setLeftTopActionButton(String value) async { if (state.leftTopActionButton == value) return; state = state.copyWith(leftTopActionButton: value); - await ref - .read(appPreferencesServiceProvider) - .setLeftTopActionButton(value); + await ref.read(appPreferencesServiceProvider).setLeftTopActionButton(value); } Future setRightTopActionButton(String value) async { @@ -522,9 +520,7 @@ class AppPreferencesNotifier extends Notifier { Future setGlanceCardMinimized(bool value) async { if (state.glanceCardMinimized == value) return; state = state.copyWith(glanceCardMinimized: value); - await ref - .read(appPreferencesServiceProvider) - .setGlanceCardMinimized(value); + await ref.read(appPreferencesServiceProvider).setGlanceCardMinimized(value); } Future setReplaceAlbumWithBitPerfectCapsule(bool value) async { @@ -538,7 +534,9 @@ class AppPreferencesNotifier extends Notifier { Future setAlbumsStretchArtwork(bool value) async { if (state.albumsStretchArtwork == value) return; state = state.copyWith(albumsStretchArtwork: value); - await ref.read(appPreferencesServiceProvider).setAlbumsStretchArtwork(value); + await ref + .read(appPreferencesServiceProvider) + .setAlbumsStretchArtwork(value); } Future setAnimatedAlbumArt(bool value) async { @@ -700,9 +698,7 @@ class AppPreferencesNotifier extends Notifier { Future setBtLdacBitsPerSample(int value) async { if (state.btLdacBitsPerSample == value) return; state = state.copyWith(btLdacBitsPerSample: value); - await ref - .read(appPreferencesServiceProvider) - .setBtLdacBitsPerSample(value); + await ref.read(appPreferencesServiceProvider).setBtLdacBitsPerSample(value); } Future setFloatingPlayerEnabled(bool value) async { @@ -794,9 +790,7 @@ class AppPreferencesNotifier extends Notifier { Future setOrbitCardWidthRatio(double value) async { if (state.orbitCardWidthRatio == value) return; state = state.copyWith(orbitCardWidthRatio: value); - await ref - .read(appPreferencesServiceProvider) - .setOrbitCardWidthRatio(value); + await ref.read(appPreferencesServiceProvider).setOrbitCardWidthRatio(value); } Future setOrbitArtResolutionMultiplier(double value) async { @@ -819,6 +813,60 @@ class AppPreferencesNotifier extends Notifier { await ref.read(appPreferencesServiceProvider).setOrbitShowGlow(value); } + Future setOrbitLandscapeRadiusRatio(double value) async { + if (state.orbitLandscapeRadiusRatio == value) return; + state = state.copyWith(orbitLandscapeRadiusRatio: value); + await ref + .read(appPreferencesServiceProvider) + .setOrbitLandscapeRadiusRatio(value); + } + + Future setOrbitLandscapeCenterYRatio(double value) async { + if (state.orbitLandscapeCenterYRatio == value) return; + state = state.copyWith(orbitLandscapeCenterYRatio: value); + await ref + .read(appPreferencesServiceProvider) + .setOrbitLandscapeCenterYRatio(value); + } + + Future setOrbitLandscapeItemSpacing(double value) async { + if (state.orbitLandscapeItemSpacing == value) return; + state = state.copyWith(orbitLandscapeItemSpacing: value); + await ref + .read(appPreferencesServiceProvider) + .setOrbitLandscapeItemSpacing(value); + } + + Future setOrbitLandscapeSelectedScale(double value) async { + if (state.orbitLandscapeSelectedScale == value) return; + state = state.copyWith(orbitLandscapeSelectedScale: value); + await ref + .read(appPreferencesServiceProvider) + .setOrbitLandscapeSelectedScale(value); + } + + Future setOrbitLandscapeDepth(double value) async { + if (state.orbitLandscapeDepth == value) return; + state = state.copyWith(orbitLandscapeDepth: value); + await ref.read(appPreferencesServiceProvider).setOrbitLandscapeDepth(value); + } + + Future setOrbitLandscapeVisibleItems(int value) async { + if (state.orbitLandscapeVisibleItems == value) return; + state = state.copyWith(orbitLandscapeVisibleItems: value); + await ref + .read(appPreferencesServiceProvider) + .setOrbitLandscapeVisibleItems(value); + } + + Future setOrbitLandscapeCardWidthRatio(double value) async { + if (state.orbitLandscapeCardWidthRatio == value) return; + state = state.copyWith(orbitLandscapeCardWidthRatio: value); + await ref + .read(appPreferencesServiceProvider) + .setOrbitLandscapeCardWidthRatio(value); + } + Future setStreaksEnabled(bool value) async { if (state.streaksEnabled == value) return; state = state.copyWith(streaksEnabled: value); @@ -861,6 +909,34 @@ class AppPreferencesNotifier extends Notifier { .setSongTileThumbnailMode(value); } + Future resetOrbitLayout({required bool landscape}) async { + if (landscape) { + state = state.copyWith( + orbitLandscapeRadiusRatio: 1.0, + orbitLandscapeCenterYRatio: 0.48, + orbitLandscapeItemSpacing: 0.28, + orbitLandscapeSelectedScale: 1.25, + orbitLandscapeDepth: 0.75, + orbitLandscapeVisibleItems: 5, + orbitLandscapeCardWidthRatio: 0.68, + ); + await ref.read(appPreferencesServiceProvider).clearOrbitLandscapeLayout(); + return; + } + state = state.copyWith( + orbitRadiusRatio: 1.0, + orbitCenterOffsetRatio: -0.5, + orbitCenterYRatio: 0.42, + orbitItemSpacing: 0.28, + orbitSelectedScale: 1.25, + orbitDepth: 0.75, + orbitVisibleItems: 5, + orbitCardArtSize: 64.0, + orbitCardWidthRatio: 0.68, + ); + await ref.read(appPreferencesServiceProvider).clearOrbitPortraitLayout(); + } + Future resetOrbitSettings() async { state = state.copyWith( orbitRadiusRatio: 1.0, @@ -875,6 +951,13 @@ class AppPreferencesNotifier extends Notifier { orbitArtResolutionMultiplier: 2.0, orbitShowPath: true, orbitShowGlow: true, + orbitLandscapeRadiusRatio: 1.0, + orbitLandscapeCenterYRatio: 0.48, + orbitLandscapeItemSpacing: 0.28, + orbitLandscapeSelectedScale: 1.25, + orbitLandscapeDepth: 0.75, + orbitLandscapeVisibleItems: 5, + orbitLandscapeCardWidthRatio: 0.68, ); await ref.read(appPreferencesServiceProvider).clearOrbitSettings(); } diff --git a/lib/services/app_preferences_service.dart b/lib/services/app_preferences_service.dart index 2dddb6b27..c76eec49b 100644 --- a/lib/services/app_preferences_service.dart +++ b/lib/services/app_preferences_service.dart @@ -107,12 +107,20 @@ class AppPreferences { final double orbitArtResolutionMultiplier; final bool orbitShowPath; final bool orbitShowGlow; + final double orbitLandscapeRadiusRatio; + final double orbitLandscapeCenterYRatio; + final double orbitLandscapeItemSpacing; + final double orbitLandscapeSelectedScale; + final double orbitLandscapeDepth; + final int orbitLandscapeVisibleItems; + final double orbitLandscapeCardWidthRatio; final bool streaksEnabled; final bool showMoreFromArtist; final bool showMoreArtists; final bool detailHeaderArtExpanded; final bool detailHeaderCenteredTitle; - final String songTileThumbnailMode; // 'artwork', 'trackNumber', or 'trackNumberOnArt' + final String + songTileThumbnailMode; // 'artwork', 'trackNumber', or 'trackNumberOnArt' final bool extendedVolumeEnabled; final String replayGainMode; // 'off', 'track', or 'album' final String lyricsTextAlign; // 'left', 'center', or 'right' @@ -227,6 +235,13 @@ class AppPreferences { this.orbitArtResolutionMultiplier = 2.0, this.orbitShowPath = true, this.orbitShowGlow = true, + this.orbitLandscapeRadiusRatio = 1.0, + this.orbitLandscapeCenterYRatio = 0.48, + this.orbitLandscapeItemSpacing = 0.28, + this.orbitLandscapeSelectedScale = 1.25, + this.orbitLandscapeDepth = 0.75, + this.orbitLandscapeVisibleItems = 5, + this.orbitLandscapeCardWidthRatio = 0.68, this.streaksEnabled = true, this.showMoreFromArtist = true, this.showMoreArtists = true, @@ -348,6 +363,13 @@ class AppPreferences { double? orbitArtResolutionMultiplier, bool? orbitShowPath, bool? orbitShowGlow, + double? orbitLandscapeRadiusRatio, + double? orbitLandscapeCenterYRatio, + double? orbitLandscapeItemSpacing, + double? orbitLandscapeSelectedScale, + double? orbitLandscapeDepth, + int? orbitLandscapeVisibleItems, + double? orbitLandscapeCardWidthRatio, bool? streaksEnabled, bool? showMoreFromArtist, bool? showMoreArtists, @@ -373,8 +395,7 @@ class AppPreferences { showBrowseMore: showBrowseMore ?? this.showBrowseMore, showQuickAccess: showQuickAccess ?? this.showQuickAccess, showEngineSelector: showEngineSelector ?? this.showEngineSelector, - showUsbVolumeOnMenu: - showUsbVolumeOnMenu ?? this.showUsbVolumeOnMenu, + showUsbVolumeOnMenu: showUsbVolumeOnMenu ?? this.showUsbVolumeOnMenu, showUsbVolumeOnSettings: showUsbVolumeOnSettings ?? this.showUsbVolumeOnSettings, crossfadeEnabled: crossfadeEnabled ?? this.crossfadeEnabled, @@ -454,8 +475,7 @@ class AppPreferences { replaceAlbumWithBitPerfectCapsule: replaceAlbumWithBitPerfectCapsule ?? this.replaceAlbumWithBitPerfectCapsule, - albumsStretchArtwork: - albumsStretchArtwork ?? this.albumsStretchArtwork, + albumsStretchArtwork: albumsStretchArtwork ?? this.albumsStretchArtwork, animatedAlbumArt: animatedAlbumArt ?? this.animatedAlbumArt, folderGridPageSize: folderGridPageSize ?? this.folderGridPageSize, lastSeenChangelogVersion: @@ -481,26 +501,21 @@ class AppPreferences { pauseOnUsbDacDisconnect ?? this.pauseOnUsbDacDisconnect, pauseOnBluetoothConnect: pauseOnBluetoothConnect ?? this.pauseOnBluetoothConnect, - pauseOnUsbDacConnect: - pauseOnUsbDacConnect ?? this.pauseOnUsbDacConnect, + pauseOnUsbDacConnect: pauseOnUsbDacConnect ?? this.pauseOnUsbDacConnect, preferredBluetoothDevice: preferredBluetoothDevice ?? this.preferredBluetoothDevice, btPreferredCodec: btPreferredCodec ?? this.btPreferredCodec, btLdacBitrate: btLdacBitrate ?? this.btLdacBitrate, - btAbsoluteVolumeSync: - btAbsoluteVolumeSync ?? this.btAbsoluteVolumeSync, - btEnableCodecControl: - btEnableCodecControl ?? this.btEnableCodecControl, + btAbsoluteVolumeSync: btAbsoluteVolumeSync ?? this.btAbsoluteVolumeSync, + btEnableCodecControl: btEnableCodecControl ?? this.btEnableCodecControl, btSampleRate: btSampleRate ?? this.btSampleRate, - btLdacBitsPerSample: - btLdacBitsPerSample ?? this.btLdacBitsPerSample, + btLdacBitsPerSample: btLdacBitsPerSample ?? this.btLdacBitsPerSample, floatingPlayerEnabled: floatingPlayerEnabled ?? this.floatingPlayerEnabled, floatingIslandEnabled: floatingIslandEnabled ?? this.floatingIslandEnabled, autoFocusSearch: autoFocusSearch ?? this.autoFocusSearch, - searchPlaybackMode: - searchPlaybackMode ?? this.searchPlaybackMode, + searchPlaybackMode: searchPlaybackMode ?? this.searchPlaybackMode, refreshRateMode: refreshRateMode ?? this.refreshRateMode, visualizerEnabled: visualizerEnabled ?? this.visualizerEnabled, orbitRadiusRatio: orbitRadiusRatio ?? this.orbitRadiusRatio, @@ -517,6 +532,19 @@ class AppPreferences { orbitArtResolutionMultiplier ?? this.orbitArtResolutionMultiplier, orbitShowPath: orbitShowPath ?? this.orbitShowPath, orbitShowGlow: orbitShowGlow ?? this.orbitShowGlow, + orbitLandscapeRadiusRatio: + orbitLandscapeRadiusRatio ?? this.orbitLandscapeRadiusRatio, + orbitLandscapeCenterYRatio: + orbitLandscapeCenterYRatio ?? this.orbitLandscapeCenterYRatio, + orbitLandscapeItemSpacing: + orbitLandscapeItemSpacing ?? this.orbitLandscapeItemSpacing, + orbitLandscapeSelectedScale: + orbitLandscapeSelectedScale ?? this.orbitLandscapeSelectedScale, + orbitLandscapeDepth: orbitLandscapeDepth ?? this.orbitLandscapeDepth, + orbitLandscapeVisibleItems: + orbitLandscapeVisibleItems ?? this.orbitLandscapeVisibleItems, + orbitLandscapeCardWidthRatio: + orbitLandscapeCardWidthRatio ?? this.orbitLandscapeCardWidthRatio, streaksEnabled: streaksEnabled ?? this.streaksEnabled, showMoreFromArtist: showMoreFromArtist ?? this.showMoreFromArtist, showMoreArtists: showMoreArtists ?? this.showMoreArtists, @@ -609,6 +637,7 @@ class AppPreferencesService { static const _animatedAlbumArtKey = 'album_animated_art'; static const _folderGridPageSizeKey = 'folder_grid_page_size'; static const _lastSeenChangelogVersionKey = 'last_seen_changelog_version'; + /// UI language. A locale tag such as 'zh_CN', or 'system' to follow the device. static const _appLocaleKey = 'app_locale'; static const _baybayinHeadingsKey = 'baybayin_headings'; @@ -656,6 +685,16 @@ class AppPreferencesService { 'orbit_art_resolution_multiplier'; static const _orbitShowPathKey = 'orbit_show_path'; static const _orbitShowGlowKey = 'orbit_show_glow'; + static const _orbitLandscapeRadiusRatioKey = 'orbit_landscape_radius_ratio'; + static const _orbitLandscapeCenterYRatioKey = + 'orbit_landscape_center_y_ratio'; + static const _orbitLandscapeItemSpacingKey = 'orbit_landscape_item_spacing'; + static const _orbitLandscapeSelectedScaleKey = + 'orbit_landscape_selected_scale'; + static const _orbitLandscapeDepthKey = 'orbit_landscape_depth'; + static const _orbitLandscapeVisibleItemsKey = 'orbit_landscape_visible_items'; + static const _orbitLandscapeCardWidthRatioKey = + 'orbit_landscape_card_width_ratio'; static const _streaksEnabledKey = 'streaks_enabled'; static const _showMoreFromArtistKey = 'album_show_more_from_artist'; static const _showMoreArtistsKey = 'album_show_more_artists'; @@ -743,14 +782,12 @@ class AppPreferencesService { prefs.getString(_widgetFlagshipAccentKey) ?? 'white', widgetFlagshipShowArtist: prefs.getBool(_widgetFlagshipShowArtistKey) ?? true, - widgetCompactBgOpacity: - prefs.getInt(_widgetCompactBgOpacityKey) ?? 3, + widgetCompactBgOpacity: prefs.getInt(_widgetCompactBgOpacityKey) ?? 3, widgetCompactShowAlbumArt: prefs.getBool(_widgetCompactShowAlbumArtKey) ?? true, widgetCompactShowArtist: prefs.getBool(_widgetCompactShowArtistKey) ?? true, - widgetCompactAccent: - prefs.getString(_widgetCompactAccentKey) ?? 'white', + widgetCompactAccent: prefs.getString(_widgetCompactAccentKey) ?? 'white', widgetTextScale: prefs.getDouble(_widgetTextScaleKey) ?? 1.0, widgetFlagshipTextScale: prefs.getDouble(_widgetFlagshipTextScaleKey) ?? 1.0, @@ -761,17 +798,14 @@ class AppPreferencesService { prefs.getBool(_lyricsMatchAudioFilenameKey) ?? false, leftActionButton: prefs.getString(_leftActionButtonKey) ?? 'lyrics', rightActionButton: prefs.getString(_rightActionButtonKey) ?? 'favorites', - leftTopActionButton: - prefs.getString(_leftTopActionButtonKey) ?? 'none', - rightTopActionButton: - prefs.getString(_rightTopActionButtonKey) ?? 'none', + leftTopActionButton: prefs.getString(_leftTopActionButtonKey) ?? 'none', + rightTopActionButton: prefs.getString(_rightTopActionButtonKey) ?? 'none', welcomeCardDismissed: prefs.getBool(_welcomeCardDismissedKey) ?? false, glanceCardHidden: prefs.getBool(_glanceCardHiddenKey) ?? false, glanceCardMinimized: prefs.getBool(_glanceCardMinimizedKey) ?? false, replaceAlbumWithBitPerfectCapsule: prefs.getBool(_replaceAlbumWithBitPerfectCapsuleKey) ?? false, - albumsStretchArtwork: - prefs.getBool(_albumsStretchArtworkKey) ?? false, + albumsStretchArtwork: prefs.getBool(_albumsStretchArtworkKey) ?? false, animatedAlbumArt: prefs.getBool(_animatedAlbumArtKey) ?? true, folderGridPageSize: prefs.getInt(_folderGridPageSizeKey) ?? 8, lastSeenChangelogVersion: prefs.getString(_lastSeenChangelogVersionKey), @@ -784,8 +818,7 @@ class AppPreferencesService { separateMiniPlayerFromNavBar: prefs.getBool(_separateMiniPlayerFromNavBarKey) ?? false, keepPlayingOnQuit: prefs.getBool(_keepPlayingOnQuitKey) ?? false, - priorityAnchorEnabled: - prefs.getBool(_priorityAnchorEnabledKey) ?? true, + priorityAnchorEnabled: prefs.getBool(_priorityAnchorEnabledKey) ?? true, motionArtDuringBitPerfect: prefs.getBool(_motionArtDuringBitPerfectKey) ?? false, pauseOnBluetoothDisconnect: @@ -796,25 +829,19 @@ class AppPreferencesService { prefs.getBool(_pauseOnUsbDacDisconnectKey) ?? true, pauseOnBluetoothConnect: prefs.getBool(_pauseOnBluetoothConnectKey) ?? false, - pauseOnUsbDacConnect: - prefs.getBool(_pauseOnUsbDacConnectKey) ?? false, + pauseOnUsbDacConnect: prefs.getBool(_pauseOnUsbDacConnectKey) ?? false, preferredBluetoothDevice: prefs.getString(_preferredBluetoothDeviceKey) ?? '', btPreferredCodec: prefs.getInt(_btPreferredCodecKey) ?? -1, btLdacBitrate: prefs.getString(_btLdacBitrateKey) ?? 'adaptive', - btAbsoluteVolumeSync: - prefs.getBool(_btAbsoluteVolumeSyncKey) ?? false, - btEnableCodecControl: - prefs.getBool(_btEnableCodecControlKey) ?? false, + btAbsoluteVolumeSync: prefs.getBool(_btAbsoluteVolumeSyncKey) ?? false, + btEnableCodecControl: prefs.getBool(_btEnableCodecControlKey) ?? false, btSampleRate: prefs.getInt(_btSampleRateKey) ?? 0, btLdacBitsPerSample: prefs.getInt(_btLdacBitsPerSampleKey) ?? 0, - floatingPlayerEnabled: - prefs.getBool(_floatingPlayerEnabledKey) ?? false, - floatingIslandEnabled: - prefs.getBool(_floatingIslandEnabledKey) ?? false, + floatingPlayerEnabled: prefs.getBool(_floatingPlayerEnabledKey) ?? false, + floatingIslandEnabled: prefs.getBool(_floatingIslandEnabledKey) ?? false, autoFocusSearch: prefs.getBool(_autoFocusSearchKey) ?? false, - searchPlaybackMode: - prefs.getString(_searchPlaybackModeKey) ?? 'results', + searchPlaybackMode: prefs.getString(_searchPlaybackModeKey) ?? 'results', refreshRateMode: await _resolveRefreshRateMode(prefs), visualizerEnabled: prefs.getBool(_visualizerEnabledKey) ?? true, orbitRadiusRatio: prefs.getDouble(_orbitRadiusRatioKey) ?? 1.0, @@ -831,6 +858,19 @@ class AppPreferencesService { prefs.getDouble(_orbitArtResolutionMultiplierKey) ?? 2.0, orbitShowPath: prefs.getBool(_orbitShowPathKey) ?? true, orbitShowGlow: prefs.getBool(_orbitShowGlowKey) ?? true, + orbitLandscapeRadiusRatio: + prefs.getDouble(_orbitLandscapeRadiusRatioKey) ?? 1.0, + orbitLandscapeCenterYRatio: + prefs.getDouble(_orbitLandscapeCenterYRatioKey) ?? 0.48, + orbitLandscapeItemSpacing: + prefs.getDouble(_orbitLandscapeItemSpacingKey) ?? 0.28, + orbitLandscapeSelectedScale: + prefs.getDouble(_orbitLandscapeSelectedScaleKey) ?? 1.25, + orbitLandscapeDepth: prefs.getDouble(_orbitLandscapeDepthKey) ?? 0.75, + orbitLandscapeVisibleItems: + prefs.getInt(_orbitLandscapeVisibleItemsKey) ?? 5, + orbitLandscapeCardWidthRatio: + prefs.getDouble(_orbitLandscapeCardWidthRatioKey) ?? 0.68, streaksEnabled: prefs.getBool(_streaksEnabledKey) ?? true, showMoreFromArtist: prefs.getBool(_showMoreFromArtistKey) ?? true, showMoreArtists: prefs.getBool(_showMoreArtistsKey) ?? true, @@ -840,8 +880,7 @@ class AppPreferencesService { prefs.getBool(_detailHeaderCenteredTitleKey) ?? false, songTileThumbnailMode: prefs.getString(_songTileThumbnailModeKey) ?? 'artwork', - extendedVolumeEnabled: - prefs.getBool(_extendedVolumeEnabledKey) ?? false, + extendedVolumeEnabled: prefs.getBool(_extendedVolumeEnabledKey) ?? false, replayGainMode: prefs.getString(_replayGainModeKey) ?? 'off', lyricsTextAlign: prefs.getString(_lyricsTextAlignKey) ?? 'center', karaokeEnabled: prefs.getBool(_lyricsKaraokeEnabledKey) ?? true, @@ -1939,6 +1978,41 @@ class AppPreferencesService { await prefs.setBool(_orbitShowGlowKey, value); } + Future setOrbitLandscapeRadiusRatio(double value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_orbitLandscapeRadiusRatioKey, value); + } + + Future setOrbitLandscapeCenterYRatio(double value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_orbitLandscapeCenterYRatioKey, value); + } + + Future setOrbitLandscapeItemSpacing(double value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_orbitLandscapeItemSpacingKey, value); + } + + Future setOrbitLandscapeSelectedScale(double value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_orbitLandscapeSelectedScaleKey, value); + } + + Future setOrbitLandscapeDepth(double value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_orbitLandscapeDepthKey, value); + } + + Future setOrbitLandscapeVisibleItems(int value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_orbitLandscapeVisibleItemsKey, value); + } + + Future setOrbitLandscapeCardWidthRatio(double value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble(_orbitLandscapeCardWidthRatioKey, value); + } + Future getStreaksEnabled() async { final prefs = await SharedPreferences.getInstance(); return prefs.getBool(_streaksEnabledKey) ?? true; @@ -1999,7 +2073,7 @@ class AppPreferencesService { await prefs.setString(_songTileThumbnailModeKey, value); } - Future clearOrbitSettings() async { + Future clearOrbitPortraitLayout() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_orbitRadiusRatioKey); await prefs.remove(_orbitCenterOffsetRatioKey); @@ -2010,6 +2084,23 @@ class AppPreferencesService { await prefs.remove(_orbitVisibleItemsKey); await prefs.remove(_orbitCardArtSizeKey); await prefs.remove(_orbitCardWidthRatioKey); + } + + Future clearOrbitLandscapeLayout() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_orbitLandscapeRadiusRatioKey); + await prefs.remove(_orbitLandscapeCenterYRatioKey); + await prefs.remove(_orbitLandscapeItemSpacingKey); + await prefs.remove(_orbitLandscapeSelectedScaleKey); + await prefs.remove(_orbitLandscapeDepthKey); + await prefs.remove(_orbitLandscapeVisibleItemsKey); + await prefs.remove(_orbitLandscapeCardWidthRatioKey); + } + + Future clearOrbitSettings() async { + await clearOrbitPortraitLayout(); + await clearOrbitLandscapeLayout(); + final prefs = await SharedPreferences.getInstance(); await prefs.remove(_orbitArtResolutionMultiplierKey); await prefs.remove(_orbitShowPathKey); await prefs.remove(_orbitShowGlowKey); diff --git a/lib/widgets/common/landscape_mini_player_dock.dart b/lib/widgets/common/landscape_mini_player_dock.dart new file mode 100644 index 000000000..41a4ba0df --- /dev/null +++ b/lib/widgets/common/landscape_mini_player_dock.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:flick/core/constants/app_constants.dart'; +import 'package:flick/core/utils/adaptive_layout.dart'; +import 'package:flick/features/onboarding/tutorial_targets.dart'; + +/// Moves between the bottom bar and compact orbit strip without laying out +/// either player on every animation frame. +class LandscapeMiniPlayerDock extends StatefulWidget { + const LandscapeMiniPlayerDock({ + super.key, + required this.sideDocked, + required this.bottomPlayerHeight, + required this.bottomPlayer, + required this.sidePlayer, + }); + + final bool sideDocked; + final double bottomPlayerHeight; + final Widget bottomPlayer; + final Widget sidePlayer; + + @override + State createState() => + _LandscapeMiniPlayerDockState(); +} + +class _LandscapeMiniPlayerDockState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + value: widget.sideDocked ? 1 : 0, + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _syncMotion(); + } + + @override + void didUpdateWidget(LandscapeMiniPlayerDock oldWidget) { + super.didUpdateWidget(oldWidget); + _syncMotion(move: oldWidget.sideDocked != widget.sideDocked); + } + + void _syncMotion({bool move = false}) { + final disabled = + MediaQuery.disableAnimationsOf(context) || + AppConstants.animationNormal == Duration.zero; + _controller.duration = disabled + ? Duration.zero + : const Duration(milliseconds: 220); + final target = widget.sideDocked ? 1.0 : 0.0; + if (disabled) { + _controller.value = target; + } else if (move) { + // animateTo starts at the current position, including on rapid reversals. + _controller.animateTo(target, curve: Curves.easeOutCubic); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Widget _player(Widget child, {required bool active}) => HeroMode( + enabled: active, + child: TickerMode( + enabled: active, + child: ExcludeSemantics( + excluding: !active, + child: IgnorePointer( + ignoring: !active, + child: RepaintBoundary(child: child), + ), + ), + ), + ); + + @override + Widget build(BuildContext context) { + // Match the original bottom player's safe area; the side strip additionally + // leaves room for the top system inset. + final topInset = MediaQuery.paddingOf(context).top; + return SafeArea( + top: false, + left: false, + child: LayoutBuilder( + builder: (context, constraints) { + final bottomRect = Rect.fromLTWH( + 0, + constraints.maxHeight - widget.bottomPlayerHeight, + constraints.maxWidth, + widget.bottomPlayerHeight, + ); + final sideRect = Rect.fromLTWH( + constraints.maxWidth - AdaptiveLayout.sideMiniPlayerWidth - 6, + topInset, + AdaptiveLayout.sideMiniPlayerWidth, + (constraints.maxHeight - topInset).clamp(0.0, double.infinity), + ); + final travel = sideRect.center - bottomRect.center; + // These subtrees stay fixed during ticks. Only translation and opacity + // change; the outgoing visualizer is muted and removed on completion. + final bottomPlayer = _player( + widget.bottomPlayer, + active: !widget.sideDocked, + ); + final sidePlayer = _player( + widget.sidePlayer, + active: widget.sideDocked, + ); + final anchor = IgnorePointer( + child: TutorialTargetAnchor( + target: TutorialTarget.miniPlayer, + child: const SizedBox.expand(), + ), + ); + + return AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final t = _controller.value; + final bottomOffset = travel * t; + final sideOffset = travel * (t - 1); + return ClipRect( + child: Stack( + children: [ + if (t < 1) + Positioned.fromRect( + key: const ValueKey('bottom_mini_player_dock'), + rect: bottomRect, + child: Transform.translate( + offset: bottomOffset, + child: Opacity(opacity: 1 - t, child: bottomPlayer), + ), + ), + if (t > 0) + Positioned.fromRect( + key: const ValueKey('side_mini_player_dock'), + rect: sideRect, + child: Transform.translate( + offset: sideOffset, + child: Opacity(opacity: t, child: sidePlayer), + ), + ), + // One persistent anchor avoids duplicate registrations while + // both player layouts briefly share the dock. + Positioned.fromRect( + key: const ValueKey('mini_player_dock_anchor'), + rect: widget.sideDocked ? sideRect : bottomRect, + child: Transform.translate( + offset: widget.sideDocked ? sideOffset : bottomOffset, + child: anchor, + ), + ), + ], + ), + ); + }, + ); + }, + ), + ); + } +} diff --git a/test/core/adaptive_layout_test.dart b/test/core/adaptive_layout_test.dart index 53fe2657b..50d643b41 100644 --- a/test/core/adaptive_layout_test.dart +++ b/test/core/adaptive_layout_test.dart @@ -46,13 +46,17 @@ void main() { Size(520, 320), Size(640, 360), Size(891, 411), + Size(960, 479), ]) { expect(AdaptiveLayout.useCompactLandscapeMiniPlayer(size), isTrue); } for (final size in const [ Size(360, 640), Size(640, 480), + Size(960, 480), + Size(1024, 600), Size(1280, 650), + Size(1280, 800), ]) { expect(AdaptiveLayout.useCompactLandscapeMiniPlayer(size), isFalse); } diff --git a/test/features/settings/orbit_settings_screen_test.dart b/test/features/settings/orbit_settings_screen_test.dart new file mode 100644 index 000000000..248c59a3b --- /dev/null +++ b/test/features/settings/orbit_settings_screen_test.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:flick/core/theme/app_theme.dart'; +import 'package:flick/features/settings/screens/orbit_settings_screen.dart'; +import 'package:flick/l10n/l10n.dart'; +import 'package:flick/providers/providers.dart'; + +Widget _host() => ProviderScope( + overrides: [currentSongProvider.overrideWithValue(null)], + child: MaterialApp( + theme: AppTheme.darkTheme, + locale: kFallbackLocale, + supportedLocales: kSupportedLocales, + localizationsDelegates: kAppLocalizationsDelegates, + home: const OrbitSettingsScreen(), + ), +); + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + testWidgets('profile selector switches without rotating the window', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(411, 900); + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(_host()); + await tester.pumpAndSettle(); + + final copy = lookupFlickLocalizations(kFallbackLocale); + expect(find.text(copy.portrait), findsOneWidget); + expect(find.text(copy.landscape), findsOneWidget); + expect(find.text(copy.horizontalReach), findsOneWidget); + expect(find.text(copy.cardSize), findsOneWidget); + + await tester.tap(find.text(copy.landscape)); + await tester.pumpAndSettle(); + + expect(find.text(copy.horizontalReach), findsNothing); + expect(find.text(copy.cardSize), findsNothing); + expect(find.text(copy.cardWidth), findsOneWidget); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/features/songs/orbit_scroll_test.dart b/test/features/songs/orbit_scroll_test.dart index abc2851b2..f53417c1e 100644 --- a/test/features/songs/orbit_scroll_test.dart +++ b/test/features/songs/orbit_scroll_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:flick/features/songs/widgets/orbit_scroll.dart'; import 'package:flick/features/songs/widgets/song_card.dart'; import 'package:flick/models/song.dart'; +import 'package:flick/widgets/common/cached_image_widget.dart'; final _songs = List.generate( 40, @@ -31,6 +32,13 @@ void main() { ValueChanged? onChanged, ValueChanged? onAction, double textScale = 1, + double radiusRatio = 1.0, + double centerYRatio = 0.42, + double itemSpacing = 0.28, + double selectedScale = 1.25, + double depth = 0.75, + int visibleItems = 5, + double cardWidthRatio = 0.68, }) => MaterialApp( builder: (context, child) => MediaQuery( data: MediaQuery.of( @@ -57,6 +65,13 @@ void main() { scrollDirection: axis, controller: orbit, swipeActionsEnabled: true, + radiusRatio: radiusRatio, + centerYRatio: centerYRatio, + itemSpacing: itemSpacing, + selectedScale: selectedScale, + depth: depth, + visibleItems: visibleItems, + cardWidthRatio: cardWidthRatio, onSelectedIndexChanged: onChanged, onSongSwipedLeft: onAction, onSongSwipedRight: onAction, @@ -158,6 +173,46 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('songs retain card and artwork state as the orbit advances', ( + tester, + ) async { + setView(tester, const Size(411, 900)); + final pages = PageController(); + final orbit = OrbitScrollController(); + addTearDown(pages.dispose); + await tester.pumpWidget(host(pages, orbit: orbit, axis: Axis.vertical)); + + final cardStates = {}; + final imageStates = {}; + for (final element in find.byType(SongCard).evaluate()) { + final card = element.widget as SongCard; + final finder = find.byWidget(card); + cardStates[card.song.id] = tester.state(finder); + imageStates[card.song.id] = tester.state( + find.descendant(of: finder, matching: find.byType(CachedImageWidget)), + ); + } + + orbit.jumpToIndex(16, animate: false); + await tester.pump(); + var retained = 0; + for (final element in find.byType(SongCard).evaluate()) { + final card = element.widget as SongCard; + if (!cardStates.containsKey(card.song.id)) continue; + final finder = find.byWidget(card); + expect(tester.state(finder), same(cardStates[card.song.id])); + expect( + tester.state( + find.descendant(of: finder, matching: find.byType(CachedImageWidget)), + ), + same(imageStates[card.song.id]), + ); + retained++; + } + expect(retained, greaterThan(1)); + expect(tester.takeException(), isNull); + }); + testWidgets('short landscape cards fit with enlarged text', (tester) async { setView(tester, const Size(640, 240)); final pages = PageController(); @@ -166,4 +221,182 @@ void main() { await tester.pumpAndSettle(); expect(tester.takeException(), isNull); }); + + testWidgets('landscape settings drive card count, depth and selected scale', ( + tester, + ) async { + setView(tester, const Size(900, 411)); + final pages = PageController(); + addTearDown(pages.dispose); + + Future pump({ + double selectedScale = 1.25, + double depth = 0.75, + int visibleItems = 5, + }) async { + await tester.pumpWidget( + host( + pages, + selectedScale: selectedScale, + depth: depth, + visibleItems: visibleItems, + ), + ); + await tester.pumpAndSettle(); + } + + SongCard selectedCard() => tester.widget( + find.byWidgetPredicate( + (widget) => widget is SongCard && widget.isSelected, + ), + ); + SongCard card(int index) => tester.widget( + find.byWidgetPredicate( + (widget) => widget is SongCard && widget.song.id == 'orbit-$index', + ), + ); + + await pump(); + expect(find.byType(SongCard), findsNWidgets(5)); + expect(selectedCard().scale, closeTo(1.25, 0.001)); + expect(card(16).scale, closeTo(1.0 - 0.6 * 0.75, 0.001)); + + await pump(depth: 0.2); + expect(card(16).scale, closeTo(1.0 - 0.6 * 0.2, 0.001)); + + await pump(selectedScale: 1.8); + expect(selectedCard().scale, closeTo(1.8, 0.001)); + + await pump(visibleItems: 7); + expect(find.byType(SongCard), findsNWidgets(7)); + expect(tester.takeException(), isNull); + }); + + testWidgets('landscape radius, spacing and width feed the renderer', ( + tester, + ) async { + setView(tester, const Size(900, 411)); + final pages = PageController(); + addTearDown(pages.dispose); + final selected = find.byWidgetPredicate( + (widget) => widget is SongCard && widget.isSelected, + ); + final neighbor = find.byWidgetPredicate( + (widget) => widget is SongCard && widget.song.id == 'orbit-16', + ); + + Future pump({ + double radiusRatio = 1.0, + double itemSpacing = 0.28, + double cardWidthRatio = 0.68, + }) async { + await tester.pumpWidget( + host( + pages, + radiusRatio: radiusRatio, + itemSpacing: itemSpacing, + cardWidthRatio: cardWidthRatio, + ), + ); + await tester.pumpAndSettle(); + } + + double neighborOffset() => + (tester.getCenter(neighbor).dx - tester.getCenter(selected).dx).abs(); + + await pump(); + final baseOffset = neighborOffset(); + final baseWidth = tester.widget(selected).width!; + + await pump(radiusRatio: 1.4); + expect(neighborOffset(), greaterThan(baseOffset * 1.3)); + + await pump(itemSpacing: 0.5); + expect(neighborOffset(), greaterThan(baseOffset * 1.5)); + + await pump(cardWidthRatio: 0.5); + expect(tester.widget(selected).width!, lessThan(baseWidth)); + expect(tester.takeException(), isNull); + }); + + testWidgets('landscape focal position follows the profile value', ( + tester, + ) async { + setView(tester, const Size(900, 411)); + final pages = PageController(); + addTearDown(pages.dispose); + final selected = find.byWidgetPredicate( + (widget) => widget is SongCard && widget.isSelected, + ); + + await tester.pumpWidget(host(pages, centerYRatio: 0.3, selectedScale: 1.0)); + await tester.pumpAndSettle(); + final high = tester.getCenter(selected).dy; + + await tester.pumpWidget(host(pages, centerYRatio: 0.6, selectedScale: 1.0)); + await tester.pumpAndSettle(); + final low = tester.getCenter(selected).dy; + + expect(low, greaterThan(high + 20)); + expect(tester.takeException(), isNull); + }); + + testWidgets('selected landscape card stays inside the stage at extremes', ( + tester, + ) async { + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final pages = PageController(); + addTearDown(pages.dispose); + final orbit = find.byKey(const ValueKey('orbit')); + final selected = find.byWidgetPredicate( + (widget) => widget is SongCard && widget.isSelected, + ); + + for (final size in const [Size(900, 411), Size(640, 240)]) { + for (final centerYRatio in const [0.3, 0.6]) { + for (final selectedScale in const [1.0, 1.8]) { + tester.view.physicalSize = size; + await tester.pumpWidget( + host( + pages, + centerYRatio: centerYRatio, + selectedScale: selectedScale, + radiusRatio: 2.0, + cardWidthRatio: 0.85, + visibleItems: 7, + textScale: 2, + ), + ); + await tester.pumpAndSettle(); + + final stage = tester.getRect(orbit); + final card = tester.getRect(selected); + final reason = '$size centerY=$centerYRatio scale=$selectedScale'; + expect( + card.top, + greaterThanOrEqualTo(stage.top - 0.5), + reason: reason, + ); + expect( + card.bottom, + lessThanOrEqualTo(stage.bottom + 0.5), + reason: reason, + ); + expect( + card.left, + greaterThanOrEqualTo(stage.left - 0.5), + reason: reason, + ); + expect( + card.right, + lessThanOrEqualTo(stage.right + 0.5), + reason: reason, + ); + expect(tester.takeException(), isNull, reason: reason); + } + } + } + }); } diff --git a/test/features/songs/orbit_viewport_test.dart b/test/features/songs/orbit_viewport_test.dart new file mode 100644 index 000000000..a8d8850e3 --- /dev/null +++ b/test/features/songs/orbit_viewport_test.dart @@ -0,0 +1,246 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flick/core/theme/app_colors.dart'; +import 'package:flick/core/theme/app_theme.dart'; +import 'package:flick/core/utils/adaptive_layout.dart'; +import 'package:flick/features/songs/screens/songs_screen.dart'; +import 'package:flick/features/songs/widgets/orbit_scroll.dart'; +import 'package:flick/features/songs/widgets/song_card.dart'; +import 'package:flick/l10n/l10n.dart'; +import 'package:flick/models/song.dart'; +import 'package:flick/providers/providers.dart'; +import 'package:flick/services/app_preferences_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +final _songs = List.generate( + 40, + (i) => Song( + id: 'orbit-$i', + title: 'Song ${i.toString().padLeft(2, '0')}', + artist: 'Artist', + duration: const Duration(minutes: 4), + fileType: 'FLAC', + ), +); + +class _OrbitSongs extends SongsNotifier { + @override + Future build() async => + SongsState(songs: _songs, sortOption: SongSortOption.title); +} + +class _OrbitPreferences extends AppPreferencesNotifier { + @override + AppPreferences build() => const AppPreferences( + fastIndexEnabled: false, + orbitShowPath: false, + orbitShowGlow: false, + orbitLandscapeItemSpacing: 0.22, + orbitLandscapeDepth: 0.2, + ); + + void update({bool? path, double? depth}) { + state = state.copyWith(orbitShowPath: path, orbitLandscapeDepth: depth); + } +} + +const _shotKey = ValueKey('orbit_viewport_screenshot'); +const _screenshots = bool.fromEnvironment('ORBIT_VIEWPORT_SCREENSHOTS'); + +Future _pixels(WidgetTester tester, String name) async { + final boundary = tester.renderObject( + find.byKey(_shotKey), + ); + return (await tester.runAsync(() async { + final image = await boundary.toImage(pixelRatio: 1); + try { + if (_screenshots) { + final png = await image.toByteData(format: ui.ImageByteFormat.png); + final file = File('build/orbit_viewport_shots/$name.png'); + await file.parent.create(recursive: true); + await file.writeAsBytes(png!.buffer.asUint8List()); + } + final bytes = await image.toByteData(format: ui.ImageByteFormat.rawRgba); + return bytes!.buffer.asUint8List(); + } finally { + image.dispose(); + } + }))!; +} + +Future _pumpScreen( + WidgetTester tester, + Size size, { + double railWidth = AdaptiveLayout.navigationRailWidth, + double bottomInset = 0, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + songsProvider.overrideWith(_OrbitSongs.new), + appPreferencesProvider.overrideWith(_OrbitPreferences.new), + currentSongProvider.overrideWith((ref) => _songs[15]), + isCastingProvider.overrideWith((ref) => false), + ], + child: MaterialApp( + theme: AppTheme.darkTheme, + locale: kFallbackLocale, + supportedLocales: kSupportedLocales, + localizationsDelegates: kAppLocalizationsDelegates, + home: Scaffold( + backgroundColor: AppColors.background, + body: RepaintBoundary( + key: _shotKey, + child: Stack( + children: [ + const Positioned.fill( + child: ColoredBox(color: AppColors.background), + ), + Positioned.fill( + left: railWidth, + child: MediaQuery( + // Mirror the shell's content pane, including Android side + // safe areas and the full-height, clipped PageView. + data: MediaQueryData( + size: Size(size.width - railWidth, size.height), + padding: const EdgeInsets.only(left: 12, right: 20), + ), + child: PageView( + children: [ + Padding( + padding: EdgeInsets.only(bottom: bottomInset), + child: RepaintBoundary( + child: SongsScreen(pathOverflowBottom: bottomInset), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + return ProviderScope.containerOf(tester.element(find.byType(SongsScreen))); +} + +int _paintedPixels(Uint8List pixels, int width, Rect area) { + var count = 0; + for (var y = area.top.ceil(); y < area.bottom.floor(); y++) { + for (var x = area.left.ceil(); x < area.right.floor(); x++) { + if (pixels[(y * width + x) * 4] > 11) count++; + } + } + return count; +} + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + for (final size in const [Size(900, 411), Size(1280, 800)]) { + testWidgets('landscape cards and ribbon reach both edges at $size', ( + tester, + ) async { + final container = await _pumpScreen(tester, size); + final prefs = + container.read(appPreferencesProvider.notifier) as _OrbitPreferences; + final stage = tester.getRect(find.byType(OrbitScroll)); + final width = size.width.toInt(); + final height = size.height.toInt(); + final cards = await _pixels(tester, '${width}_cards'); + + // With the ribbon/glow disabled, these can only be card pixels. They + // must survive outside both 28dp gutters, up to the page's real edges. + expect( + _paintedPixels( + cards, + width, + Rect.fromLTRB(76, stage.top, stage.left + 27, height.toDouble()), + ), + greaterThan(20), + reason: 'left cards must not stop at the gesture gutter', + ); + expect( + _paintedPixels( + cards, + width, + Rect.fromLTRB( + stage.right - 27, + stage.top, + width.toDouble(), + height.toDouble(), + ), + ), + greaterThan(20), + reason: 'right cards must continue through the mini-player reservation', + ); + + // Shrink distant cards so they do not cover the rim at the screen edges. + prefs.update(depth: 1); + await tester.pumpAndSettle(); + final withoutPath = await _pixels(tester, '${width}_without_path'); + prefs.update(path: true); + await tester.pumpAndSettle(); + final withPath = await _pixels(tester, '${width}_with_path'); + + for (final x in [77, width - 2]) { + var changed = 0; + for (var y = stage.top.ceil(); y < height; y++) { + final index = (y * width + x) * 4; + if (withPath[index] > withoutPath[index] + 2) changed++; + } + expect(changed, greaterThan(3), reason: 'ribbon must reach x=$x'); + } + expect( + withPath.sublist(0, stage.top.floor() * width * 4), + withoutPath.sublist(0, stage.top.floor() * width * 4), + reason: 'the wider ribbon must not bleed into the header/search', + ); + expect( + tester + .widget( + find.byWidgetPredicate((w) => w is SongCard && w.isSelected), + ) + .song + .id, + 'orbit-15', + ); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('portrait ribbon still paints through the shell bottom inset', ( + tester, + ) async { + final container = await _pumpScreen( + tester, + const Size(800, 1280), + railWidth: 0, + bottomInset: 180, + ); + (container.read(appPreferencesProvider.notifier) as _OrbitPreferences) + .update(path: true); + await tester.pumpAndSettle(); + final pixels = await _pixels(tester, 'portrait_bottom_overflow'); + expect( + _paintedPixels(pixels, 800, const Rect.fromLTRB(0, 1101, 800, 1280)), + greaterThan(100), + ); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/features/songs/song_card_test.dart b/test/features/songs/song_card_test.dart new file mode 100644 index 000000000..889048590 --- /dev/null +++ b/test/features/songs/song_card_test.dart @@ -0,0 +1,205 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flick/features/songs/widgets/song_card.dart'; +import 'package:flick/models/song.dart'; +import 'package:flick/widgets/common/cached_image_widget.dart'; + +const _song = Song( + id: 'card-song', + title: 'Original title', + artist: 'Original artist', + duration: Duration(minutes: 4), + fileType: 'FLAC', +); + +Widget _host({ + Song song = _song, + double scale = 1, + double opacity = 1, + bool isSelected = false, + bool isSelectionMode = false, + bool isMultiSelected = false, + double artSizeLarge = 110, + double artResolutionMultiplier = 1.5, + VoidCallback? onTap, + VoidCallback? onSwipeLeft, + VoidCallback? onSwipeRight, +}) => MaterialApp( + home: Scaffold( + body: Center( + child: SongCard( + song: song, + width: 320, + scale: scale, + opacity: opacity, + isSelected: isSelected, + isSelectionMode: isSelectionMode, + isMultiSelected: isMultiSelected, + artSizeBase: 70, + artSizeLarge: artSizeLarge, + artResolutionMultiplier: artResolutionMultiplier, + swipeActionsEnabled: true, + onTap: onTap, + onSwipeLeft: onSwipeLeft, + onSwipeRight: onSwipeRight, + ), + ), + ), +); + +void main() { + testWidgets('motion retains the card face and its painted content', ( + tester, + ) async { + await tester.pumpWidget(_host(scale: 0.9, opacity: 0.8)); + final imageFinder = find.byType(CachedImageWidget); + final image = tester.widget(imageFinder); + final imageState = tester.state(imageFinder); + final boundaryFinder = find.descendant( + of: find.byType(SongCard), + matching: find.byType(RepaintBoundary), + ); + final boundary = tester.renderObject(boundaryFinder); + final paintedContent = boundary.debugLayer!.firstChild; + expect(paintedContent, isNotNull); + + await tester.pumpWidget(_host(scale: 0.8, opacity: 0.7)); + expect(tester.widget(imageFinder), same(image)); + expect(tester.state(imageFinder), same(imageState)); + expect(tester.renderObject(boundaryFinder), same(boundary)); + expect(boundary.debugLayer!.firstChild, same(paintedContent)); + + final opacity = tester.widget( + find + .descendant(of: find.byType(SongCard), matching: find.byType(Opacity)) + .first, + ); + expect(opacity.child, isA()); + expect(tester.takeException(), isNull); + }); + + testWidgets( + 'same-ID metadata and artwork updates refresh the retained face', + (tester) async { + await tester.pumpWidget(_host()); + final imageFinder = find.byType(CachedImageWidget); + final originalImage = tester.widget(imageFinder); + final imageState = tester.state(imageFinder); + final edited = _song.copyWith( + title: 'Edited title', + artist: 'Edited artist', + albumArt: '/missing/edited-cover.png', + duration: const Duration(minutes: 5), + fileType: 'WAV', + ); + // Song equality compares IDs, not the metadata displayed by the card. + expect(edited, _song); + + await tester.pumpWidget(_host(song: edited)); + expect(find.text('Original title'), findsNothing); + expect(find.text('Edited title'), findsOneWidget); + expect(find.text('Edited artist'), findsOneWidget); + expect(find.text('05:00'), findsOneWidget); + expect(find.text('WAV'), findsOneWidget); + final updatedImage = tester.widget(imageFinder); + expect(updatedImage, isNot(same(originalImage))); + expect(updatedImage.imagePath, '/missing/edited-cover.png'); + expect(tester.state(imageFinder), same(imageState)); + }, + ); + + testWidgets('landscape card keeps artwork above the song footer', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SongCard( + song: _song, + width: 220, + height: 110, + artSizeBase: 70, + artworkAbove: true, + ), + ), + ), + ), + ); + + expect(find.byType(CachedImageWidget), findsOneWidget); + final art = tester.getRect(find.byType(CachedImageWidget)); + final title = tester.getRect(find.text('Original title')); + final artist = tester.getRect(find.text('Original artist')); + expect(art.bottom, lessThanOrEqualTo(title.top)); + expect(title.bottom, lessThanOrEqualTo(artist.top)); + expect(tester.takeException(), isNull); + }); + + testWidgets('selection and artwork preferences refresh the retained face', ( + tester, + ) async { + await tester.pumpWidget(_host()); + final imageFinder = find.byType(CachedImageWidget); + final imageState = tester.state(imageFinder); + expect(tester.widget(imageFinder).thumbnailWidth, 105); + + await tester.pumpWidget(_host(isSelected: true)); + expect(tester.widget(imageFinder).thumbnailWidth, 165); + expect(tester.state(imageFinder), same(imageState)); + + await tester.pumpWidget( + _host(isSelected: true, artSizeLarge: 120, artResolutionMultiplier: 2), + ); + expect(tester.widget(imageFinder).thumbnailWidth, 240); + expect(tester.state(imageFinder), same(imageState)); + expect(tester.takeException(), isNull); + }); + + testWidgets('multiselect changes refresh the retained face', (tester) async { + await tester.pumpWidget(_host()); + expect(find.byIcon(Icons.radio_button_unchecked_rounded), findsNothing); + + await tester.pumpWidget(_host(isSelectionMode: true)); + expect(find.byIcon(Icons.radio_button_unchecked_rounded), findsOneWidget); + + await tester.pumpWidget( + _host(isSelectionMode: true, isMultiSelected: true), + ); + expect(find.byIcon(Icons.radio_button_unchecked_rounded), findsNothing); + expect(find.byIcon(Icons.check_circle_rounded), findsOneWidget); + }); + + testWidgets('retained content uses current tap and swipe callbacks', ( + tester, + ) async { + var oldTaps = 0; + var taps = 0; + var queued = 0; + var favorites = 0; + await tester.pumpWidget(_host(onTap: () => oldTaps++)); + await tester.pumpWidget( + _host( + scale: 0.95, + onTap: () => taps++, + onSwipeLeft: () => queued++, + onSwipeRight: () => favorites++, + ), + ); + await tester.tap(find.byType(SongCard)); + expect(oldTaps, 0); + expect(taps, 1); + + await tester.drag(find.byType(SongCard), const Offset(-150, 0)); + await tester.pumpAndSettle(); + expect(queued, 1); + expect(favorites, 0); + + await tester.drag(find.byType(SongCard), const Offset(150, 0)); + await tester.pumpAndSettle(); + expect(queued, 1); + expect(favorites, 1); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/providers/orbit_layout_preferences_test.dart b/test/providers/orbit_layout_preferences_test.dart new file mode 100644 index 000000000..6f452d539 --- /dev/null +++ b/test/providers/orbit_layout_preferences_test.dart @@ -0,0 +1,157 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'package:flick/providers/app_preferences_provider.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() => SharedPreferences.setMockInitialValues({})); + + Future newContainer() async { + final container = ProviderContainer(); + addTearDown(container.dispose); + await container.read(appPreferencesProvider.notifier).loaded; + return container; + } + + test( + 'landscape layout keeps the previous landscape render as defaults', + () async { + final container = await newContainer(); + final prefs = container.read(appPreferencesProvider); + + expect(prefs.orbitLandscapeRadiusRatio, 1.0); + expect(prefs.orbitLandscapeCenterYRatio, 0.48); + expect(prefs.orbitLandscapeItemSpacing, 0.28); + expect(prefs.orbitLandscapeSelectedScale, 1.25); + expect(prefs.orbitLandscapeDepth, 0.75); + expect(prefs.orbitLandscapeVisibleItems, 5); + expect(prefs.orbitLandscapeCardWidthRatio, 0.68); + }, + ); + + test('portrait and landscape layouts persist independently', () async { + final container = await newContainer(); + final notifier = container.read(appPreferencesProvider.notifier); + + await notifier.setOrbitRadiusRatio(0.82); + await notifier.setOrbitCenterYRatio(0.36); + await notifier.setOrbitItemSpacing(0.19); + await notifier.setOrbitSelectedScale(1.4); + await notifier.setOrbitDepth(0.5); + await notifier.setOrbitVisibleItems(7); + await notifier.setOrbitCardWidthRatio(0.58); + + await notifier.setOrbitLandscapeRadiusRatio(1.36); + await notifier.setOrbitLandscapeCenterYRatio(0.56); + await notifier.setOrbitLandscapeItemSpacing(0.41); + await notifier.setOrbitLandscapeSelectedScale(1.7); + await notifier.setOrbitLandscapeDepth(0.3); + await notifier.setOrbitLandscapeVisibleItems(9); + await notifier.setOrbitLandscapeCardWidthRatio(0.78); + + final restored = await newContainer(); + final prefs = restored.read(appPreferencesProvider); + expect(prefs.orbitRadiusRatio, 0.82); + expect(prefs.orbitCenterYRatio, 0.36); + expect(prefs.orbitItemSpacing, 0.19); + expect(prefs.orbitSelectedScale, 1.4); + expect(prefs.orbitDepth, 0.5); + expect(prefs.orbitVisibleItems, 7); + expect(prefs.orbitCardWidthRatio, 0.58); + expect(prefs.orbitLandscapeRadiusRatio, 1.36); + expect(prefs.orbitLandscapeCenterYRatio, 0.56); + expect(prefs.orbitLandscapeItemSpacing, 0.41); + expect(prefs.orbitLandscapeSelectedScale, 1.7); + expect(prefs.orbitLandscapeDepth, 0.3); + expect(prefs.orbitLandscapeVisibleItems, 9); + expect(prefs.orbitLandscapeCardWidthRatio, 0.78); + }); + + test('saved portrait settings do not leak into landscape defaults', () async { + SharedPreferences.setMockInitialValues({ + 'orbit_radius_ratio': 0.74, + 'orbit_center_offset_ratio': -0.8, + 'orbit_center_y_ratio': 0.33, + 'orbit_item_spacing': 0.44, + 'orbit_selected_scale': 1.6, + 'orbit_depth': 0.2, + 'orbit_visible_items': 9, + 'orbit_card_art_size': 96.0, + 'orbit_card_width_ratio': 0.8, + }); + + final container = await newContainer(); + final prefs = container.read(appPreferencesProvider); + expect(prefs.orbitRadiusRatio, 0.74); + expect(prefs.orbitCenterYRatio, 0.33); + expect(prefs.orbitLandscapeRadiusRatio, 1.0); + expect(prefs.orbitLandscapeCenterYRatio, 0.48); + expect(prefs.orbitLandscapeItemSpacing, 0.28); + expect(prefs.orbitLandscapeSelectedScale, 1.25); + expect(prefs.orbitLandscapeDepth, 0.75); + expect(prefs.orbitLandscapeVisibleItems, 5); + expect(prefs.orbitLandscapeCardWidthRatio, 0.68); + }); + + test( + 'resetting one layout leaves the other and shared visuals untouched', + () async { + final container = await newContainer(); + final notifier = container.read(appPreferencesProvider.notifier); + + await notifier.setOrbitRadiusRatio(0.82); + await notifier.setOrbitVisibleItems(7); + await notifier.setOrbitLandscapeRadiusRatio(1.36); + await notifier.setOrbitLandscapeCenterYRatio(0.56); + await notifier.setOrbitLandscapeVisibleItems(9); + await notifier.setOrbitArtResolutionMultiplier(3.0); + await notifier.setOrbitShowPath(false); + await notifier.setOrbitShowGlow(false); + + await notifier.resetOrbitLayout(landscape: true); + + var prefs = container.read(appPreferencesProvider); + expect(prefs.orbitLandscapeRadiusRatio, 1.0); + expect(prefs.orbitLandscapeCenterYRatio, 0.48); + expect(prefs.orbitLandscapeItemSpacing, 0.28); + expect(prefs.orbitLandscapeSelectedScale, 1.25); + expect(prefs.orbitLandscapeDepth, 0.75); + expect(prefs.orbitLandscapeVisibleItems, 5); + expect(prefs.orbitLandscapeCardWidthRatio, 0.68); + expect(prefs.orbitRadiusRatio, 0.82); + expect(prefs.orbitVisibleItems, 7); + expect(prefs.orbitArtResolutionMultiplier, 3.0); + expect(prefs.orbitShowPath, isFalse); + expect(prefs.orbitShowGlow, isFalse); + + var stored = await SharedPreferences.getInstance(); + expect(stored.getDouble('orbit_landscape_radius_ratio'), isNull); + expect(stored.getDouble('orbit_radius_ratio'), 0.82); + expect(stored.getDouble('orbit_art_resolution_multiplier'), 3.0); + expect(stored.getBool('orbit_show_path'), isFalse); + + await notifier.resetOrbitLayout(landscape: false); + + prefs = container.read(appPreferencesProvider); + expect(prefs.orbitRadiusRatio, 1.0); + expect(prefs.orbitCenterOffsetRatio, -0.5); + expect(prefs.orbitCenterYRatio, 0.42); + expect(prefs.orbitItemSpacing, 0.28); + expect(prefs.orbitSelectedScale, 1.25); + expect(prefs.orbitDepth, 0.75); + expect(prefs.orbitVisibleItems, 5); + expect(prefs.orbitCardArtSize, 64.0); + expect(prefs.orbitCardWidthRatio, 0.68); + expect(prefs.orbitLandscapeRadiusRatio, 1.0); + expect(prefs.orbitArtResolutionMultiplier, 3.0); + + stored = await SharedPreferences.getInstance(); + expect(stored.getDouble('orbit_radius_ratio'), isNull); + expect(stored.getDouble('orbit_landscape_radius_ratio'), isNull); + expect(stored.getDouble('orbit_art_resolution_multiplier'), 3.0); + }, + ); +} diff --git a/test/widgets/landscape_mini_player_dock_test.dart b/test/widgets/landscape_mini_player_dock_test.dart new file mode 100644 index 000000000..344364423 --- /dev/null +++ b/test/widgets/landscape_mini_player_dock_test.dart @@ -0,0 +1,433 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:flick/core/constants/app_constants.dart'; +import 'package:flick/features/onboarding/tutorial_targets.dart'; +import 'package:flick/widgets/common/landscape_mini_player_dock.dart'; + +const _bottomKey = ValueKey('bottom_player'); +const _sideKey = ValueKey('side_player'); + +Widget _host({ + required bool sideDocked, + bool reducedMotion = false, + EdgeInsets padding = EdgeInsets.zero, + Widget bottomPlayer = const SizedBox.expand(key: _bottomKey), + Widget sidePlayer = const SizedBox.expand(key: _sideKey), +}) => ProviderScope( + child: MaterialApp( + home: MediaQuery( + data: MediaQueryData( + size: const Size(800, 400), + padding: padding, + disableAnimations: reducedMotion, + ), + child: Scaffold( + body: Stack( + children: [ + Positioned.fill( + left: 76, + child: LandscapeMiniPlayerDock( + sideDocked: sideDocked, + bottomPlayerHeight: 80, + bottomPlayer: bottomPlayer, + sidePlayer: sidePlayer, + ), + ), + ], + ), + ), + ), + ), +); + +class _Counts { + int builds = 0; + int layouts = 0; +} + +class _PlayerProbe extends StatelessWidget { + const _PlayerProbe(this.counts); + + final _Counts counts; + + @override + Widget build(BuildContext context) { + counts.builds++; + return _LayoutProbe(counts); + } +} + +class _LayoutProbe extends SingleChildRenderObjectWidget { + const _LayoutProbe(this.counts) : super(child: const SizedBox.expand()); + + final _Counts counts; + + @override + RenderObject createRenderObject(BuildContext context) => + _LayoutCounter(counts); +} + +class _LayoutCounter extends RenderProxyBox { + _LayoutCounter(this.counts); + + final _Counts counts; + + @override + void performLayout() { + counts.layouts++; + super.performLayout(); + } +} + +void main() { + setUp(() => AppConstants.setAnimationsEnabled(true)); + tearDown(() => AppConstants.setAnimationsEnabled(true)); + + Future setLandscape(WidgetTester tester) async { + await tester.binding.setSurfaceSize(const Size(800, 400)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + } + + testWidgets('starts at either dock without an entrance animation', ( + tester, + ) async { + await setLandscape(tester); + await tester.pumpWidget(_host(sideDocked: false)); + await tester.pump(); + expect( + tester.getRect(find.byKey(_bottomKey)), + const Rect.fromLTWH(76, 320, 724, 80), + ); + expect(find.byKey(_sideKey), findsNothing); + expect(tester.binding.hasScheduledFrame, isFalse); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpWidget(_host(sideDocked: true)); + await tester.pump(); + expect( + tester.getRect(find.byKey(_sideKey)), + const Rect.fromLTWH(730, 0, 64, 400), + ); + expect(find.byKey(_bottomKey), findsNothing); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('glides both fixed-size players along one path in 220ms', ( + tester, + ) async { + await setLandscape(tester); + await tester.pumpWidget(_host(sideDocked: false)); + final start = tester.getRect(find.byKey(_bottomKey)); + + await tester.pumpWidget(_host(sideDocked: true)); + expect(tester.getRect(find.byKey(_bottomKey)), start); + await tester.pump(const Duration(milliseconds: 80)); + + final bottom = tester.getRect(find.byKey(_bottomKey)); + final side = tester.getRect(find.byKey(_sideKey)); + expect(tester.getSize(find.byKey(_bottomKey)), start.size); + expect(tester.getSize(find.byKey(_sideKey)), const Size(64, 400)); + expect(bottom.center.dx, greaterThan(start.center.dx)); + expect(bottom.center.dx, lessThan(762)); + expect(bottom.center.dy, lessThan(start.center.dy)); + expect((bottom.center - side.center).distance, lessThan(0.001)); + + await tester.pump(const Duration(milliseconds: 140)); + expect(find.byKey(_bottomKey), findsNothing); + expect(tester.getCenter(find.byKey(_sideKey)), const Offset(762, 200)); + // The ticker stops on the frame following the exact duration boundary. + await tester.pump(const Duration(milliseconds: 16)); + expect(tester.binding.transientCallbackCount, 0); + expect(tester.binding.hasScheduledFrame, isFalse); + + await tester.pumpWidget(_host(sideDocked: false)); + await tester.pump(const Duration(milliseconds: 80)); + final returning = tester.getCenter(find.byKey(_bottomKey)); + expect(returning.dx, greaterThan(start.center.dx)); + expect(returning.dx, lessThan(762)); + expect(returning.dy, greaterThan(200)); + await tester.pump(const Duration(milliseconds: 140)); + expect(tester.getRect(find.byKey(_bottomKey)), start); + expect(find.byKey(_sideKey), findsNothing); + await tester.pump(const Duration(milliseconds: 16)); + expect(tester.binding.transientCallbackCount, 0); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('rapid reversals continue from the current position', ( + tester, + ) async { + await setLandscape(tester); + await tester.pumpWidget(_host(sideDocked: false)); + await tester.pumpWidget(_host(sideDocked: true)); + await tester.pump(const Duration(milliseconds: 64)); + final before = tester.getCenter(find.byKey(_sideKey)); + + await tester.pumpWidget(_host(sideDocked: false)); + expect(tester.getCenter(find.byKey(_sideKey)), before); + await tester.pump(const Duration(milliseconds: 32)); + final reversed = tester.getCenter(find.byKey(_bottomKey)); + expect(reversed.dx, lessThan(before.dx)); + expect(reversed.dy, greaterThan(before.dy)); + + await tester.pumpWidget(_host(sideDocked: true)); + expect(tester.getCenter(find.byKey(_bottomKey)), reversed); + await tester.pump(const Duration(milliseconds: 220)); + expect(find.byKey(_bottomKey), findsNothing); + expect(find.byKey(_sideKey), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('animation ticks do not rebuild or lay out either player', ( + tester, + ) async { + await setLandscape(tester); + final bottom = _Counts(); + final side = _Counts(); + final bottomPlayer = _PlayerProbe(bottom); + final sidePlayer = _PlayerProbe(side); + + Future show(bool sideDocked) => tester.pumpWidget( + _host( + sideDocked: sideDocked, + bottomPlayer: bottomPlayer, + sidePlayer: sidePlayer, + ), + ); + + await show(false); + await show(true); + await tester.pump(const Duration(milliseconds: 16)); + final builds = (bottom.builds, side.builds); + final layouts = (bottom.layouts, side.layouts); + + for (var frame = 0; frame < 10; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + } + expect((bottom.builds, side.builds), builds); + expect((bottom.layouts, side.layouts), layouts); + + await tester.pump(const Duration(milliseconds: 220)); + expect(find.byType(_PlayerProbe), findsOneWidget); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('reduced motion snaps to the target, including mid-flight', ( + tester, + ) async { + await setLandscape(tester); + await tester.pumpWidget(_host(sideDocked: false)); + await tester.pumpWidget(_host(sideDocked: true)); + await tester.pump(const Duration(milliseconds: 48)); + await tester.pumpWidget(_host(sideDocked: true, reducedMotion: true)); + expect(find.byKey(_bottomKey), findsNothing); + expect(tester.getCenter(find.byKey(_sideKey)), const Offset(762, 200)); + + await tester.pumpWidget(_host(sideDocked: false, reducedMotion: true)); + expect(find.byKey(_sideKey), findsNothing); + expect(tester.getCenter(find.byKey(_bottomKey)), const Offset(438, 360)); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('respects the app-wide animations switch', (tester) async { + await setLandscape(tester); + await tester.pumpWidget(_host(sideDocked: true)); + AppConstants.setAnimationsEnabled(false); + await tester.pumpWidget(_host(sideDocked: false)); + expect(find.byKey(_sideKey), findsNothing); + expect(tester.getCenter(find.byKey(_bottomKey)), const Offset(438, 360)); + expect(tester.binding.hasScheduledFrame, isFalse); + }); + + testWidgets('keeps safe insets and one tutorial anchor through handoffs', ( + tester, + ) async { + await setLandscape(tester); + const padding = EdgeInsets.only(top: 24, right: 20, bottom: 16); + await tester.pumpWidget(_host(sideDocked: false, padding: padding)); + expect( + tester.getRect(find.byKey(_bottomKey)), + const Rect.fromLTWH(76, 304, 704, 80), + ); + final container = ProviderScope.containerOf( + tester.element(find.byType(LandscapeMiniPlayerDock)), + ); + final registry = container.read(tutorialTargetRegistryProvider); + + await tester.pumpWidget(_host(sideDocked: true, padding: padding)); + await tester.pump(const Duration(milliseconds: 80)); + expect(find.byType(TutorialTargetAnchor), findsOneWidget); + expect( + registry.rectFor(TutorialTarget.miniPlayer), + tester.getRect(find.byKey(_sideKey)), + ); + + await tester.pump(const Duration(milliseconds: 140)); + expect( + tester.getRect(find.byKey(_sideKey)), + const Rect.fromLTWH(710, 24, 64, 360), + ); + expect( + registry.rectFor(TutorialTarget.miniPlayer), + tester.getRect(find.byKey(_sideKey)), + ); + }); + + testWidgets( + 'only the target is interactive, ticking, and available to heroes', + (tester) async { + await setLandscape(tester); + var bottomTaps = 0; + var sideTaps = 0; + final bottomPlayer = Hero( + tag: 'mini_player_art', + child: GestureDetector( + key: _bottomKey, + behavior: HitTestBehavior.opaque, + onTap: () => bottomTaps++, + child: const SizedBox.expand(), + ), + ); + final sidePlayer = Hero( + tag: 'mini_player_art', + child: GestureDetector( + key: _sideKey, + behavior: HitTestBehavior.opaque, + onTap: () => sideTaps++, + child: const SizedBox.expand(), + ), + ); + Future show(bool sideDocked) => tester.pumpWidget( + _host( + sideDocked: sideDocked, + bottomPlayer: bottomPlayer, + sidePlayer: sidePlayer, + ), + ); + + await show(false); + await show(true); + await tester.pump(const Duration(milliseconds: 80)); + final bottomContext = tester.element(find.byKey(_bottomKey)); + final sideContext = tester.element(find.byKey(_sideKey)); + expect(TickerMode.valuesOf(bottomContext).enabled, isFalse); + expect(TickerMode.valuesOf(sideContext).enabled, isTrue); + expect( + bottomContext.findAncestorWidgetOfExactType()!.enabled, + isFalse, + ); + expect( + sideContext.findAncestorWidgetOfExactType()!.enabled, + isTrue, + ); + expect( + bottomContext + .findAncestorWidgetOfExactType()! + .excluding, + isTrue, + ); + expect( + sideContext + .findAncestorWidgetOfExactType()! + .excluding, + isFalse, + ); + await tester.tap(find.byKey(_sideKey)); + expect(sideTaps, 1); + expect(bottomTaps, 0); + + Navigator.of(sideContext).push( + MaterialPageRoute( + builder: (_) => const Scaffold( + body: Hero( + tag: 'mini_player_art', + child: SizedBox(width: 100, height: 100), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets( + 'keeps dock state when an upper sibling disappears with the mode change', + (tester) async { + await setLandscape(tester); + + // Mirrors MainShell: a conditional overlay above the dock (the nav-inset + // blocker) is removed in the same rebuild that engages the side player. + Widget shellHost({required bool sideDocked}) => ProviderScope( + child: MaterialApp( + home: MediaQuery( + data: const MediaQueryData(size: Size(800, 400)), + child: Scaffold( + body: Stack( + children: [ + if (!sideDocked) + Positioned( + left: 76, + right: 0, + bottom: 0, + height: 90, + child: const ColoredBox( + color: Colors.black, + child: SizedBox.expand(key: ValueKey('blocker')), + ), + ), + Positioned.fill( + key: const ValueKey('landscape_mini_player_dock'), + left: 76, + child: LandscapeMiniPlayerDock( + sideDocked: sideDocked, + bottomPlayerHeight: 80, + bottomPlayer: const SizedBox.expand(key: _bottomKey), + sidePlayer: const SizedBox.expand(key: _sideKey), + ), + ), + ], + ), + ), + ), + ), + ); + + await tester.pumpWidget(shellHost(sideDocked: false)); + final start = tester.getRect(find.byKey(_bottomKey)); + + // The blocker vanishes as the side dock engages; the glide must still + // start from the bottom instead of remounting at the target. + await tester.pumpWidget(shellHost(sideDocked: true)); + await tester.pump(const Duration(milliseconds: 16)); + expect(find.byKey(_bottomKey), findsOneWidget); + expect(find.byKey(_sideKey), findsOneWidget); + final mid = tester.getRect(find.byKey(_bottomKey)); + expect(mid.center.dx, greaterThan(start.center.dx)); + expect(mid.center.dx, lessThan(762)); + + await tester.pump(const Duration(milliseconds: 204)); + expect(find.byKey(_bottomKey), findsNothing); + expect(tester.getCenter(find.byKey(_sideKey)), const Offset(762, 200)); + + // Returning to the bottom re-inserts the blocker; the outgoing side + // strip must still glide back instead of vanishing instantly. + await tester.pumpWidget(shellHost(sideDocked: false)); + await tester.pump(const Duration(milliseconds: 16)); + expect(find.byKey(_sideKey), findsOneWidget); + expect(find.byKey(_bottomKey), findsOneWidget); + final back = tester.getCenter(find.byKey(_sideKey)); + expect(back.dy, greaterThan(200)); + expect(back.dy, lessThan(360)); + + await tester.pump(const Duration(milliseconds: 204)); + expect(find.byKey(_sideKey), findsNothing); + expect(tester.getRect(find.byKey(_bottomKey)), start); + await tester.pump(const Duration(milliseconds: 16)); + expect(tester.binding.hasScheduledFrame, isFalse); + }, + ); +} diff --git a/test/widgets/landscape_side_mini_player_test.dart b/test/widgets/landscape_side_mini_player_test.dart index 1231845d2..48a24f1ae 100644 --- a/test/widgets/landscape_side_mini_player_test.dart +++ b/test/widgets/landscape_side_mini_player_test.dart @@ -12,7 +12,10 @@ import 'package:flick/models/song.dart'; import 'package:flick/providers/mini_player_config_provider.dart'; import 'package:flick/providers/providers.dart'; import 'package:flick/services/player_service.dart'; +import 'package:flick/widgets/common/embedded_mini_player.dart'; +import 'package:flick/widgets/common/landscape_mini_player_dock.dart'; import 'package:flick/widgets/common/landscape_side_mini_player.dart'; +import 'package:flick/widgets/navigation/bottom_bar_geometry.dart'; const _song = Song( id: 'side-player-track', @@ -45,6 +48,7 @@ Widget _host( ProviderContainer container, { required bool animationsEnabled, double height = 360, + Widget child = const LandscapeSideMiniPlayer(), }) => UncontrolledProviderScope( container: container, child: MaterialApp( @@ -62,10 +66,7 @@ Widget _host( alignment: Alignment.centerRight, child: SizedBox( height: height, - child: TickerMode( - enabled: animationsEnabled, - child: const LandscapeSideMiniPlayer(), - ), + child: TickerMode(enabled: animationsEnabled, child: child), ), ), ), @@ -280,4 +281,72 @@ void main() { ); await tester.pumpWidget(const SizedBox.shrink()); }); + + for (final separated in [false, true]) { + testWidgets('dock preserves real player layouts, separated: $separated', ( + tester, + ) async { + final player = _Player(); + addTearDown(player.dispose); + final container = _container(player); + await container + .read(miniPlayerConfigProvider.notifier) + .update( + (config) => + config.copyWith(height: 72, navigationGap: 20, textScale: 1.8), + ); + + Future show(bool sideDocked) => tester.pumpWidget( + _host( + container, + animationsEnabled: true, + child: Builder( + builder: (context) { + final config = container.read(miniPlayerConfigProvider); + return LandscapeMiniPlayerDock( + sideDocked: sideDocked, + bottomPlayerHeight: + BottomBarGeometry.miniPlayerHeight(context, config) + + 12 + + (separated ? config.navigationGap : 8), + bottomPlayer: EmbeddedMiniPlayer(separated: separated), + sidePlayer: const LandscapeSideMiniPlayer(), + ); + }, + ), + ), + ); + + await show(false); + await tester.pump(); + final bottomSurface = tester.getRect( + find.byKey(const ValueKey('mini_player_surface')), + ); + expect(bottomSurface.height, 72); + + await show(true); + await tester.pump(const Duration(milliseconds: 80)); + expect(find.byType(EmbeddedMiniPlayer), findsOneWidget); + expect(find.byType(LandscapeSideMiniPlayer), findsOneWidget); + expect( + tester.getSize(find.byKey(const ValueKey('mini_player_surface'))), + bottomSurface.size, + ); + expect(tester.takeException(), isNull); + + await tester.pump(const Duration(milliseconds: 140)); + expect(find.byType(EmbeddedMiniPlayer), findsNothing); + expect(tester.getSize(find.byType(LandscapeSideMiniPlayer)).width, 64); + + await show(false); + await tester.pump(const Duration(milliseconds: 220)); + expect(find.byType(LandscapeSideMiniPlayer), findsNothing); + expect( + tester.getRect(find.byKey(const ValueKey('mini_player_surface'))), + bottomSurface, + ); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }); + } }