From 5b519070a3f8c805e7bb57d2353d5eda2438c2de Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:33:57 +0700 Subject: [PATCH] feat(player): add beat-matched AutoMix and refine Mornye navigation Add default-off local beat analysis, pitch-preserving tempo matching, crossfade fallback, and cancellation-safe two-deck playback. Expose AutoMix in settings and the Mornye queue. Separate Search from Home, remove the Mornye greeting, and keep Home/Search icons moving continuously between full and compact navigation. Restore expansion on upward Library scrolling. Validate beat analysis, transport handoff and cancellation, settings persistence, and navigation behavior. Flutter analysis and the signed iOS simulator build pass. --- lib/l10n/arb/app_en.arb | 3 + lib/l10n/arb/app_id.arb | 5 +- lib/models/settings.dart | 4 + lib/models/settings.g.dart | 2 + lib/providers/settings_provider.dart | 5 + lib/screens/home_tab.dart | 191 ++++++----- lib/screens/home_tab_explore.dart | 3 +- lib/screens/main_shell.dart | 90 ++++-- .../settings/library_settings_page.dart | 8 + lib/services/automix_analysis.dart | 254 +++++++++++++++ lib/services/automix_analyzer.dart | 115 +++++++ lib/services/music_player_automix.dart | 303 ++++++++++++++++++ lib/services/music_player_service.dart | 131 ++++++-- lib/services/shell_navigation_service.dart | 39 ++- lib/widgets/mornye_bottom_bar.dart | 235 ++++++++------ lib/widgets/mornye_chrome.dart | 28 +- lib/widgets/mornye_player_queue.dart | 13 + test/automix_analysis_test.dart | 125 ++++++++ test/home_search_tabs_test.dart | 215 +++++++++++++ test/mornye_bottom_bar_test.dart | 158 ++++++++- test/mornye_player_queue_test.dart | 17 + test/music_player_automix_test.dart | 282 ++++++++++++++++ test/shell_navigation_service_test.dart | 146 +++++---- 23 files changed, 2074 insertions(+), 298 deletions(-) create mode 100644 lib/services/automix_analysis.dart create mode 100644 lib/services/automix_analyzer.dart create mode 100644 lib/services/music_player_automix.dart create mode 100644 test/automix_analysis_test.dart create mode 100644 test/home_search_tabs_test.dart create mode 100644 test/music_player_automix_test.dart 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(); }