diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 2c2ff316..8c9fd5f0 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -6635,6 +6635,9 @@ "@mornyeFavorited": { "description": "Selected state of the compact favorite action" }, + "autoMixDescription": "Blend songs with beat matching and pitch-preserving tempo adjustment. Uses crossfade when beats cannot be matched.", + "autoMixOn": "AutoMix on", + "autoMixOff": "AutoMix off", "optionsEmbeddedCoverSizeOriginal": "Original resolution", "@optionsEmbeddedCoverSizeOriginal": { "description": "Option that preserves the provider artwork at its original resolution" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 62675f21..2912cb1e 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -6246,5 +6246,8 @@ "mornyeContinuePlaying": "Putar Berikutnya", "mornyeFavorite": "Favorit", "mornyeGoToArtist": "Buka Artis", - "mornyeFavorited": "Difavoritkan" + "mornyeFavorited": "Difavoritkan", + "autoMixDescription": "Sambungkan lagu dengan pencocokan beat dan penyesuaian tempo tanpa mengubah nada. Gunakan crossfade jika beat tidak cocok.", + "autoMixOn": "AutoMix aktif", + "autoMixOff": "AutoMix nonaktif" } diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 92c2c1e9..f32b5f0d 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -31,6 +31,7 @@ class AppSettings { final bool embedReplayGain; // Apply ReplayGain/R128 tags as volume normalization in the built-in player. final bool playbackNormalization; + final bool autoMix; final bool discordRichPresence; final bool isFirstLaunch; final bool checkForUpdates; @@ -139,6 +140,7 @@ class AppSettings { this.embedLyrics = true, this.embedReplayGain = false, this.playbackNormalization = false, + this.autoMix = false, this.discordRichPresence = false, this.isFirstLaunch = true, this.checkForUpdates = true, @@ -223,6 +225,7 @@ class AppSettings { bool? embedLyrics, bool? embedReplayGain, bool? playbackNormalization, + bool? autoMix, bool? discordRichPresence, bool? isFirstLaunch, bool? checkForUpdates, @@ -303,6 +306,7 @@ class AppSettings { embedReplayGain: embedReplayGain ?? this.embedReplayGain, playbackNormalization: playbackNormalization ?? this.playbackNormalization, + autoMix: autoMix ?? this.autoMix, discordRichPresence: discordRichPresence ?? this.discordRichPresence, isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch, checkForUpdates: checkForUpdates ?? this.checkForUpdates, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 444d9d96..9e0fe714 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -22,6 +22,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( embedLyrics: json['embedLyrics'] as bool? ?? true, embedReplayGain: json['embedReplayGain'] as bool? ?? false, playbackNormalization: json['playbackNormalization'] as bool? ?? false, + autoMix: json['autoMix'] as bool? ?? false, discordRichPresence: json['discordRichPresence'] as bool? ?? false, isFirstLaunch: json['isFirstLaunch'] as bool? ?? true, checkForUpdates: json['checkForUpdates'] as bool? ?? true, @@ -118,6 +119,7 @@ Map _$AppSettingsToJson( 'embedLyrics': instance.embedLyrics, 'embedReplayGain': instance.embedReplayGain, 'playbackNormalization': instance.playbackNormalization, + 'autoMix': instance.autoMix, 'discordRichPresence': instance.discordRichPresence, 'isFirstLaunch': instance.isFirstLaunch, 'checkForUpdates': instance.checkForUpdates, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 4d370651..23c95c5c 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -551,6 +551,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setAutoMix(bool enabled) { + state = state.copyWith(autoMix: enabled); + _saveSettings(); + } + void setDiscordRichPresence(bool enabled) { state = state.copyWith(discordRichPresence: enabled); _saveSettings(); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index e34a8ecf..286f32ea 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -61,18 +61,26 @@ part 'home_tab_import.dart'; part 'home_tab_search_results.dart'; part 'home_tab_widgets.dart'; +enum HomeTabMode { combined, browse, search } + class HomeTab extends ConsumerStatefulWidget { - const HomeTab({super.key}); + const HomeTab({super.key, this.mode = HomeTabMode.combined}); + + final HomeTabMode mode; + @override ConsumerState createState() => _HomeTabState(); } class _HomeTabState extends ConsumerState with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { + bool get _showsSearch => widget.mode != HomeTabMode.browse; + bool get _showsHome => widget.mode != HomeTabMode.search; final _historySnapshot = TrackHistorySnapshot(); final _urlController = TextEditingController(); final FocusNode _searchFocusNode = FocusNode(); final _homeScrollController = ScrollController(); + String _lastSearchText = ''; String? _lastSearchQuery; String? _activeSearchInput; bool _isResettingSearchSurface = false; @@ -166,9 +174,7 @@ class _HomeTabState extends ConsumerState super.initState(); _urlController.addListener(_onSearchChanged); _searchFocusNode.addListener(_onSearchFocusChanged); - ShellNavigationService.homeSearchRequests.addListener( - _focusSearchFromShell, - ); + ShellNavigationService.searchRequests.addListener(_focusSearchFromShell); // Run an initial fetch check in case extensions were already initialized // before HomeTab was mounted (e.g. auto-installed during first setup). @@ -180,6 +186,7 @@ class _HomeTabState extends ConsumerState previous, next, ) { + if (!_showsSearch) return; _onTrackStateChanged(previous, next); if (previous != null && previous.isLoading && @@ -207,7 +214,7 @@ class _HomeTabState extends ConsumerState (previous, next) { if (next == true && previous != true) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { + if (mounted && _showsHome) { ref .read(exploreProvider.notifier) .fetchHomeFeed(forceRefresh: true); @@ -219,6 +226,7 @@ class _HomeTabState extends ConsumerState } void _fetchExploreIfNeeded() { + if (!_showsHome) return; if (ref.read(settingsProvider).homeFeedProvider == AppSettings.homeFeedProviderOff) { ref.read(exploreProvider.notifier).clear(); @@ -239,9 +247,7 @@ class _HomeTabState extends ConsumerState @override void dispose() { - ShellNavigationService.homeSearchRequests.removeListener( - _focusSearchFromShell, - ); + ShellNavigationService.searchRequests.removeListener(_focusSearchFromShell); _homeScrollController.dispose(); _liveSearchDebounce?.cancel(); _trackStateSub.close(); @@ -353,8 +359,11 @@ class _HomeTabState extends ConsumerState } void _focusSearchFromShell() { + if (!_showsSearch) return; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted) return; + if (!mounted || !_showsSearch || !TickerMode.valuesOf(context).enabled) { + return; + } if (_homeScrollController.hasClients) _homeScrollController.jumpTo(0); _searchFocusNode.requestFocus(); }); @@ -399,6 +408,10 @@ class _HomeTabState extends ConsumerState void _onSearchChanged() { final text = _urlController.text.trim(); + // Focusing an empty field also changes its selection. Only a text edit + // should reset results and dismiss focus, not that cursor notification. + if (text == _lastSearchText) return; + _lastSearchText = text; ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty); @@ -717,11 +730,14 @@ class _HomeTabState extends ConsumerState Widget build(BuildContext context) { super.build(context); - final hasActualResults = ref.watch( - trackProvider.select((s) => s.tracks.isNotEmpty), - ); - final isLoading = ref.watch(trackProvider.select((s) => s.isLoading)); - final searchError = ref.watch(trackProvider.select((s) => s.error)); + final hasActualResults = + _showsSearch && + ref.watch(trackProvider.select((s) => s.tracks.isNotEmpty)); + final isLoading = + _showsSearch && ref.watch(trackProvider.select((s) => s.isLoading)); + final searchError = _showsSearch + ? ref.watch(trackProvider.select((s) => s.error)) + : null; final hasSearchedBefore = ref.watch( settingsProvider.select((s) => s.hasSearchedBefore), ); @@ -756,9 +772,9 @@ class _HomeTabState extends ConsumerState ); final colorScheme = Theme.of(context).colorScheme; - final searchText = _urlController.text.trim(); + final searchText = _showsSearch ? _urlController.text.trim() : ''; final hasSearchInput = searchText.isNotEmpty; - final isSearchFocused = _searchFocusNode.hasFocus; + final isSearchFocused = _showsSearch && _searchFocusNode.hasFocus; final hasShortSearchInput = hasSearchInput && searchText.length < _minLiveSearchChars; final hasSearchError = hasSearchInput && searchError != null; @@ -773,15 +789,18 @@ class _HomeTabState extends ConsumerState !hasActualResults && !isLoading && searchError == null; - final isShowingRecentAccess = ref.watch( - trackProvider.select((s) => s.isShowingRecentAccess), - ); + final isShowingRecentAccess = + _showsSearch && + ref.watch(trackProvider.select((s) => s.isShowingRecentAccess)); final screenHeight = MediaQuery.sizeOf(context).height; final hasHistoryItems = ref.watch( _homeHistoryPreviewProvider.select((items) => items.isNotEmpty), ); - final recentModeRequested = isShowingRecentAccess || isSearchFocused; + final recentModeRequested = + widget.mode == HomeTabMode.search || + isShowingRecentAccess || + isSearchFocused; final showRecentAccess = recentModeRequested && (!hasSearchInput || @@ -796,16 +815,20 @@ class _HomeTabState extends ConsumerState explicitSearchProvider, extensions, ); - final showSearchBar = HomeSearchProviderPolicy.shouldShowSearchBar( - hasSearchProvider: hasSearchProvider, - isSearchProviderLoading: isSearchProviderLoading, - hasHomeFeedExtension: hasHomeFeedExtension, - hasExploreContent: hasExploreContent, - hasSearchInput: hasSearchInput, - ); + final showSearchBar = + _showsSearch && + (widget.mode == HomeTabMode.search || + HomeSearchProviderPolicy.shouldShowSearchBar( + hasSearchProvider: hasSearchProvider, + isSearchProviderLoading: isSearchProviderLoading, + hasHomeFeedExtension: hasHomeFeedExtension, + hasExploreContent: hasExploreContent, + hasSearchInput: hasSearchInput, + )); final hasResults = hasSearchInput || hasActualResults || isLoading || showRecentAccess; final showExplore = + _showsHome && !hasActualResults && !isLoading && !hasActiveSearchSurface && @@ -824,7 +847,7 @@ class _HomeTabState extends ConsumerState previous, next, ) { - if (previous == next) return; + if (!_showsSearch || previous == next) return; final selectedSearchFilter = ref.read( trackProvider.select((s) => s.selectedSearchFilter), ); @@ -869,13 +892,17 @@ class _HomeTabState extends ConsumerState controller: _homeScrollController, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, slivers: [ - AppSliverHeader.tabRoot(title: context.l10n.homeTitle), + AppSliverHeader.tabRoot( + title: widget.mode == HomeTabMode.search + ? context.l10n.mornyeSearch + : context.l10n.homeTitle, + ), SliverToBoxAdapter( child: AnimatedSize( duration: const Duration(milliseconds: 250), curve: Curves.easeOut, - child: (hasResults || showExplore) + child: (hasResults || showExplore || !_showsHome) ? const SizedBox.shrink() : _buildHomeIntro( colorScheme: colorScheme, @@ -952,14 +979,15 @@ class _HomeTabState extends ConsumerState duration: const Duration(milliseconds: 250), curve: Curves.easeOut, child: - (hasResults || + (!_showsHome || + hasResults || showRecentAccess || showExplore || showEmptyHomeState) ? const SizedBox.shrink() : Column( children: [ - if (!hasSearchedBefore) + if (_showsSearch && !hasSearchedBefore) Padding( padding: const EdgeInsets.only(top: 8), child: Text( @@ -1015,7 +1043,8 @@ class _HomeTabState extends ConsumerState }, ), - if (hasHomeFeedExtension && + if (_showsHome && + hasHomeFeedExtension && !homeFeedDisabled && !hasActualResults && !isLoading && @@ -1032,52 +1061,58 @@ class _HomeTabState extends ConsumerState ), ), - Consumer( - builder: (context, ref, _) { - final tracks = ref.watch( - trackProvider.select((s) => s.tracks), - ); - final isLoading = ref.watch( - trackProvider.select((s) => s.isLoading), - ); - final error = ref.watch(trackProvider.select((s) => s.error)); - final searchExtensionId = ref.watch( - trackProvider.select((s) => s.searchExtensionId), - ); - final localLibrarySettings = ref.watch( - settingsProvider.select( - (s) => - (s.localLibraryEnabled, s.localLibraryShowDuplicates), - ), - ); - final extensions = ref.watch( - extensionProvider.select((s) => s.extensions), - ); - final showLocalLibraryIndicator = - localLibrarySettings.$1 && localLibrarySettings.$2; - final thumbnailSizesByExtensionId = - _getThumbnailSizesByExtensionId(extensions); - final hasResults = - tracks.isNotEmpty || - isLoading || - error != null || - hasActiveSearchSurface; + if (_showsSearch) + Consumer( + builder: (context, ref, _) { + final tracks = ref.watch( + trackProvider.select((s) => s.tracks), + ); + final isLoading = ref.watch( + trackProvider.select((s) => s.isLoading), + ); + final error = ref.watch( + trackProvider.select((s) => s.error), + ); + final searchExtensionId = ref.watch( + trackProvider.select((s) => s.searchExtensionId), + ); + final localLibrarySettings = ref.watch( + settingsProvider.select( + (s) => ( + s.localLibraryEnabled, + s.localLibraryShowDuplicates, + ), + ), + ); + final extensions = ref.watch( + extensionProvider.select((s) => s.extensions), + ); + final showLocalLibraryIndicator = + localLibrarySettings.$1 && localLibrarySettings.$2; + final thumbnailSizesByExtensionId = + _getThumbnailSizesByExtensionId(extensions); + final hasResults = + tracks.isNotEmpty || + isLoading || + error != null || + hasActiveSearchSurface; - return SliverMainAxisGroup( - slivers: _buildSearchResults( - tracks: tracks, - isLoading: isLoading, - error: error, - colorScheme: colorScheme, - hasResults: hasResults, - showEmptySearchResult: showEmptySearchResult, - searchExtensionId: searchExtensionId, - showLocalLibraryIndicator: showLocalLibraryIndicator, - thumbnailSizesByExtensionId: thumbnailSizesByExtensionId, - ), - ); - }, - ), + return SliverMainAxisGroup( + slivers: _buildSearchResults( + tracks: tracks, + isLoading: isLoading, + error: error, + colorScheme: colorScheme, + hasResults: hasResults, + showEmptySearchResult: showEmptySearchResult, + searchExtensionId: searchExtensionId, + showLocalLibraryIndicator: showLocalLibraryIndicator, + thumbnailSizesByExtensionId: + thumbnailSizesByExtensionId, + ), + ); + }, + ), const NavBarSliverSpacer(), ], ), diff --git a/lib/screens/home_tab_explore.dart b/lib/screens/home_tab_explore.dart index 538a1902..5cfae7fe 100644 --- a/lib/screens/home_tab_explore.dart +++ b/lib/screens/home_tab_explore.dart @@ -6,7 +6,8 @@ extension _HomeTabExploreUI on _HomeTabState { String? greeting, ColorScheme colorScheme, ) { - final hasGreeting = greeting != null && greeting.isNotEmpty; + final hasGreeting = + !context.isMornye && greeting != null && greeting.isNotEmpty; final isTablet = MediaQuery.sizeOf(context).shortestSide >= 600; final sectionOffset = hasGreeting ? 1 : 0; final totalCount = sections.length + sectionOffset + 1; diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index bf1d9088..12bf83be 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -68,10 +68,13 @@ class _MainShellState extends ConsumerState ShellNavigationService.libraryTabNavigatorKey; final GlobalKey _repoTabNavigatorKey = ShellNavigationService.repoTabNavigatorKey; + final GlobalKey _searchTabNavigatorKey = + ShellNavigationService.searchTabNavigatorKey; late final _PreviewStopNavigatorObserver _homePreviewStopObserver; late final _PreviewStopNavigatorObserver _libraryPreviewStopObserver; late final _PreviewStopNavigatorObserver _repoPreviewStopObserver; + late final _PreviewStopNavigatorObserver _searchPreviewStopObserver; @override void didChangeDependencies() { @@ -85,6 +88,7 @@ class _MainShellState extends ConsumerState setPlaybackNormalizationEnabled( ref.read(settingsProvider).playbackNormalization, ); + setAutoMixEnabled(ref.read(settingsProvider).autoMix); unawaited( DiscordPresenceService.instance.setEnabled( ref.read(settingsProvider).discordRichPresence, @@ -112,6 +116,9 @@ class _MainShellState extends ConsumerState _repoPreviewStopObserver = _PreviewStopNavigatorObserver( () => ref.read(previewPlayerProvider.notifier).stop(), ); + _searchPreviewStopObserver = _PreviewStopNavigatorObserver( + () => ref.read(previewPlayerProvider.notifier).stop(), + ); _pageController = PageController(initialPage: _currentIndex); _tabJumpTransitionController = AnimationController( vsync: this, @@ -289,11 +296,14 @@ class _MainShellState extends ConsumerState if (!mounted) return; Navigator.of(context).popUntil((route) => route.isFirst); - _homeTabNavigatorKey.currentState?.popUntil((route) => route.isFirst); - - if (_currentIndex != 0) { - _onNavTap(0); - } + _onShellTabRequested(ShellTab.search); + final searchNavigator = context.isMornye + ? _searchTabNavigatorKey + : _homeTabNavigatorKey; + searchNavigator.currentState?.popUntil((route) => route.isFirst); + // Mount the lazy Search page before the metadata listener handles the link. + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; ref.read(settingsProvider.notifier).setHasSearchedBefore(); if (mounted) { ScaffoldMessenger.of( @@ -480,7 +490,7 @@ class _MainShellState extends ConsumerState // Unfocus BEFORE clear so _onTrackStateChanged can properly // clear _urlController (it checks !_searchFocusNode.hasFocus) FocusManager.instance.primaryFocus?.unfocus(); - ref.read(trackProvider.notifier).clear(); + if (!context.isMornye) ref.read(trackProvider.notifier).clear(); } void _onShellTabRequested(ShellTab tab) { @@ -489,17 +499,23 @@ class _MainShellState extends ConsumerState ); final index = switch (tab) { ShellTab.home => 0, + ShellTab.search => context.isMornye ? (showStore ? 4 : 3) : 0, ShellTab.library => 1, ShellTab.repository => showStore ? 2 : null, ShellTab.settings => showStore ? 3 : 2, }; - if (index != null) _onNavTap(index); + if (index != null) _onNavTap(index, resetHome: tab != ShellTab.search); } - void _onNavTap(int index) { + void _onNavTap(int index, {bool resetHome = true}) { + final showStore = ref.read( + settingsProvider.select((s) => s.showExtensionStore), + ); _mornyeChrome.expand(); - if (index == 0 && _currentIndex == 0) { + if (index == 0 && resetHome && (context.isMornye || _currentIndex == 0)) { _resetHomeToMain(); + } + if (index == 0 && _currentIndex == 0) { return; } @@ -511,12 +527,10 @@ class _MainShellState extends ConsumerState // cannot do this because _currentIndex is already updated below.) ref.read(previewPlayerProvider.notifier).stop(); setState(() => _currentIndex = index); - final showStore = ref.read( - settingsProvider.select((s) => s.showExtensionStore), - ); ShellNavigationService.syncState( currentTabIndex: _currentIndex, showRepoTab: showStore, + showSearchTab: context.isMornye, ); FocusManager.instance.primaryFocus?.unfocus(); // Jump directly when skipping intermediate tabs to avoid @@ -551,6 +565,7 @@ class _MainShellState extends ConsumerState ShellNavigationService.syncState( currentTabIndex: _currentIndex, showRepoTab: showStore, + showSearchTab: context.isMornye, ); FocusManager.instance.primaryFocus?.unfocus(); } @@ -578,10 +593,12 @@ class _MainShellState extends ConsumerState if (!mounted) return; final trackState = ref.read(trackProvider); + final isSearchTab = + _currentIndex == (context.isMornye ? (showStore ? 4 : 3) : 0); final isKeyboardVisible = MediaQuery.viewInsetsOf(context).bottom > 0; - if (_currentIndex == 0 && + if (isSearchTab && trackState.isShowingRecentAccess && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) { @@ -592,14 +609,14 @@ class _MainShellState extends ConsumerState return; } - if (_currentIndex == 0 && trackState.isShowingRecentAccess) { + if (isSearchTab && trackState.isShowingRecentAccess) { ref.read(trackProvider.notifier).setShowingRecentAccess(false); FocusManager.instance.primaryFocus?.unfocus(); _lastBackPress = null; return; } - if (_currentIndex == 0 && + if (isSearchTab && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) { // Unfocus BEFORE clear so _onTrackStateChanged can properly @@ -611,7 +628,7 @@ class _MainShellState extends ConsumerState return; } - if (_currentIndex == 0 && isKeyboardVisible) { + if (isSearchTab && isKeyboardVisible) { FocusManager.instance.primaryFocus?.unfocus(); _lastBackPress = null; return; @@ -623,7 +640,7 @@ class _MainShellState extends ConsumerState return; } - if (trackState.isLoading) { + if (isSearchTab && trackState.isLoading) { return; } @@ -647,6 +664,9 @@ class _MainShellState extends ConsumerState if (index == 0) return _homeTabNavigatorKey.currentState; if (index == 1) return _libraryTabNavigatorKey.currentState; if (showStore && index == 2) return _repoTabNavigatorKey.currentState; + if (context.isMornye && index == (showStore ? 4 : 3)) { + return _searchTabNavigatorKey.currentState; + } return null; } @@ -664,6 +684,9 @@ class _MainShellState extends ConsumerState ) { setPlaybackNormalizationEnabled(enabled); }); + ref.listen(settingsProvider.select((s) => s.autoMix), (_, enabled) { + setAutoMixEnabled(enabled); + }); final queueState = ref.watch( downloadQueueProvider.select((s) => s.queuedCount), ); @@ -676,6 +699,7 @@ class _MainShellState extends ConsumerState ShellNavigationService.syncState( currentTabIndex: _currentIndex, showRepoTab: showStore, + showSearchTab: context.isMornye, ); final repoUpdatesCount = ref.watch( repoProvider.select((s) => s.updatesAvailableCount), @@ -687,7 +711,11 @@ class _MainShellState extends ConsumerState navigatorKey: _homeTabNavigatorKey, observers: [_homePreviewStopObserver], heroAnimationsEnabled: heroAnimationsEnabled, - child: const HomeTab(), + child: Builder( + builder: (context) => HomeTab( + mode: context.isMornye ? HomeTabMode.browse : HomeTabMode.combined, + ), + ), ), _TabNavigator( key: const ValueKey('tab-library'), @@ -705,6 +733,14 @@ class _MainShellState extends ConsumerState child: const RepoTab(), ), const SettingsTab(), + if (context.isMornye) + _TabNavigator( + key: const ValueKey('tab-search'), + navigatorKey: _searchTabNavigatorKey, + observers: [_searchPreviewStopObserver], + heroAnimationsEnabled: heroAnimationsEnabled, + child: const HomeTab(mode: HomeTabMode.search), + ), ]; final l10n = context.l10n; @@ -774,9 +810,15 @@ class _MainShellState extends ConsumerState selectedIcon: SpinIcon(child: const Icon(Icons.settings)), label: l10n.navSettings, ), + if (context.isMornye) + NavigationDestination( + icon: const Icon(CupertinoIcons.search), + label: l10n.mornyeSearch, + ), ]; final maxIndex = tabs.length - 1; + final selectedDestination = _currentIndex.clamp(0, maxIndex); if (_currentIndex > maxIndex) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { @@ -867,10 +909,7 @@ class _MainShellState extends ConsumerState ), child: IntrinsicHeight( child: NavigationRail( - selectedIndex: _currentIndex.clamp( - 0, - maxIndex, - ), + selectedIndex: selectedDestination, onDestinationSelected: _onNavTap, labelType: NavigationRailLabelType.all, backgroundColor: Theme.of( @@ -924,11 +963,10 @@ class _MainShellState extends ConsumerState builder: (context, collapsed, _) => MornyeBottomBar( collapsed: canMinimizeChrome && collapsed, destinations: destinations, - selectedIndex: _currentIndex.clamp(0, maxIndex), + selectedIndex: selectedDestination, onSelected: _onNavTap, - onExpand: _mornyeChrome.expand, - onSearch: - ShellNavigationService.requestHomeSearch, + onHome: () => _onNavTap(0), + onSearch: ShellNavigationService.requestSearch, blurEnabled: !ref.watch(lowEndDeviceProvider) || ref.watch(backdropBlurEnabledProvider), diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 33ca9d34..8034b263 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -921,6 +921,14 @@ class _LibrarySettingsPageState extends ConsumerState { onChanged: (v) => ref .read(settingsProvider.notifier) .setPlaybackNormalization(v), + ), + SettingsSwitchItem( + icon: Icons.compare_arrows, + title: 'AutoMix', + subtitle: context.l10n.autoMixDescription, + value: settings.autoMix, + onChanged: (value) => + ref.read(settingsProvider.notifier).setAutoMix(value), showDivider: Platform.isAndroid, ), if (Platform.isAndroid) diff --git a/lib/services/automix_analysis.dart b/lib/services/automix_analysis.dart new file mode 100644 index 00000000..64f8196b --- /dev/null +++ b/lib/services/automix_analysis.dart @@ -0,0 +1,254 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +/// A local beat grid, measured in seconds within the decoded audio window. +class AutoMixBeatGrid { + const AutoMixBeatGrid({ + required this.bpm, + required this.phase, + required this.confidence, + required this.firstSound, + }); + + final double bpm; + final double phase; + final double confidence; + final double firstSound; + + bool get reliable => confidence >= 0.55 && bpm >= 60 && bpm <= 180; + double get period => 60 / bpm; + double beatAtOrAfter(double seconds) => + phase + ((seconds - phase) / period).ceil() * period; +} + +/// Bounded, original onset/autocorrelation detector. Runs in an isolate on +/// 24-second mono PCM windows, not on the UI isolate or an entire music file. +/// The onset -> autocorrelation -> phase approach is described in DAFx-09, +/// "Real-time beat-synchronous analysis of musical audio": +/// https://www.dafx.de/paper-archive/2009/papers/paper_65.pdf +AutoMixBeatGrid analyzeAutoMixPcm(Uint8List pcm) { + const sampleRate = 11025; + const hop = 128; + const framesPerSecond = sampleRate / hop; + final samples = ByteData.sublistView(pcm); + final frames = pcm.length ~/ (2 * hop); + const unknown = AutoMixBeatGrid( + bpm: 0, + phase: 0, + confidence: 0, + firstSound: 0, + ); + if (frames < framesPerSecond * 6) return unknown; + final onset = Float64List(frames); + final energy = Float64List(frames); + var low = 0.0; + var mid = 0.0; + var previousLow = 0.0; + var previousMid = 0.0; + var previousHigh = 0.0; + for (var frame = 0; frame < frames; frame++) { + var loEnergy = 0.0; + var midEnergy = 0.0; + var hiEnergy = 0.0; + for (var i = 0; i < hop; i++) { + final value = + samples.getInt16((frame * hop + i) * 2, Endian.little) / 32768; + low += 0.075 * (value - low); + mid += 0.45 * (value - mid); + loEnergy += low * low; + midEnergy += (mid - low) * (mid - low); + hiEnergy += (value - mid) * (value - mid); + energy[frame] += value * value / hop; + } + final lo = math.log(1 + loEnergy * 100 / hop); + final mi = math.log(1 + midEnergy * 100 / hop); + final hi = math.log(1 + hiEnergy * 100 / hop); + onset[frame] = + math.max(0, lo - previousLow) + + math.max(0, mi - previousMid) + + 0.5 * math.max(0, hi - previousHigh); + previousLow = lo; + previousMid = mi; + previousHigh = hi; + } + final peakEnergy = energy.reduce(math.max); + if (peakEnergy < 0.00001) return unknown; + final firstAudible = energy.indexWhere( + (e) => e > math.max(0.00001, peakEnergy * 0.01), + ); + final firstSound = math.max(0, firstAudible) / framesPerSecond; + // Suppress slow volume changes and constant noise before finding the pulse. + final prefix = Float64List(frames + 1); + for (var i = 0; i < frames; i++) { + prefix[i + 1] = prefix[i] + onset[i]; + } + var total = 0.0; + for (var i = 0; i < frames; i++) { + final begin = math.max(0, i - 8); + final end = math.min(frames, i + 9); + onset[i] = math.max( + 0, + onset[i] - (prefix[end] - prefix[begin]) / (end - begin), + ); + total += onset[i] * onset[i]; + } + if (total < 0.000001) { + return AutoMixBeatGrid( + bpm: 0, + phase: 0, + confidence: 0, + firstSound: firstSound, + ); + } + + double correlation(double lag) { + var dot = 0.0; + var left = 0.0; + var right = 0.0; + for (var i = lag.ceil(); i < frames; i++) { + final source = i - lag; + final j = source.floor(); + final fraction = source - j; + final delayed = + onset[j] * (1 - fraction) + + onset[math.min(j + 1, frames - 1)] * fraction; + dot += onset[i] * delayed; + left += onset[i] * onset[i]; + right += delayed * delayed; + } + return dot / math.sqrt(math.max(0.000000001, left * right)); + } + + var bestBpm = 0.0; + var bestScore = 0.0; + for (var bpm = 60.0; bpm <= 180; bpm += 0.25) { + final score = correlation(framesPerSecond * 60 / bpm); + // Resolve equally strong half-time candidates toward common musical tempi. + final weighted = + score * (0.96 + 0.04 * math.exp(-math.pow((bpm - 120) / 45, 2))); + if (weighted > bestScore) { + bestScore = weighted; + bestBpm = bpm; + } + } + if (bestBpm == 0) return unknown; + final period = framesPerSecond * 60 / bestBpm; + var bestPhase = 0.0; + var phaseScore = 0.0; + for (var phase = 0.0; phase < period; phase += 0.5) { + var score = 0.0; + var count = 0; + for (var beat = phase; beat < frames - 1; beat += period) { + final i = beat.round(); + score += onset[i]; + count++; + } + score /= math.max(1, count); + if (score > phaseScore) { + phaseScore = score; + bestPhase = phase; + } + } + var hits = 0; + var beats = 0; + for (var beat = bestPhase; beat < frames - 2; beat += period) { + final i = beat.round(); + final peak = math.max( + onset[i], + math.max(onset[math.max(0, i - 1)], onset[i + 1]), + ); + if (peak >= phaseScore * 0.3) hits++; + beats++; + } + final coverage = hits / math.max(1, beats); + return AutoMixBeatGrid( + bpm: bestBpm, + phase: bestPhase / framesPerSecond, + confidence: math.min(bestScore, coverage), + firstSound: firstSound, + ); +} + +class AutoMixPlan { + const AutoMixPlan({ + required this.start, + required this.incomingStart, + required this.duration, + required this.rate, + required this.beatMatched, + }); + + final Duration start; + final Duration incomingStart; + final Duration duration; + final double rate; + final bool beatMatched; + + static AutoMixPlan? create({ + required Duration outgoingDuration, + required Duration incomingDuration, + AutoMixBeatGrid? outro, + AutoMixBeatGrid? intro, + double outroOffset = 0, + }) { + final end = outgoingDuration.inMicroseconds / 1e6; + final nextLength = incomingDuration.inMicroseconds / 1e6; + if (end < 20 || nextLength < 20) return null; + var fade = 5.0; + var start = end - fade; + var incomingStart = 0.0; + var rate = 1.0; + var matched = false; + if (outro?.reliable == true && intro?.reliable == true) { + final outgoing = outro!; + final incoming = intro!; + final candidates = + [ + 0.5, + 1.0, + 2.0, + ].map((factor) => outgoing.bpm / (incoming.bpm * factor)).toList() + ..sort((a, b) => (a - 1).abs().compareTo((b - 1).abs())); + final candidate = candidates.first; + final firstBeat = incoming.beatAtOrAfter(incoming.firstSound); + // Never stretch wildly or discard a long musical intro to force a mix. + if ((candidate - 1).abs() <= 0.08 && firstBeat <= 3) { + rate = candidate; + fade = (8 * outgoing.period).clamp(3.0, 8.0); + final phase = outroOffset + outgoing.phase; + start = + phase + + ((end - fade - phase) / outgoing.period).floor() * outgoing.period; + incomingStart = firstBeat; + matched = true; + } + } + return AutoMixPlan( + start: Duration(microseconds: (start * 1e6).round()), + incomingStart: Duration(microseconds: (incomingStart * 1e6).round()), + duration: Duration(microseconds: (fade * 1e6).round()), + rate: rate, + beatMatched: matched, + ); + } +} + +/// Equal-power gain ramps; tempo returns gradually after the outgoing song ends. +({double outgoing, double incoming, double rate, bool complete}) +autoMixEnvelope(Duration elapsed, AutoMixPlan plan) { + final t = (elapsed.inMicroseconds / plan.duration.inMicroseconds).clamp( + 0.0, + 1.0, + ); + final recovery = ((elapsed - plan.duration).inMicroseconds / 8000000).clamp( + 0.0, + 1.0, + ); + final smooth = recovery * recovery * (3 - 2 * recovery); + return ( + outgoing: math.cos(t * math.pi / 2), + incoming: math.sin(t * math.pi / 2), + rate: plan.rate + (1 - plan.rate) * smooth, + complete: recovery >= 1 || (t >= 1 && plan.rate == 1), + ); +} diff --git a/lib/services/automix_analyzer.dart b/lib/services/automix_analyzer.dart new file mode 100644 index 00000000..c295c9c9 --- /dev/null +++ b/lib/services/automix_analyzer.dart @@ -0,0 +1,115 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/services/audio_analysis_jobs.dart'; +import 'package:spotiflac_android/services/automix_analysis.dart'; + +/// Only short head/tail windows are decoded. Summaries are bounded in memory; +/// raw PCM is deleted immediately and is never added to the user's library. +class AutoMixAnalyzer { + static const windowSeconds = 24.0; + final _cache = {}; + final _jobs = AudioAnalysisJobs( + start: (arguments) async { + final done = Completer(); + final session = await FFmpegKit.executeWithArgumentsAsync(arguments, ( + session, + ) async { + final success = ReturnCode.isSuccess(await session.getReturnCode()); + if (!done.isCompleted) done.complete(success); + }); + final id = session.getSessionId(); + if (id == null) throw StateError('AutoMix analysis session has no ID'); + final timeout = Timer(const Duration(seconds: 20), () { + unawaited(FFmpegKit.cancel(id)); + }); + return (id: id, completed: done.future.whenComplete(timeout.cancel)); + }, + cancel: FFmpegKit.cancel, + ); + int _generation = 0; + + void cancel() { + _generation++; + _jobs.invalidate(); + } + + void dispose() { + _generation++; + _jobs.dispose(); + _cache.clear(); + } + + Future analyze(String path, {double offset = 0}) async { + final generation = _generation; + Directory? work; + try { + final stat = await File(path).stat(); + if (stat.type != FileSystemEntityType.file) return null; + final key = + '$path:${stat.size}:${stat.modified.microsecondsSinceEpoch}:$offset'; + final cached = _cache.remove(key); + if (cached != null) { + _cache[key] = cached; + return cached; + } + if (generation != _generation) return null; + final temp = await getTemporaryDirectory(); + work = await Directory(temp.path).createTemp('automix-'); + final output = File('${work.path}/window.pcm'); + final success = await _jobs.run([ + '-hide_banner', + '-loglevel', + 'error', + '-nostdin', + '-y', + '-threads', + '1', + '-filter_threads', + '1', + '-ss', + offset.toStringAsFixed(6), + '-i', + path, + '-t', + '$windowSeconds', + '-vn', + '-sn', + '-dn', + '-ac', + '1', + '-ar', + '11025', + '-c:a', + 'pcm_s16le', + '-f', + 's16le', + output.path, + ]); + if (!success || generation != _generation || !await output.exists()) { + return null; + } + if (await output.length() > 11025 * 2 * 25) return null; + final grid = await compute(analyzeAutoMixPcm, await output.readAsBytes()); + if (generation != _generation) return null; + _cache[key] = grid; + while (_cache.length > 16) { + _cache.remove(_cache.keys.first); + } + return grid; + } on AudioAnalysisCancelled { + return null; + } on Object { + // Unsupported/corrupt input must not interrupt ordinary playback. + return null; + } finally { + if (work != null && await work.exists()) { + await work.delete(recursive: true); + } + } + } +} diff --git a/lib/services/music_player_automix.dart b/lib/services/music_player_automix.dart new file mode 100644 index 00000000..ba76c350 --- /dev/null +++ b/lib/services/music_player_automix.dart @@ -0,0 +1,303 @@ +part of 'music_player_service.dart'; + +/// Owns only the extra deck and transition work. The ordinary player remains +/// the sole transport when AutoMix is disabled or a transition cannot be made. +class _MusicAutoMix { + _MusicAutoMix(this.handler, this.analyzer); + + final MusicPlayerHandler handler; + final AutoMixAnalyzer analyzer; + final _preparationPins = >{}; + final _mixPins = {}; + AudioPlayer? _prepared; + AudioPlayer? _outgoing; + AutoMixPlan? _plan; + PlayableMedia? _next; + String? _nextPath; + Duration? _nextDuration; + String? _attempt; + int _nextIndex = -1; + int _queueRevision = -1; + int _playGeneration = -1; + int _generation = 0; + int _cancelling = 0; + bool _preparing = false; + bool _starting = false; + double _incomingVolume = 1; + double rate = 1; + Timer? _startTimer; + Timer? _fadeTimer; + Future _fadeWrite = Future.value(); + bool _writing = false; + + Set get pinnedPaths => { + ..._mixPins, + for (final paths in _preparationPins.values) ...paths, + }; + + bool get _canMix => + _autoMixEnabled && + !handler._disposed && + handler._sourceReady && + !handler._userPaused && + !handler._interruptionActive && + handler._player.state == PlayerState.playing && + handler._repeatMode != AudioServiceRepeatMode.one && + handler._media.length > 1; + + bool _current(int generation) => + generation == _generation && + _canMix && + _playGeneration == handler._playRequestGeneration && + _queueRevision == handler._sessionQueueRevision; + + void onPosition(Duration position) { + if (!_canMix || _cancelling > 0 || _starting || _fadeTimer != null) return; + final plan = _plan; + if (plan != null) { + if (!_current(_generation)) { + unawaited(cancel()); + return; + } + final delay = plan.start - position; + _startTimer?.cancel(); + if (delay < const Duration(milliseconds: -350)) return; + final generation = _generation; + _startTimer = Timer(delay.isNegative ? Duration.zero : delay, () { + if (_current(generation)) unawaited(_start(generation)); + }); + return; + } + if (_preparing) return; + final duration = handler.mediaItem.value?.duration; + if (duration == null || duration < const Duration(seconds: 20)) return; + final key = + '${handler._playRequestGeneration}:${handler._sessionQueueRevision}'; + if (_attempt == key) return; + _attempt = key; + unawaited(_prepare(duration)); + } + + Future _prepare(Duration duration) async { + _preparing = true; + final generation = ++_generation; + _playGeneration = handler._playRequestGeneration; + _queueRevision = handler._sessionQueueRevision; + final index = handler._index; + final nextIndex = handler._shuffle + ? handler._pickNextShuffle() + : index + 1 < handler._media.length + ? index + 1 + : handler._repeatMode == AudioServiceRepeatMode.all + ? 0 + : -1; + final pins = {}; + _preparationPins[generation] = pins; + AudioPlayer? deck; + try { + if (nextIndex < 0 || nextIndex == index || index < 0) return; + final current = handler._media[index]; + final next = handler._media[nextIndex]; + final currentPath = await handler._resolveSource(current); + if (currentPath == null || !_current(generation)) return; + pins.add(currentPath); + final nextPath = await handler._resolveSource(next); + if (nextPath == null || !_current(generation)) return; + pins.add(nextPath); + deck = AudioPlayer( + playerId: + 'music-mix-$generation-${DateTime.now().microsecondsSinceEpoch}', + ); + handler._listenToPlayer(deck); + await deck.setReleaseMode(ReleaseMode.stop); + await deck.setAudioContext(_musicAudioContext); + await deck.setVolume(0); + if (!_current(generation)) return; + await deck.setSource(DeviceFileSource(nextPath)); + if (!_current(generation)) return; + final nextDuration = + await deck.getDuration() ?? next.duration ?? Duration.zero; + final offset = max( + 0.0, + duration.inMicroseconds / 1e6 - AutoMixAnalyzer.windowSeconds, + ); + final outro = await analyzer.analyze(currentPath, offset: offset); + if (!_current(generation)) return; + final intro = await analyzer.analyze(nextPath); + if (!_current(generation)) return; + final plan = AutoMixPlan.create( + outgoingDuration: duration, + incomingDuration: nextDuration, + outro: outro, + intro: intro, + outroOffset: offset, + ); + if (plan == null) return; + await deck.seek(plan.incomingStart); + await deck.setPlaybackRate(plan.rate); + final volume = await handler._normalizationVolumeFor( + nextPath, + cacheKey: next.isContentUri ? next.source : null, + ); + if (!_current(generation)) return; + _prepared = deck; + deck = null; + _next = next; + _nextIndex = nextIndex; + _nextPath = nextPath; + _nextDuration = nextDuration; + _incomingVolume = volume; + _mixPins.addAll(pins); + _plan = plan; + _log.d( + 'AutoMix prepared (${plan.beatMatched ? 'beat matched' : 'crossfade'}, rate=${plan.rate.toStringAsFixed(3)})', + ); + final position = await handler._player.getCurrentPosition(); + if (position != null && _current(generation)) onPosition(position); + } catch (error) { + _log.w('AutoMix preparation skipped: $error'); + } finally { + if (deck != null) await handler._disposeDeck(deck); + _preparationPins.remove(generation); + if (generation == _generation) _preparing = false; + await handler._cleanupPendingResolvedPaths(); + } + } + + Future _start(int generation) async { + final incoming = _prepared; + final next = _next; + final plan = _plan; + if (incoming == null || + next == null || + plan == null || + !_current(generation)) { + return; + } + if (_nextIndex >= handler._media.length || + !identical(handler._media[_nextIndex], next)) { + return; + } + _starting = true; + try { + final position = await handler._player.getCurrentPosition(); + if (!_current(generation) || position == null) return; + final delta = position - plan.start; + if (delta < const Duration(milliseconds: -40)) { + _starting = false; + onPosition(position); + return; + } + if (delta > const Duration(milliseconds: 350)) return; + await incoming.resume(); + if (!_current(generation)) return; + final outgoing = handler._player; + final outgoingVolume = handler._normalizationVolume; + _outgoing = outgoing; + _prepared = null; + _plan = null; + handler._player = incoming; + handler._index = _nextIndex; + handler._playRequestGeneration++; + handler._recordPlayHistory(_nextIndex); + handler._normalizationVolume = _incomingVolume; + handler._activeResolvedPath = next.isContentUri ? _nextPath : null; + handler._pendingRestorePosition = null; + rate = plan.rate; + handler.mediaItem.add( + next + .toMediaItem(resolvedSource: next.isContentUri ? _nextPath : null) + .copyWith(duration: _nextDuration), + ); + handler._broadcastPosition(plan.incomingStart, force: true); + handler._broadcastState(playerState: PlayerState.playing); + unawaited(handler._persistSession(position: plan.incomingStart)); + final clock = Stopwatch()..start(); + _fadeTimer = Timer.periodic(const Duration(milliseconds: 40), (_) { + if (_writing || generation != _generation) return; + _writing = true; + _fadeWrite = () async { + try { + final levels = autoMixEnvelope(clock.elapsed, plan); + if (_outgoing != null) { + await outgoing.setVolume(outgoingVolume * levels.outgoing); + } + if (generation != _generation) return; + await incoming.setVolume(_incomingVolume * levels.incoming); + if (generation != _generation) return; + if ((levels.rate - rate).abs() > 0.001 || levels.complete) { + await incoming.setPlaybackRate(levels.rate); + rate = levels.rate; + } + if (generation != _generation) return; + if (clock.elapsed >= plan.duration && _outgoing != null) { + _outgoing = null; + await handler._disposeDeck(outgoing); + } + if (levels.complete) { + _fadeTimer?.cancel(); + _fadeTimer = null; + rate = 1; + _mixPins.clear(); + await handler._cleanupPendingResolvedPaths(); + handler._broadcastState(); + } + } catch (error) { + _log.w('AutoMix transition ended early: $error'); + // Defer cancellation until this volume write has unwound. + scheduleMicrotask(() => unawaited(cancel())); + } finally { + _writing = false; + } + }(); + }); + } catch (error) { + _log.w('AutoMix start skipped: $error'); + } finally { + if (generation == _generation) _starting = false; + } + } + + Future cancel() async { + _generation++; + _cancelling++; + analyzer.cancel(); + _startTimer?.cancel(); + _startTimer = null; + _fadeTimer?.cancel(); + _fadeTimer = null; + final prepared = _prepared; + final outgoing = _outgoing; + final restore = outgoing != null || rate != 1; + _prepared = null; + _outgoing = null; + _plan = null; + _next = null; + _nextPath = null; + _nextDuration = null; + _preparing = false; + _starting = false; + _attempt = null; + try { + await _fadeWrite; + if (prepared != null) await handler._disposeDeck(prepared); + if (outgoing != null) await handler._disposeDeck(outgoing); + if (restore && !handler._disposed) { + await handler._player.setPlaybackRate(1); + await handler._player.setVolume(handler._normalizationVolume); + } + rate = 1; + _mixPins.clear(); + await handler._cleanupPendingResolvedPaths(); + } catch (error) { + _log.w('AutoMix cleanup: $error'); + } finally { + _cancelling--; + } + } + + Future dispose() async { + await cancel(); + analyzer.dispose(); + } +} diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index 474e7043..cbbe93b6 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -12,12 +12,16 @@ import 'package:spotiflac_android/services/app_state_database.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/playback_normalization.dart'; +import 'package:spotiflac_android/services/automix_analysis.dart'; +import 'package:spotiflac_android/services/automix_analyzer.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/ios_container_paths.dart'; import 'package:spotiflac_android/utils/playback_artwork.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; +part 'music_player_automix.dart'; + final _log = AppLogger('MusicPlayer'); String _playbackUnknownTitle = 'Unknown title'; @@ -32,6 +36,7 @@ void updateMusicPlayerStrings({ } bool _playbackNormalizationEnabled = false; +bool _autoMixEnabled = false; MusicPlayerHandler? _activeMusicPlayerHandler; /// Enables/disables ReplayGain volume normalization and re-applies it to the @@ -42,6 +47,13 @@ void setPlaybackNormalizationEnabled(bool enabled) { _activeMusicPlayerHandler?.reapplyNormalization(); } +void setAutoMixEnabled(bool enabled) { + if (_autoMixEnabled == enabled) return; + _autoMixEnabled = enabled; + final handler = _activeMusicPlayerHandler; + if (handler != null) unawaited(handler._autoMix.cancel()); +} + /// Refreshes gain tags after a successful file update, including SAF copies. void refreshPlaybackNormalization(String source) { final handler = _activeMusicPlayerHandler; @@ -289,7 +301,11 @@ Duration normalizedPlaybackResumePosition( class MusicPlayerHandler extends BaseAudioHandler with QueueHandler, SeekHandler { - final AudioPlayer _player = AudioPlayer(playerId: 'music-player'); + AudioPlayer _player = AudioPlayer(playerId: 'music-player'); + late final _MusicAutoMix _autoMix; + double _normalizationVolume = 1; + final _playerSubscriptions = + >>{}; AudioSession? _audioSession; final List _media = []; final List _queueItems = []; @@ -339,7 +355,8 @@ class MusicPlayerHandler extends BaseAudioHandler DateTime? get sleepTimerEndsAt => _sleepTimerEndsAt; - MusicPlayerHandler() { + MusicPlayerHandler({AutoMixAnalyzer? autoMixAnalyzer}) { + _autoMix = _MusicAutoMix(this, autoMixAnalyzer ?? AutoMixAnalyzer()); _activeMusicPlayerHandler = this; _init(); } @@ -351,8 +368,20 @@ class MusicPlayerHandler extends BaseAudioHandler unawaited(_player.setAudioContext(_musicAudioContext)); unawaited(_configureAudioSession()); - _subscriptions.addAll([ - _player.onPlayerStateChanged.listen((state) { + _listenToPlayer(_player); + } + + void _listenToPlayer(AudioPlayer player) { + // Position updates must continue with the display asleep. UI progress is + // interpolated between updates; querying native playback every frame is + // unnecessary and would stop preparing AutoMix when there are no frames. + player.positionUpdater = TimerPositionUpdater( + interval: const Duration(milliseconds: 200), + getPosition: player.getCurrentPosition, + ); + _playerSubscriptions[player] = [ + player.onPlayerStateChanged.listen((state) { + if (!identical(player, _player)) return; if (_switchingGeneration != 0 && (state == PlayerState.stopped || state == PlayerState.completed || @@ -367,17 +396,29 @@ class MusicPlayerHandler extends BaseAudioHandler } _broadcastState(playerState: state); }), - _player.onPositionChanged.listen(_handlePositionChanged), - _player.onDurationChanged.listen((duration) { + player.onPositionChanged.listen((position) { + if (identical(player, _player)) _handlePositionChanged(position); + }), + player.onDurationChanged.listen((duration) { + if (!identical(player, _player)) return; final current = mediaItem.value; if (current != null && duration > Duration.zero) { mediaItem.add(current.copyWith(duration: duration)); } }), - _player.onPlayerComplete.listen((_) { - unawaited(_handlePlayerComplete()); + player.onPlayerComplete.listen((_) { + if (identical(player, _player)) unawaited(_handlePlayerComplete()); }), - ]); + ]; + } + + Future _disposeDeck(AudioPlayer player) async { + final subscriptions = _playerSubscriptions.remove(player); + if (subscriptions == null) return; + for (final subscription in subscriptions) { + await subscription.cancel(); + } + await player.dispose(); } /// Configures the OS audio session and reacts to interruptions (e.g. another @@ -445,6 +486,7 @@ class MusicPlayerHandler extends BaseAudioHandler _log.i('Pausing internal player because of $reason'); _playRequestGeneration++; _switchingGeneration = 0; + await _autoMix.cancel(); try { await _player.pause(); } catch (e) { @@ -534,6 +576,8 @@ class MusicPlayerHandler extends BaseAudioHandler ? AudioProcessingState.loading : _mapProcessingState(state), playing: playing, + queueIndex: _index >= 0 ? _index : null, + speed: _autoMix.rate, shuffleMode: _shuffle ? AudioServiceShuffleMode.all : AudioServiceShuffleMode.none, @@ -555,11 +599,17 @@ class MusicPlayerHandler extends BaseAudioHandler } _lastPositionBroadcastAt = now; _lastBroadcastPosition = position; - playbackState.add(playbackState.value.copyWith(updatePosition: position)); + playbackState.add( + playbackState.value.copyWith( + updatePosition: position, + speed: _autoMix.rate, + ), + ); } void _handlePositionChanged(Duration position) { _broadcastPosition(position); + _autoMix.onPosition(position); if (_restoringSession || _player.state != PlayerState.playing || _media.isEmpty || @@ -622,6 +672,13 @@ class MusicPlayerHandler extends BaseAudioHandler final normalizationGeneration = ++_normalizationGeneration; if (index < 0 || index >= _media.length) return; unawaited(() async { + await _autoMix.cancel(); + if (generation != _playRequestGeneration || + index != _index || + index >= _media.length || + _disposed) { + return; + } final media = _media[index]; var resolved = media.isContentUri ? _resolvedPathCache[media.source] @@ -652,6 +709,7 @@ class MusicPlayerHandler extends BaseAudioHandler } try { await _player.setVolume(volume); + _normalizationVolume = volume; } catch (e) { _log.w('Failed to apply normalization volume: $e'); } @@ -718,7 +776,7 @@ class MusicPlayerHandler extends BaseAudioHandler } Future _discardResolvedPath(String path) async { - if (path == _activeResolvedPath) { + if (path == _activeResolvedPath || _autoMix.pinnedPaths.contains(path)) { _pendingResolvedPathDeletes.add(path); return; } @@ -732,7 +790,11 @@ class MusicPlayerHandler extends BaseAudioHandler Future _cleanupPendingResolvedPaths() async { final deletable = _pendingResolvedPathDeletes - .where((path) => path != _activeResolvedPath) + .where( + (path) => + path != _activeResolvedPath && + !_autoMix.pinnedPaths.contains(path), + ) .toList(growable: false); for (final path in deletable) { _pendingResolvedPathDeletes.remove(path); @@ -1022,23 +1084,15 @@ class MusicPlayerHandler extends BaseAudioHandler _pendingRestorePosition = null; } final generation = ++_playRequestGeneration; + _sourceReady = false; + await _autoMix.cancel(); + if (generation != _playRequestGeneration || _disposed) return; _index = index; _pausedByInterruption = false; _interruptionActive = false; _userPaused = false; - if (recordHistory) { - _playHistory.add(index); - if (_playHistory.length > 200) _playHistory.removeAt(0); - _recent.add(index); - final maxRecent = ((_media.length - 1) * 0.6).floor().clamp( - 1, - _media.length > 1 ? _media.length - 1 : 1, - ); - while (_recent.length > maxRecent) { - _recent.removeAt(0); - } - } + if (recordHistory) _recordPlayHistory(index); final media = _media[index]; final effectiveStartPosition = normalizedPlaybackResumePosition( @@ -1112,6 +1166,7 @@ class MusicPlayerHandler extends BaseAudioHandler _sourceReady = false; if (!_isCurrentPlayRequest(generation, media)) return; await _player.setVolume(normalizationVolume); + _normalizationVolume = normalizationVolume; if (!_isCurrentPlayRequest(generation, media)) return; final startAt = effectiveStartPosition > Duration.zero ? effectiveStartPosition @@ -1146,6 +1201,7 @@ class MusicPlayerHandler extends BaseAudioHandler cacheKey: media.source, ); await _player.setVolume(normalizationVolume); + _normalizationVolume = normalizationVolume; await _player.play(DeviceFileSource(fallback), position: startAt); } if (!_isCurrentPlayRequest(generation, media)) return; @@ -1219,6 +1275,19 @@ class MusicPlayerHandler extends BaseAudioHandler return pool[_random.nextInt(pool.length)]; } + void _recordPlayHistory(int index) { + _playHistory.add(index); + if (_playHistory.length > 200) _playHistory.removeAt(0); + _recent.add(index); + final maxRecent = ((_media.length - 1) * 0.6).floor().clamp( + 1, + _media.length > 1 ? _media.length - 1 : 1, + ); + while (_recent.length > maxRecent) { + _recent.removeAt(0); + } + } + Future _onComplete() async { if (_repeatMode == AudioServiceRepeatMode.one && _index >= 0 && @@ -1323,6 +1392,7 @@ class MusicPlayerHandler extends BaseAudioHandler _switchingGeneration = 0; _userPaused = true; _pausedByInterruption = false; + await _autoMix.cancel(); await _player.pause(); _broadcastState(playerState: PlayerState.paused); await _persistSession(position: await _currentPositionForPersist()); @@ -1330,12 +1400,14 @@ class MusicPlayerHandler extends BaseAudioHandler @override Future seek(Duration position) async { + await _autoMix.cancel(); await _player.seek(position); _broadcastPosition(position, force: true); } @override Future setShuffleMode(AudioServiceShuffleMode shuffleMode) async { + await _autoMix.cancel(); _shuffle = shuffleMode == AudioServiceShuffleMode.all; _broadcastState(); if (_media.isNotEmpty && _index >= 0) { @@ -1345,6 +1417,7 @@ class MusicPlayerHandler extends BaseAudioHandler @override Future setRepeatMode(AudioServiceRepeatMode repeatMode) async { + await _autoMix.cancel(); // Group repeat has no meaning for a flat queue; treat it as all. _repeatMode = repeatMode == AudioServiceRepeatMode.group ? AudioServiceRepeatMode.all @@ -1361,6 +1434,7 @@ class MusicPlayerHandler extends BaseAudioHandler _playRequestGeneration++; _switchingGeneration = 0; _userPaused = true; + await _autoMix.cancel(); await _player.stop(); _sourceReady = false; _activeResolvedPath = null; @@ -1393,6 +1467,7 @@ class MusicPlayerHandler extends BaseAudioHandler @override Future skipToPrevious() async { + await _autoMix.cancel(); if (playbackState.value.position > const Duration(seconds: 3)) { await _player.seek(Duration.zero); _broadcastPosition(Duration.zero, force: true); @@ -1505,14 +1580,20 @@ class MusicPlayerHandler extends BaseAudioHandler Future dispose() async { _disposed = true; + if (identical(_activeMusicPlayerHandler, this)) { + _activeMusicPlayerHandler = null; + } cancelSleepTimer(); _playRequestGeneration++; + await _autoMix.dispose(); for (final sub in _subscriptions) { await sub.cancel(); } _subscriptions.clear(); _sourceReady = false; - await _player.dispose(); + for (final player in _playerSubscriptions.keys.toList()) { + await _disposeDeck(player); + } _activeResolvedPath = null; final tempPaths = { ..._resolvedPathCache.values, diff --git a/lib/services/shell_navigation_service.dart b/lib/services/shell_navigation_service.dart index e3edd65a..05741d0f 100644 --- a/lib/services/shell_navigation_service.dart +++ b/lib/services/shell_navigation_service.dart @@ -1,9 +1,9 @@ import 'package:flutter/widgets.dart'; -enum ShellTab { home, library, repository, settings } +enum ShellTab { home, library, repository, settings, search } class ShellNavigationService { - static final homeSearchRequests = ValueNotifier(0); + static final searchRequests = ValueNotifier(0); static final chromeBrightness = ValueNotifier(null); static final chromeSurface = ValueNotifier(null); static final _visiblePages = , Route?>{}; @@ -13,7 +13,7 @@ class ShellNavigationService { ({ModalRoute route, Brightness brightness, Color? surface}) >{}; static bool _chromeUpdateScheduled = false; - static int _homeSearchGeneration = 0; + static int _searchGeneration = 0; static void setChromeBrightness({ required Object owner, @@ -52,21 +52,24 @@ class ShellNavigationService { WidgetsBinding.instance.ensureVisualUpdate(); } - static void requestHomeSearch() { - if (!requestTab(ShellTab.home)) return; - final generation = ++_homeSearchGeneration; + static void requestSearch() { + if (!requestTab(ShellTab.search)) return; + final generation = ++_searchGeneration; final owner = _tabSelectionOwner; - // PageView creates Home lazily. Deliver the request after its navigator + // PageView creates Search lazily. Deliver the request after its navigator // and focus listener mount, rather than losing the first tap on another tab. WidgetsBinding.instance.addPostFrameCallback((_) { - if (generation != _homeSearchGeneration || + if (generation != _searchGeneration || !identical(owner, _tabSelectionOwner) || - _currentTabIndex != 0) { + _currentTabIndex != (_showSearchTab ? (_showRepoTab ? 4 : 3) : 0)) { return; } - homeTabNavigatorKey.currentState?.popUntil((route) => route.isFirst); - homeSearchRequests.value++; - // Home scrolls to its search field and requests focus after layout. + final navigatorKey = _showSearchTab + ? searchTabNavigatorKey + : homeTabNavigatorKey; + navigatorKey.currentState?.popUntil((route) => route.isFirst); + searchRequests.value++; + // Search scrolls to its field and requests focus after layout. // Scheduling that frame avoids waiting for another tap to wake it up. WidgetsBinding.instance.ensureVisualUpdate(); }); @@ -79,9 +82,12 @@ class ShellNavigationService { GlobalKey(); static final GlobalKey repoTabNavigatorKey = GlobalKey(); + static final GlobalKey searchTabNavigatorKey = + GlobalKey(); static int _currentTabIndex = 0; static bool _showRepoTab = false; + static bool _showSearchTab = false; static Object? _tabSelectionOwner; static ValueChanged? _tabSelectionHandler; @@ -109,12 +115,16 @@ class ShellNavigationService { static void syncState({ required int currentTabIndex, required bool showRepoTab, + bool showSearchTab = false, }) { - if (_currentTabIndex == currentTabIndex && _showRepoTab == showRepoTab) { + if (_currentTabIndex == currentTabIndex && + _showRepoTab == showRepoTab && + _showSearchTab == showSearchTab) { return; } _currentTabIndex = currentTabIndex; _showRepoTab = showRepoTab; + _showSearchTab = showSearchTab; _scheduleChromeUpdate(); } @@ -124,6 +134,9 @@ class ShellNavigationService { if (_showRepoTab && _currentTabIndex == 2) { return repoTabNavigatorKey; } + if (_showSearchTab && _currentTabIndex == (_showRepoTab ? 4 : 3)) { + return searchTabNavigatorKey; + } return null; } diff --git a/lib/widgets/mornye_bottom_bar.dart b/lib/widgets/mornye_bottom_bar.dart index 752e7c07..e0bc6ed2 100644 --- a/lib/widgets/mornye_bottom_bar.dart +++ b/lib/widgets/mornye_bottom_bar.dart @@ -1,4 +1,5 @@ -import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'dart:math' as math; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; @@ -20,7 +21,9 @@ class MornyeChromeController extends ValueNotifier { } bool handleScroll(ScrollNotification notification) { - if (notification.depth != 0 || notification.metrics.axis != Axis.vertical) { + // Library's active list sits inside a PageView and NestedScrollView. Its + // drag notifications have a greater depth than the outer header's scroll. + if (notification.metrics.axis != Axis.vertical) { return false; } if (notification is ScrollStartNotification || @@ -60,7 +63,7 @@ class MornyeBottomBar extends ConsumerWidget { required this.destinations, required this.selectedIndex, required this.onSelected, - required this.onExpand, + required this.onHome, required this.onSearch, required this.blurEnabled, }); @@ -69,7 +72,7 @@ class MornyeBottomBar extends ConsumerWidget { final List destinations; final int selectedIndex; final ValueChanged onSelected; - final VoidCallback onExpand; + final VoidCallback onHome; final VoidCallback onSearch; final bool blurEnabled; @@ -79,47 +82,29 @@ class MornyeBottomBar extends ConsumerWidget { currentMediaItemProvider.select((item) => item.value != null), ); // Animated glass tabs already reserve 8px above their visible capsule. - final tabGap = + final glassTabs = blurEnabled && - !MediaQuery.disableAnimationsOf(context) && - !MediaQuery.highContrastOf(context) - ? 0.0 - : 8.0; + !MediaQuery.disableAnimationsOf(context) && + !MediaQuery.highContrastOf(context); + final tabGap = glassTabs ? 0.0 : 8.0; // These contents do not depend on animation progress. Retain their widget // instances so folding only updates size/opacity wrappers each frame. - Widget sideContent({ - required Widget icon, - required String tooltip, - required VoidCallback onPressed, - }) => RepaintBoundary( + Widget sideSurface() => RepaintBoundary( child: MornyeGlass.navigation( blurEnabled: blurEnabled, strongTint: true, tintOpacity: MornyeTheme.chromeOpacity(context), radius: 26, - child: SizedBox.square( - dimension: 52, - child: Material( - color: Colors.transparent, - child: IconButton( - tooltip: tooltip, - color: Theme.of(context).colorScheme.primary, - icon: icon, - onPressed: onPressed, - ), - ), - ), + child: const SizedBox.square(dimension: 52), ), ); - final expandButton = sideContent( - icon: destinations[selectedIndex].icon, - tooltip: context.l10n.mornyeShowTabs, - onPressed: onExpand, - ); - final searchButton = sideContent( - icon: const Icon(CupertinoIcons.search), - tooltip: context.l10n.mornyeSearch, - onPressed: onSearch, + final homeSurface = sideSurface(); + final searchSurface = sideSurface(); + final scheme = Theme.of(context).colorScheme; + final inactiveIconColor = Color.lerp( + scheme.onSurfaceVariant, + scheme.onSurface, + scheme.brightness == Brightness.dark ? 0.5 : 0.4, ); final player = MiniPlayer(compact: collapsed, bottomPadding: 0); final tabs = TickerMode( @@ -130,76 +115,140 @@ class MornyeBottomBar extends ConsumerWidget { selectedIndex: selectedIndex, onSelected: onSelected, blurEnabled: blurEnabled, + hideEdgeIcons: true, ), ), ); - return TweenAnimationBuilder( - tween: Tween(end: collapsed ? 1 : 0), - duration: MediaQuery.disableAnimationsOf(context) - ? Duration.zero - : const Duration(milliseconds: 380), - curve: Curves.easeInOutCubic, - builder: (context, amount, _) { - Widget sideButton({ - required Widget child, - required Alignment alignment, - }) => ClipRect( - child: Align( - alignment: alignment, - widthFactor: amount, - child: SizedBox( - width: 60, - height: 48 + 4 * amount, - child: Align( - alignment: alignment, + return LayoutBuilder( + builder: (context, constraints) { + // Match the tab's actual label height, including accessibility scaling. + // Both icons stay mounted above the fading capsule for the whole trip. + final labelStyle = Theme.of(context).textTheme.labelSmall!.copyWith( + fontSize: glassTabs ? 11 : null, + fontWeight: FontWeight.w600, + ); + final labelPainter = TextPainter( + text: TextSpan(text: destinations.first.label, style: labelStyle), + textDirection: Directionality.of(context), + textScaler: MediaQuery.textScalerOf(context), + maxLines: 1, + )..layout(); + final labelHeight = labelPainter.height; + labelPainter.dispose(); + final fullIconBottom = glassTabs + ? 40 + (labelHeight + 2) / 2 + : math.max(64.0, 49 + labelHeight) - 23.5; + final tabInset = glassTabs ? 6.0 : 5.0; + final fullIconStart = + tabInset + + (constraints.maxWidth - tabInset * 2) / destinations.length / 2; + return TweenAnimationBuilder( + tween: Tween(end: collapsed ? 1 : 0), + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 380), + curve: Curves.easeInOutCubic, + builder: (context, amount, _) { + Widget movingIcon({required bool home, required Widget surface}) { + final index = home ? 0 : destinations.length - 1; + final offset = (fullIconStart - 26) * (1 - amount); + return PositionedDirectional( + start: home ? offset : null, + end: home ? null : offset, + bottom: fullIconBottom * (1 - amount) + 34 * amount - 26, + width: 52, + height: 52, child: IgnorePointer( - ignoring: !collapsed, + ignoring: amount < 0.5, child: ExcludeSemantics( - excluding: !collapsed, - child: Opacity(opacity: amount, child: child), + excluding: amount < 0.5, + child: Stack( + alignment: Alignment.center, + children: [ + Opacity(opacity: amount, child: surface), + Material( + color: Colors.transparent, + child: IconButton( + key: ValueKey( + home + ? 'mornye-compact-home' + : 'mornye-compact-search', + ), + tooltip: home + ? context.l10n.navHome + : context.l10n.mornyeSearch, + iconSize: 25, + color: Color.lerp( + index == selectedIndex + ? scheme.primary + : inactiveIconColor, + scheme.primary, + amount, + ), + icon: destinations[index].icon, + onPressed: home ? onHome : onSearch, + ), + ), + ], + ), ), ), - ), - ), - ), - ); + ); + } - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (hasPlayer || amount > 0) - Padding( - padding: EdgeInsets.only( - bottom: tabGap + (8 - tabGap) * amount, - ), - child: Row( + return Stack( + clipBehavior: Clip.none, + children: [ + Column( + mainAxisSize: MainAxisSize.min, children: [ - sideButton( - child: expandButton, - alignment: Alignment.centerLeft, - ), - Expanded(child: player), - sideButton( - child: searchButton, - alignment: Alignment.centerRight, + if (hasPlayer || amount > 0) + Align( + alignment: Alignment.bottomCenter, + // With no track, introducing the row at full height would + // make the bar jump taller on the first animation frame. + heightFactor: hasPlayer ? 1 : amount, + child: Padding( + padding: EdgeInsets.only( + bottom: hasPlayer + ? tabGap + (8 - tabGap) * amount + : 8, + ), + child: Row( + children: [ + SizedBox( + width: 60 * amount, + height: hasPlayer ? 48 + 4 * amount : 52, + ), + Expanded(child: player), + SizedBox( + width: 60 * amount, + height: hasPlayer ? 48 + 4 * amount : 52, + ), + ], + ), + ), + ), + ClipRect( + child: Align( + alignment: Alignment.bottomCenter, + heightFactor: 1 - amount, + child: IgnorePointer( + ignoring: amount > 0.5, + child: ExcludeSemantics( + excluding: amount > 0.5, + child: Opacity(opacity: 1 - amount, child: tabs), + ), + ), + ), ), ], ), - ), - ClipRect( - child: Align( - alignment: Alignment.bottomCenter, - heightFactor: 1 - amount, - child: IgnorePointer( - ignoring: collapsed, - child: ExcludeSemantics( - excluding: collapsed, - child: Opacity(opacity: 1 - amount, child: tabs), - ), - ), - ), - ), - ], + movingIcon(home: true, surface: homeSurface), + movingIcon(home: false, surface: searchSurface), + ], + ); + }, ); }, ); diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart index 07d30008..11c64550 100644 --- a/lib/widgets/mornye_chrome.dart +++ b/lib/widgets/mornye_chrome.dart @@ -437,12 +437,16 @@ class MornyeTabBar extends StatelessWidget { required this.selectedIndex, required this.onSelected, required this.blurEnabled, + this.hideEdgeIcons = false, }); final List destinations; final int selectedIndex; final ValueChanged onSelected; final bool blurEnabled; + // The folding bottom bar paints these two icons above both layouts so they + // can travel without fading along with the full tab capsule. + final bool hideEdgeIcons; @override Widget build(BuildContext context) { @@ -516,7 +520,7 @@ class MornyeTabBar extends StatelessWidget { ), ), items: [ - for (final destination in destinations) + for (final (index, destination) in destinations.indexed) LiquidGlassTabBarItem( label: destination.label, iconBuilder: (context, icon) => IconTheme( @@ -526,10 +530,20 @@ class MornyeTabBar extends StatelessWidget { ? scheme.primary : inactiveIconColor, ), - child: destination.icon, + child: Opacity( + opacity: + hideEdgeIcons && + (index == 0 || + index == destinations.length - 1) + ? 0 + : 1, + child: destination.icon, + ), ), labelBuilder: (context, label) => Text( destination.label, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.labelSmall ?.copyWith( fontSize: label.textStyle.fontSize, @@ -593,7 +607,15 @@ class MornyeTabBar extends StatelessWidget { ), // Keep tab selection quiet, as in Mornye. Badges // stay live without the Material bounce/spin. - child: destinations[index].icon, + child: Opacity( + opacity: + hideEdgeIcons && + (index == 0 || + index == destinations.length - 1) + ? 0 + : 1, + child: destinations[index].icon, + ), ), const SizedBox(height: 2), Text( diff --git a/lib/widgets/mornye_player_queue.dart b/lib/widgets/mornye_player_queue.dart index a5cc7cfd..f3324628 100644 --- a/lib/widgets/mornye_player_queue.dart +++ b/lib/widgets/mornye_player_queue.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; @@ -44,6 +45,7 @@ class MornyePlayerQueue extends ConsumerWidget { final start = currentIndex + 1; final count = queue.length - start; final controller = ref.read(musicPlayerControllerProvider); + final autoMix = ref.watch(settingsProvider.select((s) => s.autoMix)); final type = Theme.of(context).textTheme; final repeatLabel = switch (playback.repeat) { AudioServiceRepeatMode.one => context.l10n.nowPlayingRepeatOne, @@ -87,6 +89,17 @@ class MornyePlayerQueue extends ConsumerWidget { children: [ Row( children: [ + modeButton( + icon: CupertinoIcons.arrow_merge, + label: autoMix + ? context.l10n.autoMixOn + : context.l10n.autoMixOff, + selected: autoMix, + onPressed: () => ref + .read(settingsProvider.notifier) + .setAutoMix(!autoMix), + ), + const SizedBox(width: 12), modeButton( icon: CupertinoIcons.shuffle, label: playback.shuffle diff --git a/test/automix_analysis_test.dart b/test/automix_analysis_test.dart new file mode 100644 index 00000000..1962cdb3 --- /dev/null +++ b/test/automix_analysis_test.dart @@ -0,0 +1,125 @@ +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/services/automix_analysis.dart'; + +Uint8List drumPattern(double bpm, {double phase = 0.17}) { + const sampleRate = 11025; + final bytes = ByteData(sampleRate * 24 * 2); + final random = math.Random(7); + for (var i = 0; i < sampleRate * 24; i++) { + final time = i / sampleRate; + final sinceBeat = (time - phase) % (60 / bpm); + final transient = time < phase ? 0.0 : math.exp(-sinceBeat * 50); + final value = + 0.75 * transient * math.sin(time * 2 * math.pi * 100) + + (random.nextDouble() - 0.5) * 0.003; + bytes.setInt16(i * 2, (value * 32767).round(), Endian.little); + } + return bytes.buffer.asUint8List(); +} + +void main() { + for (final bpm in [90.0, 120.0, 124.0, 160.0]) { + test('detects $bpm BPM and beat phase from audio', () { + final grid = analyzeAutoMixPcm(drumPattern(bpm)); + expect(grid.reliable, isTrue, reason: '${grid.bpm}, ${grid.confidence}'); + expect(grid.bpm, closeTo(bpm, 0.75)); + expect(grid.beatAtOrAfter(0), closeTo(0.17, 0.04)); + }); + } + + test('silence and unstructured noise do not invent a reliable beat', () { + expect(analyzeAutoMixPcm(Uint8List(11025 * 24 * 2)).reliable, isFalse); + final random = math.Random(11); + final noise = ByteData(11025 * 24 * 2); + for (var i = 0; i < noise.lengthInBytes; i += 2) { + noise.setInt16(i, random.nextInt(32000) - 16000, Endian.little); + } + expect(analyzeAutoMixPcm(noise.buffer.asUint8List()).reliable, isFalse); + }); + + AutoMixBeatGrid grid(double bpm, {double confidence = 0.9}) => + AutoMixBeatGrid( + bpm: bpm, + phase: 0.17, + confidence: confidence, + firstSound: 0.1, + ); + + AutoMixPlan plan(double from, double to, {double confidence = 0.9}) => + AutoMixPlan.create( + outgoingDuration: const Duration(seconds: 180), + incomingDuration: const Duration(seconds: 200), + outro: grid(from, confidence: confidence), + intro: grid(to), + outroOffset: 156, + )!; + + test('compatible tempi align outgoing and incoming beats', () { + final transition = plan(120, 124); + expect(transition.beatMatched, isTrue); + expect(transition.rate, closeTo(120 / 124, 0.00001)); + final beat = (transition.start.inMicroseconds / 1e6 - 156.17) / 0.5; + expect(beat, closeTo(beat.round(), 0.00001)); + expect(transition.incomingStart.inMilliseconds, 170); + expect( + transition.start + transition.duration, + lessThanOrEqualTo(const Duration(seconds: 180)), + ); + }); + + test('incompatible or uncertain beats use unstretched crossfade', () { + for (final transition in [ + plan(120, 160), + plan(120, 124, confidence: 0.2), + ]) { + expect(transition.beatMatched, isFalse); + expect(transition.rate, 1); + expect(transition.incomingStart, Duration.zero); + } + expect( + AutoMixPlan.create( + outgoingDuration: const Duration(seconds: 10), + incomingDuration: const Duration(minutes: 2), + ), + isNull, + ); + }); + + test('equal-power overlap restores normal tempo smoothly', () { + final transition = plan(120, 124); + final start = autoMixEnvelope(Duration.zero, transition); + expect(start.outgoing, 1); + expect(start.incoming, 0); + final middle = autoMixEnvelope(transition.duration ~/ 2, transition); + expect( + middle.outgoing * middle.outgoing + middle.incoming * middle.incoming, + closeTo(1, 0.00001), + ); + expect(middle.rate, transition.rate); + final recovery = autoMixEnvelope( + transition.duration + const Duration(seconds: 4), + transition, + ); + expect(recovery.rate, closeTo((transition.rate + 1) / 2, 0.00001)); + final end = autoMixEnvelope( + transition.duration + const Duration(seconds: 8), + transition, + ); + expect(end.complete, isTrue); + expect(end.rate, 1); + expect(end.incoming, 1); + expect(end.outgoing, closeTo(0, 0.00001)); + }); + + test('AutoMix is off for new and existing settings, and persists opt-in', () { + expect(const AppSettings().autoMix, isFalse); + expect(AppSettings.fromJson({}).autoMix, isFalse); + final enabled = const AppSettings().copyWith(autoMix: true); + expect(AppSettings.fromJson(enabled.toJson()).autoMix, isTrue); + expect(enabled.copyWith(autoMix: false).autoMix, isFalse); + }); +} diff --git a/test/home_search_tabs_test.dart b/test/home_search_tabs_test.dart new file mode 100644 index 00000000..9eda5e22 --- /dev/null +++ b/test/home_search_tabs_test.dart @@ -0,0 +1,215 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/app_localizations.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/download_history_provider.dart'; +import 'package:spotiflac_android/providers/explore_provider.dart'; +import 'package:spotiflac_android/providers/extension_provider.dart'; +import 'package:spotiflac_android/providers/recent_access_provider.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; +import 'package:spotiflac_android/providers/track_provider.dart'; +import 'package:spotiflac_android/screens/home_tab.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +void main() { + testWidgets( + 'Home keeps its feed while Search retains its query and results', + (tester) async { + tester.view.physicalSize = const Size(430, 932); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final search = _Search(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + settingsProvider.overrideWith(_Settings.new), + extensionProvider.overrideWith(_Extensions.new), + exploreProvider.overrideWith(_Explore.new), + downloadHistoryProvider.overrideWith(_History.new), + recentAccessProvider.overrideWith(_Recent.new), + trackProvider.overrideWith(() => search), + ], + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const _Tabs(), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Featured albums'), findsOneWidget); + expect(find.byType(TextField), findsNothing); + expect(find.text('Recently visited artist'), findsNothing); + + await tester.tap(find.text('Open Search')); + await tester.pumpAndSettle(); + expect(find.text('Search'), findsWidgets); + expect(find.text('Featured albums'), findsNothing); + expect(find.text('Recently visited artist'), findsOneWidget); + await tester.enterText(find.byType(TextField), 'Example'); + expect( + tester.widget(find.byType(TextField)).controller!.text, + 'Example', + ); + await tester.pump(); + await tester.testTextInput.receiveAction(TextInputAction.search); + await tester.pumpAndSettle(); + expect(search._requests, 1); + expect(find.text('Found artist'), findsOneWidget); + + await tester.tap(find.text('Open Home')); + await tester.pumpAndSettle(); + expect(find.text('Featured albums'), findsOneWidget); + expect(find.byType(TextField), findsNothing); + expect(find.text('Found artist'), findsNothing); + + await tester.tap(find.text('Open Search')); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(TextField)).controller!.text, + 'Example', + ); + expect(find.text('Found artist'), findsOneWidget); + expect(search._requests, 1); + await tester.tap(find.byTooltip('Clear')); + await tester.pumpAndSettle(); + expect(find.text('Found artist'), findsNothing); + expect(find.text('Recently visited artist'), findsOneWidget); + expect(find.text('Featured albums'), findsNothing); + expect(tester.takeException(), isNull); + }, + ); +} + +class _Tabs extends StatefulWidget { + const _Tabs(); + + @override + State<_Tabs> createState() => _TabsState(); +} + +class _TabsState extends State<_Tabs> { + int _index = 0; + + @override + Widget build(BuildContext context) => Scaffold( + body: IndexedStack( + index: _index, + children: [ + TickerMode( + enabled: _index == 0, + child: const HomeTab(mode: HomeTabMode.browse), + ), + TickerMode( + enabled: _index == 1, + child: const HomeTab(mode: HomeTabMode.search), + ), + ], + ), + bottomNavigationBar: Row( + children: [ + for (final (index, label) in [(0, 'Open Home'), (1, 'Open Search')]) + TextButton( + onPressed: () { + FocusManager.instance.primaryFocus?.unfocus(); + setState(() => _index = index); + }, + child: Text(label), + ), + ], + ), + ); +} + +class _Settings extends SettingsNotifier { + @override + AppSettings build() => + const AppSettings(searchProvider: 'example', hasSearchedBefore: true); +} + +class _Extensions extends ExtensionNotifier { + @override + ExtensionState build() => const ExtensionState( + isInitialized: true, + extensions: [ + Extension( + id: 'example', + name: 'example', + displayName: 'Example', + version: '1.0.0', + description: '', + enabled: true, + status: 'loaded', + hasMetadataProvider: true, + searchBehavior: SearchBehavior(enabled: true), + ), + ], + ); +} + +class _Explore extends ExploreNotifier { + @override + ExploreState build() => const ExploreState( + sections: [ + ExploreSection( + uri: 'example:featured', + title: 'Featured albums', + items: [], + ), + ], + ); +} + +class _History extends DownloadHistoryNotifier { + @override + DownloadHistoryState build() => DownloadHistoryState(); +} + +class _Recent extends RecentAccessNotifier { + @override + RecentAccessState build() => RecentAccessState( + isLoaded: true, + items: [ + RecentAccessItem( + id: 'recent', + name: 'Recently visited artist', + type: RecentAccessType.artist, + accessedAt: DateTime(2026), + providerId: 'example', + ), + ], + ); +} + +class _Search extends TrackNotifier { + int _requests = 0; + + @override + Future customSearch( + String extensionId, + String query, { + Map? options, + String? selectedFilter, + bool allowVerificationRetry = true, + }) async { + _requests++; + state = const TrackState( + hasSearchText: true, + searchExtensionId: 'example', + tracks: [ + Track( + id: 'artist', + name: 'Found artist', + artistName: '', + albumName: '', + duration: 0, + itemType: 'artist', + source: 'example', + ), + ], + ); + } +} diff --git a/test/mornye_bottom_bar_test.dart b/test/mornye_bottom_bar_test.dart index e582d1f2..3ce42597 100644 --- a/test/mornye_bottom_bar_test.dart +++ b/test/mornye_bottom_bar_test.dart @@ -23,12 +23,14 @@ void main() { late MornyeChromeController chrome; late ScrollController scroll; late int searches; + late int selected; final capture = GlobalKey(); setUp(() { chrome = MornyeChromeController(); scroll = ScrollController(); searches = 0; + selected = 0; }); tearDown(() { chrome.dispose(); @@ -95,14 +97,22 @@ void main() { icon: Icon(Icons.music_note), label: 'Library', ), + NavigationDestination( + icon: Icon(Icons.grid_view), + label: 'Repo', + ), NavigationDestination( icon: Icon(Icons.settings), label: 'Settings', ), + NavigationDestination( + icon: Icon(Icons.search), + label: 'Search', + ), ], selectedIndex: 0, - onSelected: (_) {}, - onExpand: chrome.expand, + onSelected: (index) => selected = index, + onHome: chrome.expand, onSearch: () => searches++, blurEnabled: blur, ), @@ -115,6 +125,117 @@ void main() { } const albumBlue = Color(0xff464566); + + for (final blur in [false, true]) { + testWidgets( + 'edge icons travel continuously without fading (glass: $blur)', + (tester) async { + await pumpShell(tester, blur: blur); + final homeButton = find.byKey(const ValueKey('mornye-compact-home')); + final icon = find.descendant( + of: homeButton, + matching: find.byIcon(Icons.home), + ); + final element = tester.element(icon); + final tabIcon = find + .descendant( + of: find.byType(MornyeTabBar), + matching: find.byIcon(Icons.home), + ) + .first; + final start = tester.getCenter(icon); + expect((start - tester.getCenter(tabIcon)).distance, lessThan(0.5)); + chrome.value = true; + await tester.pump(); + var previous = start; + for (var frame = 0; frame < 12; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(tester.element(icon), same(element)); + for (final opacity in tester.widgetList( + find.ancestor(of: icon, matching: find.byType(Opacity)), + )) { + expect(opacity.opacity, 1); + } + final center = tester.getCenter(icon); + expect((center - previous).distance, lessThan(8)); + previous = center; + } + expect(previous.dx, lessThan(start.dx)); + chrome.expand(); + await tester.pumpAndSettle(); + expect(tester.element(icon), same(element)); + expect((tester.getCenter(icon) - start).distance, lessThan(0.5)); + expect(tester.takeException(), isNull); + }, + ); + + testWidgets('five tabs keep Search at the right edge (glass: $blur)', ( + tester, + ) async { + await pumpShell(tester, blur: blur); + final tabs = find.byType(MornyeTabBar); + final search = find + .descendant(of: tabs, matching: find.text('Search')) + .first; + final settings = find + .descendant(of: tabs, matching: find.text('Settings')) + .first; + expect(tester.widget(tabs).destinations, hasLength(5)); + expect( + tester.getCenter(search).dx, + greaterThan(tester.getCenter(settings).dx), + ); + // The glass renderer paints the labels under a gesture overlay. + await tester.tapAt(tester.getCenter(search)); + await tester.pumpAndSettle(); + expect(selected, 4); + expect(tester.takeException(), isNull); + }); + } + + for (final withPlayer in [false, true]) { + testWidgets('compact transition has no height jump (player: $withPlayer)', ( + tester, + ) async { + await pumpShell(tester, withPlayer: withPlayer); + final bar = find.byType(MornyeBottomBar); + var previousHeight = tester.getSize(bar).height; + final playerState = withPlayer + ? tester.state(find.byType(MiniPlayer)) + : null; + chrome.value = true; + await tester.pump(); + Offset? firstHomeCenter; + for (var frame = 0; frame < 25; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + final height = tester.getSize(bar).height; + expect(height, lessThanOrEqualTo(previousHeight + 0.01)); + previousHeight = height; + firstHomeCenter ??= tester.getCenter( + find.byKey(const ValueKey('mornye-compact-home')), + ); + } + final homeCenter = tester.getCenter( + find.byKey(const ValueKey('mornye-compact-home')), + ); + expect((homeCenter.dy - firstHomeCenter!.dy).abs(), lessThan(20)); + expect(find.byTooltip('Home').hitTestable(), findsOneWidget); + expect(find.byTooltip('Search').hitTestable(), findsOneWidget); + if (withPlayer) { + expect(tester.state(find.byType(MiniPlayer)), same(playerState)); + } + chrome.expand(); + await tester.pump(); + for (var frame = 0; frame < 25; frame++) { + await tester.pump(const Duration(milliseconds: 16)); + final height = tester.getSize(bar).height; + expect(height, greaterThanOrEqualTo(previousHeight - 0.01)); + previousHeight = height; + } + expect(tester.takeException(), isNull); + }); + } + for (final brightness in Brightness.values) { for (final backdrop in [ Colors.white, @@ -214,7 +335,7 @@ void main() { lessThan(expandedHeight - 60), ); expect(find.byTooltip('Next track'), findsNothing); - await tester.tap(find.byTooltip('Show tabs')); + await tester.tap(find.byTooltip('Home')); await tester.pumpAndSettle(); expect(chrome.value, isFalse); expect(tester.getSize(find.byType(MornyeBottomBar)).height, expandedHeight); @@ -367,6 +488,35 @@ void main() { }, ); + testWidgets('Library inner scrolling expands before the header returns', ( + tester, + ) async { + await pumpShell( + tester, + body: NestedScrollView( + headerSliverBuilder: (_, _) => const [ + SliverAppBar(expandedHeight: 180, title: Text('Library')), + ], + body: PageView( + children: [ + ListView.builder( + itemExtent: 60, + itemCount: 60, + itemBuilder: (_, index) => Text('Track $index'), + ), + ], + ), + ), + ); + await tester.drag(find.byType(ListView), const Offset(0, -600)); + await tester.pumpAndSettle(); + expect(chrome.value, isTrue); + await tester.drag(find.byType(ListView), const Offset(0, 100)); + await tester.pumpAndSettle(); + expect(chrome.value, isFalse); + expect(tester.takeException(), isNull); + }); + testWidgets( 'glass bar stays mounted and pauses its tickers while collapsed', (tester) async { @@ -396,7 +546,7 @@ void main() { expect(chrome.value, isTrue); await tester.tap(find.byTooltip('Search')); expect(searches, 1); - await tester.tap(find.byTooltip('Show tabs')); + await tester.tap(find.byTooltip('Home')); await tester.pumpAndSettle(); expect(chrome.value, isFalse); expect(tester.takeException(), isNull); diff --git a/test/mornye_player_queue_test.dart b/test/mornye_player_queue_test.dart index 3c5505e5..b1fadfba 100644 --- a/test/mornye_player_queue_test.dart +++ b/test/mornye_player_queue_test.dart @@ -5,6 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/mornye_player_queue.dart'; @@ -26,6 +28,14 @@ class _Player extends MusicPlayerController { repeat = mode; } +class _Settings extends SettingsNotifier { + @override + AppSettings build() => const AppSettings(); + + @override + void setAutoMix(bool enabled) => state = state.copyWith(autoMix: enabled); +} + void main() { testWidgets( 'upcoming queue maps taps and reorders to the complete playback queue', @@ -40,6 +50,7 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + settingsProvider.overrideWith(_Settings.new), musicPlayerControllerProvider.overrideWithValue(player), currentMediaItemProvider.overrideWith( (ref) => Stream.value(queue[1]), @@ -65,6 +76,12 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Previous'), findsNothing); expect(find.text('Current'), findsNothing); + await tester.tap(find.byTooltip('AutoMix off')); + await tester.pumpAndSettle(); + expect(find.byTooltip('AutoMix on'), findsOneWidget); + await tester.tap(find.byTooltip('AutoMix on')); + await tester.pumpAndSettle(); + expect(find.byTooltip('AutoMix off'), findsOneWidget); await tester.tap(find.text('Last')); expect(player.jumpedTo, 3); final firstHandle = find.byType(ReorderableDragStartListener).first; diff --git a/test/music_player_automix_test.dart b/test/music_player_automix_test.dart new file mode 100644 index 00000000..376cf0f4 --- /dev/null +++ b/test/music_player_automix_test.dart @@ -0,0 +1,282 @@ +import 'dart:async'; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:spotiflac_android/services/automix_analysis.dart'; +import 'package:spotiflac_android/services/automix_analyzer.dart'; +import 'package:spotiflac_android/services/music_player_service.dart'; + +class _Analyzer extends AutoMixAnalyzer { + final calls = []; + Completer? pending; + bool matchBeats = false; + + @override + Future analyze(String path, {double offset = 0}) async { + calls.add(path); + if (matchBeats) { + return AutoMixBeatGrid( + bpm: offset > 0 ? 120 : 124, + phase: 0.17, + confidence: 0.9, + firstSound: 0.1, + ); + } + return pending?.future; + } +} + +/// Exercises the real AudioPlayer transport/streams against a native-channel +/// fake, including preparation, seek completion and outgoing completion events. +class _AudioNative { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + final calls = <(String, String, Map)>[]; + final positions = {}; + final live = {}; + final playing = {}; + + void install() { + for (final name in [ + 'xyz.luan/audioplayers.global', + 'xyz.luan/audioplayers.global/events', + ]) { + messenger.setMockMethodCallHandler( + MethodChannel(name), + (_) async => null, + ); + } + messenger.setMockMethodCallHandler( + const MethodChannel('xyz.luan/audioplayers'), + (call) async { + final args = call.arguments as Map; + final id = args['playerId']! as String; + calls.add((id, call.method, args)); + switch (call.method) { + case 'create': + live.add(id); + positions[id] = 0; + messenger.setMockMethodCallHandler( + MethodChannel('xyz.luan/audioplayers/events/$id'), + (_) async => null, + ); + case 'setSourceUrl': + unawaited(event(id, 'audio.onPrepared', true)); + unawaited(event(id, 'audio.onDuration', 60000)); + case 'seek': + positions[id] = args['position']! as int; + unawaited(event(id, 'audio.onSeekComplete')); + case 'resume': + playing.add(id); + case 'pause' || 'stop': + playing.remove(id); + case 'dispose': + playing.remove(id); + live.remove(id); + case 'getDuration': + return 60000; + case 'getCurrentPosition': + return positions[id]; + } + return null; + }, + ); + } + + Future event(String id, String name, [Object? value]) async { + await messenger.handlePlatformMessage( + 'xyz.luan/audioplayers/events/$id', + const StandardMethodCodec().encodeSuccessEnvelope({ + 'event': name, + 'value': ?value, + }), + (_) {}, + ); + } + + String get prepared => live.singleWhere((id) => id != 'music-player'); + + double? lastVolume(String id) => + calls + .where((call) => call.$1 == id && call.$2 == 'setVolume') + .lastOrNull + ?.$3['volume'] + as double?; +} + +const _tracks = [ + PlayableMedia(id: 'one', source: '/one.flac', title: 'One', artist: 'Artist'), + PlayableMedia(id: 'two', source: '/two.flac', title: 'Two', artist: 'Artist'), + PlayableMedia( + id: 'three', + source: '/three.flac', + title: 'Three', + artist: 'Artist', + ), +]; + +Future _until(bool Function() ready) async { + for (var i = 0; i < 100; i++) { + if (ready()) return; + await Future.delayed(const Duration(milliseconds: 20)); + } + fail('Playback did not reach the expected state'); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + late _AudioNative native; + late _Analyzer analyzer; + late MusicPlayerHandler handler; + + setUp(() { + setAutoMixEnabled(false); + native = _AudioNative()..install(); + analyzer = _Analyzer(); + handler = MusicPlayerHandler(autoMixAnalyzer: analyzer); + }); + + tearDown(() async { + await handler.dispose(); + analyzer.pending?.complete(null); + setAutoMixEnabled(false); + await Future.delayed(const Duration(milliseconds: 30)); + expect(native.live, isEmpty); + expect(native.playing, isEmpty); + }); + + Future prepare() async { + setAutoMixEnabled(true); + await handler.setQueueAndPlay(_tracks); + await _until(() => analyzer.calls.length == 2); + await _until( + () => native.calls.any((call) => call.$2 == 'setPlaybackRate'), + ); + } + + Future startMix({int startPosition = 55000}) async { + await prepare(); + final incoming = native.prepared; + native.positions['music-player'] = startPosition; + await _until(() => handler.mediaItem.value?.id == 'two'); + await _until(() => (native.lastVolume(incoming) ?? 0) > 0); + return incoming; + } + + test( + 'disabled AutoMix uses only the ordinary player and no analysis', + () async { + await handler.setQueueAndPlay(_tracks); + native.positions['music-player'] = 56000; + await Future.delayed(const Duration(milliseconds: 400)); + expect(analyzer.calls, isEmpty); + expect(native.live, {'music-player'}); + expect(handler.mediaItem.value?.id, 'one'); + }, + ); + + test( + 'handoff publishes native duration and ignores outgoing completion', + () async { + final incoming = await startMix(); + expect(native.playing, {'music-player', incoming}); + expect(handler.mediaItem.value?.duration, const Duration(minutes: 1)); + expect(handler.playbackState.value.queueIndex, 1); + await native.event('music-player', 'audio.onComplete'); + await Future.delayed(const Duration(milliseconds: 80)); + expect(handler.mediaItem.value?.id, 'two'); + expect(handler.playbackState.value.playing, isTrue); + }, + ); + + test( + 'pause during overlap stops both decks and restores incoming gain', + () async { + final incoming = await startMix(); + await handler.pause(); + expect(native.live, {incoming}); + expect(native.playing, isEmpty); + expect(native.lastVolume(incoming), 1); + expect(handler.playbackState.value.playing, isFalse); + }, + ); + + test( + 'matched transition releases outgoing and restores tempo after the fade', + () async { + analyzer.matchBeats = true; + final incoming = await startMix(startPosition: 55670); + expect(handler.playbackState.value.speed, closeTo(120 / 124, 0.0001)); + await Future.delayed(const Duration(milliseconds: 4200)); + expect(native.live, {incoming}); + expect(native.lastVolume(incoming), closeTo(1, 0.0001)); + await Future.delayed(const Duration(seconds: 8)); + expect(handler.playbackState.value.speed, 1); + expect(native.playing, {incoming}); + expect( + native.calls.where( + (call) => call.$1 == 'music-player' && call.$2 == 'dispose', + ), + hasLength(1), + ); + }, + ); + + test( + 'seek during overlap cancels fade and seeks only the active deck', + () async { + final incoming = await startMix(); + await handler.seek(const Duration(seconds: 12)); + expect(native.live, {incoming}); + expect(native.positions[incoming], 12000); + expect(native.lastVolume(incoming), 1); + }, + ); + + test( + 'turning AutoMix off releases the standby player without changing song', + () async { + await prepare(); + setAutoMixEnabled(false); + await _until(() => native.live.length == 1); + expect(native.live, {'music-player'}); + expect(handler.mediaItem.value?.id, 'one'); + expect(native.playing, {'music-player'}); + }, + ); + + test('queue edit never starts the previously prepared next song', () async { + await prepare(); + await handler.enqueue(_tracks[2], playNext: true); + native.positions['music-player'] = 55000; + await _until(() => analyzer.calls.length >= 4); + await _until(() => handler.mediaItem.value?.id == 'three'); + expect(handler.mediaItem.value?.id, 'three'); + }); + + test('manual skip invalidates an analysis still in flight', () async { + analyzer.pending = Completer(); + setAutoMixEnabled(true); + await handler.setQueueAndPlay(_tracks); + await _until(() => analyzer.calls.isNotEmpty); + await handler.skipToNext(); + await handler.pause(); + analyzer.pending!.complete(null); + analyzer.pending = null; + await _until(() => native.live.length == 1); + expect(handler.mediaItem.value?.id, 'two'); + expect(native.playing, isEmpty); + }); + + test('repeat one never prepares another deck', () async { + setAutoMixEnabled(true); + await handler.setRepeatMode(AudioServiceRepeatMode.one); + await handler.setQueueAndPlay(_tracks); + await Future.delayed(const Duration(milliseconds: 400)); + expect(native.live, {'music-player'}); + expect(analyzer.calls, isEmpty); + }); +} diff --git a/test/shell_navigation_service_test.dart b/test/shell_navigation_service_test.dart index 01519fb7..c25cd5be 100644 --- a/test/shell_navigation_service_test.dart +++ b/test/shell_navigation_service_test.dart @@ -5,67 +5,95 @@ import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/widgets/view_queue_snackbar_action.dart'; void main() { - testWidgets('first search request reaches a lazily mounted Home tab', ( - tester, - ) async { - await tester.pumpWidget(const MaterialApp(home: _SearchShell())); - await tester.pumpAndSettle(); - expect(find.byType(TextField), findsNothing); + for (final (separateSearch, showRepo) in [ + (false, false), + (true, false), + (true, true), + ]) { + testWidgets( + 'first search request reaches the lazy page (separate=$separateSearch, repo=$showRepo)', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: _SearchShell( + separateSearch: separateSearch, + showRepo: showRepo, + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.byType(TextField), findsNothing); - await tester.tap(find.text('Search')); - await tester.pumpAndSettle(); - final field = tester.widget(find.byType(TextField)); - expect(field.focusNode!.hasFocus, isTrue); - expect(tester.testTextInput.isVisible, isTrue); - }); - - testWidgets('one search request returns from an album and focuses Home', ( - tester, - ) async { - await tester.pumpWidget(const MaterialApp(home: _SearchShell())); - await tester.tap(find.text('Search')); - await tester.pumpAndSettle(); - final navigator = ShellNavigationService.homeTabNavigatorKey.currentState!; - navigator.push( - MaterialPageRoute( - builder: (_) => const Scaffold(body: Text('Album')), - ), + await tester.tap(find.text('Search')); + await tester.pumpAndSettle(); + final field = tester.widget(find.byType(TextField)); + expect(field.focusNode!.hasFocus, isTrue); + expect(tester.testTextInput.isVisible, isTrue); + }, ); - await tester.pumpAndSettle(); - await tester.tap(find.text('Search')); - await tester.pumpAndSettle(); - expect(find.text('Album'), findsNothing); - final field = tester.widget(find.byType(TextField)); - expect(field.focusNode!.hasFocus, isTrue); - expect(tester.testTextInput.isVisible, isTrue); - }); + + testWidgets( + 'search returns from an album to its own tab (separate=$separateSearch, repo=$showRepo)', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: _SearchShell( + separateSearch: separateSearch, + showRepo: showRepo, + ), + ), + ); + await tester.tap(find.text('Search')); + await tester.pumpAndSettle(); + final navigator = + (separateSearch + ? ShellNavigationService.searchTabNavigatorKey + : ShellNavigationService.homeTabNavigatorKey) + .currentState!; + expect(ShellNavigationService.activeTabNavigator(), same(navigator)); + navigator.push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Album')), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Search')); + await tester.pumpAndSettle(); + expect(find.text('Album'), findsNothing); + final field = tester.widget(find.byType(TextField)); + expect(field.focusNode!.hasFocus, isTrue); + expect(tester.testTextInput.isVisible, isTrue); + }, + ); + } testWidgets( - 'search selects Home before requesting focus and stops when shell is absent', + 'search selects its tab before requesting focus and stops when shell is absent', (tester) async { final owner = Object(); - var selectedHome = false; + ShellNavigationService.syncState(currentTabIndex: 0, showRepoTab: false); + var selectedSearch = false; var focusRequests = 0; void onSearch() { - expect(selectedHome, isTrue); + expect(selectedSearch, isTrue); focusRequests++; } - ShellNavigationService.homeSearchRequests.addListener(onSearch); + ShellNavigationService.searchRequests.addListener(onSearch); addTearDown(() { - ShellNavigationService.homeSearchRequests.removeListener(onSearch); + ShellNavigationService.searchRequests.removeListener(onSearch); ShellNavigationService.unregisterTabSelectionHandler(owner); }); ShellNavigationService.registerTabSelectionHandler( owner: owner, - handler: (tab) => selectedHome = tab == ShellTab.home, + handler: (tab) => selectedSearch = tab == ShellTab.search, ); - ShellNavigationService.requestHomeSearch(); + ShellNavigationService.requestSearch(); expect(focusRequests, 0); await tester.pump(); expect(focusRequests, 1); ShellNavigationService.unregisterTabSelectionHandler(owner); - ShellNavigationService.requestHomeSearch(); + ShellNavigationService.requestSearch(); expect(focusRequests, 1); }, ); @@ -155,7 +183,10 @@ void main() { } class _SearchShell extends StatefulWidget { - const _SearchShell(); + const _SearchShell({required this.separateSearch, required this.showRepo}); + + final bool separateSearch; + final bool showRepo; @override State<_SearchShell> createState() => _SearchShellState(); @@ -163,22 +194,29 @@ class _SearchShell extends StatefulWidget { class _SearchShellState extends State<_SearchShell> { final _pages = PageController(initialPage: 1); - late final _observer = ShellChromeObserver( - ShellNavigationService.homeTabNavigatorKey, - ); + GlobalKey get _navigatorKey => widget.separateSearch + ? ShellNavigationService.searchTabNavigatorKey + : ShellNavigationService.homeTabNavigatorKey; + int get _searchIndex => widget.separateSearch ? (widget.showRepo ? 4 : 3) : 0; + late final _observer = ShellChromeObserver(_navigatorKey); @override void initState() { super.initState(); - ShellNavigationService.syncState(currentTabIndex: 1, showRepoTab: false); + ShellNavigationService.syncState( + currentTabIndex: 1, + showRepoTab: widget.showRepo, + showSearchTab: widget.separateSearch, + ); ShellNavigationService.registerTabSelectionHandler( owner: this, handler: (_) { ShellNavigationService.syncState( - currentTabIndex: 0, - showRepoTab: false, + currentTabIndex: _searchIndex, + showRepoTab: widget.showRepo, + showSearchTab: widget.separateSearch, ); - _pages.jumpToPage(0); + _pages.jumpToPage(_searchIndex); }, ); } @@ -196,17 +234,17 @@ class _SearchShellState extends State<_SearchShell> { body: Column( children: [ TextButton( - onPressed: ShellNavigationService.requestHomeSearch, + onPressed: ShellNavigationService.requestSearch, child: const Text('Search'), ), Expanded( child: PageView.builder( controller: _pages, - itemCount: 2, - itemBuilder: (_, index) => index == 1 + itemCount: widget.separateSearch ? _searchIndex + 1 : 2, + itemBuilder: (_, index) => index != _searchIndex ? const Text('Library') : Navigator( - key: ShellNavigationService.homeTabNavigatorKey, + key: _navigatorKey, observers: [_observer], onGenerateRoute: (_) => MaterialPageRoute( builder: (_) => const _SearchHome(), @@ -232,7 +270,7 @@ class _SearchHomeState extends State<_SearchHome> { @override void initState() { super.initState(); - ShellNavigationService.homeSearchRequests.addListener(_onSearch); + ShellNavigationService.searchRequests.addListener(_onSearch); } void _onSearch() { @@ -243,7 +281,7 @@ class _SearchHomeState extends State<_SearchHome> { @override void dispose() { - ShellNavigationService.homeSearchRequests.removeListener(_onSearch); + ShellNavigationService.searchRequests.removeListener(_onSearch); _focus.dispose(); super.dispose(); }