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.
This commit is contained in:
zarzet committed 2026-09-20 20:36:26 +07:00
1 parent 31ef7c7c88
commit 5b519070a3
23 files changed
+2074 -298

No files matched your search

+3
View File
@@ -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"
+4 -1
View File
@@ -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"
}
+4
View File
@@ -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,
+2
View File
@@ -22,6 +22,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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<String, dynamic> _$AppSettingsToJson(
'embedLyrics': instance.embedLyrics,
'embedReplayGain': instance.embedReplayGain,
'playbackNormalization': instance.playbackNormalization,
'autoMix': instance.autoMix,
'discordRichPresence': instance.discordRichPresence,
'isFirstLaunch': instance.isFirstLaunch,
'checkForUpdates': instance.checkForUpdates,
+5
View File
@@ -551,6 +551,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setAutoMix(bool enabled) {
state = state.copyWith(autoMix: enabled);
_saveSettings();
}
void setDiscordRichPresence(bool enabled) {
state = state.copyWith(discordRichPresence: enabled);
_saveSettings();
+113 -78
View File
@@ -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<HomeTab> createState() => _HomeTabState();
}
class _HomeTabState extends ConsumerState<HomeTab>
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<HomeTab>
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<HomeTab>
previous,
next,
) {
if (!_showsSearch) return;
_onTrackStateChanged(previous, next);
if (previous != null &&
previous.isLoading &&
@@ -207,7 +214,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
(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<HomeTab>
}
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<HomeTab>
@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<HomeTab>
}
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<HomeTab>
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<HomeTab>
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<HomeTab>
);
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<HomeTab>
!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<HomeTab>
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<HomeTab>
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<HomeTab>
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<HomeTab>
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<HomeTab>
},
),
if (hasHomeFeedExtension &&
if (_showsHome &&
hasHomeFeedExtension &&
!homeFeedDisabled &&
!hasActualResults &&
!isLoading &&
@@ -1032,52 +1061,58 @@ class _HomeTabState extends ConsumerState<HomeTab>
),
),
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(),
],
),
+2 -1
View File
@@ -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;
+64 -26
View File
@@ -68,10 +68,13 @@ class _MainShellState extends ConsumerState<MainShell>
ShellNavigationService.libraryTabNavigatorKey;
final GlobalKey<NavigatorState> _repoTabNavigatorKey =
ShellNavigationService.repoTabNavigatorKey;
final GlobalKey<NavigatorState> _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<MainShell>
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<MainShell>
_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<MainShell>
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<MainShell>
// 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<MainShell>
);
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<MainShell>
// 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<MainShell>
ShellNavigationService.syncState(
currentTabIndex: _currentIndex,
showRepoTab: showStore,
showSearchTab: context.isMornye,
);
FocusManager.instance.primaryFocus?.unfocus();
}
@@ -578,10 +593,12 @@ class _MainShellState extends ConsumerState<MainShell>
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<MainShell>
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<MainShell>
return;
}
if (_currentIndex == 0 && isKeyboardVisible) {
if (isSearchTab && isKeyboardVisible) {
FocusManager.instance.primaryFocus?.unfocus();
_lastBackPress = null;
return;
@@ -623,7 +640,7 @@ class _MainShellState extends ConsumerState<MainShell>
return;
}
if (trackState.isLoading) {
if (isSearchTab && trackState.isLoading) {
return;
}
@@ -647,6 +664,9 @@ class _MainShellState extends ConsumerState<MainShell>
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<MainShell>
) {
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<MainShell>
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<MainShell>
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<MainShell>
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<MainShell>
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<MainShell>
),
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<MainShell>
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),
@@ -921,6 +921,14 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
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)
+254
View File
@@ -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),
);
}
+115
View File
@@ -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 = <String, AutoMixBeatGrid>{};
final _jobs = AudioAnalysisJobs<bool>(
start: (arguments) async {
final done = Completer<bool>();
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<AutoMixBeatGrid?> 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);
}
}
}
}
+303
View File
@@ -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 = <int, Set<String>>{};
final _mixPins = <String>{};
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<void> _fadeWrite = Future<void>.value();
bool _writing = false;
Set<String> 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<void> _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 = <String>{};
_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<void> _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<void> 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<void> dispose() async {
await cancel();
analyzer.dispose();
}
}
+106 -25
View File
@@ -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 =
<AudioPlayer, List<StreamSubscription<dynamic>>>{};
AudioSession? _audioSession;
final List<PlayableMedia> _media = [];
final List<MediaItem> _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<void> _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<void> _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<void> _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<void> _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<void> seek(Duration position) async {
await _autoMix.cancel();
await _player.seek(position);
_broadcastPosition(position, force: true);
}
@override
Future<void> 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<void> 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<void> 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<void> 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 = <String>{
..._resolvedPathCache.values,
+26 -13
View File
@@ -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<int>(0);
static final searchRequests = ValueNotifier<int>(0);
static final chromeBrightness = ValueNotifier<Brightness?>(null);
static final chromeSurface = ValueNotifier<Color?>(null);
static final _visiblePages = <GlobalKey<NavigatorState>, Route<dynamic>?>{};
@@ -13,7 +13,7 @@ class ShellNavigationService {
({ModalRoute<dynamic> 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<NavigatorState>();
static final GlobalKey<NavigatorState> repoTabNavigatorKey =
GlobalKey<NavigatorState>();
static final GlobalKey<NavigatorState> searchTabNavigatorKey =
GlobalKey<NavigatorState>();
static int _currentTabIndex = 0;
static bool _showRepoTab = false;
static bool _showSearchTab = false;
static Object? _tabSelectionOwner;
static ValueChanged<ShellTab>? _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;
}
+142 -93
View File
@@ -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> {
}
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<NavigationDestination> destinations;
final int selectedIndex;
final ValueChanged<int> 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<double>(
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<double>(
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),
],
);
},
);
},
);
+25 -3
View File
@@ -437,12 +437,16 @@ class MornyeTabBar extends StatelessWidget {
required this.selectedIndex,
required this.onSelected,
required this.blurEnabled,
this.hideEdgeIcons = false,
});
final List<NavigationDestination> destinations;
final int selectedIndex;
final ValueChanged<int> 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(
+13
View File
@@ -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