From 5f0463a7a42d5e534405fca6d3b4f84099ecd02c Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:53:40 +0700 Subject: [PATCH] feat(player): continue playback with Library autoplay --- lib/l10n/arb/app_en.arb | 3 + lib/l10n/arb/app_id.arb | 3 + lib/models/settings.dart | 4 + lib/models/settings.g.dart | 2 + lib/providers/music_player_provider.dart | 2 + lib/providers/settings_provider.dart | 5 + lib/screens/main_shell.dart | 8 + lib/screens/now_playing_screen.dart | 14 ++ lib/services/music_player_autoplay.dart | 254 +++++++++++++++++++++++ lib/services/music_player_service.dart | 87 +++++++- lib/widgets/mornye_player_queue.dart | 34 ++- test/mornye_player_queue_test.dart | 10 + test/music_player_automix_test.dart | 155 +++++++++++++- test/music_player_autoplay_test.dart | 74 +++++++ 14 files changed, 643 insertions(+), 12 deletions(-) create mode 100644 lib/services/music_player_autoplay.dart create mode 100644 test/music_player_autoplay_test.dart diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 582c5a70..aba017f7 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -6919,6 +6919,9 @@ "autoMixDescription": "Blend songs with beat matching and pitch-preserving tempo adjustment. Uses crossfade when beats cannot be matched.", "autoMixOn": "AutoMix on", "autoMixOff": "AutoMix off", + "autoplayOn": "Autoplay on", + "autoplayOff": "Autoplay off", + "autoplayFromLibrary": "Autoplay from your Library", "profileTitle": "Profile", "profileSetUp": "Set up your profile", "profileEdit": "Edit name and photo", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 3b6221d1..6dff92a0 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -6817,6 +6817,9 @@ "autoMixDescription": "Sambungkan lagu dengan pencocokan beat dan penyesuaian tempo tanpa mengubah nada. Gunakan crossfade jika beat tidak cocok.", "autoMixOn": "AutoMix aktif", "autoMixOff": "AutoMix nonaktif", + "autoplayOn": "Autoplay aktif", + "autoplayOff": "Autoplay nonaktif", + "autoplayFromLibrary": "Autoplay dari Library kamu", "profileTitle": "Profil", "profileSetUp": "Atur profil kamu", "profileEdit": "Ubah nama dan foto", diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 0187635d..6723df25 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -32,6 +32,7 @@ class AppSettings { // Apply ReplayGain/R128 tags as volume normalization in the built-in player. final bool playbackNormalization; final bool autoMix; + final bool autoplay; final bool isFirstLaunch; final bool checkForUpdates; final String updateChannel; @@ -144,6 +145,7 @@ class AppSettings { this.embedReplayGain = false, this.playbackNormalization = false, this.autoMix = false, + this.autoplay = false, this.isFirstLaunch = true, this.checkForUpdates = true, this.updateChannel = 'stable', @@ -231,6 +233,7 @@ class AppSettings { bool? embedReplayGain, bool? playbackNormalization, bool? autoMix, + bool? autoplay, bool? isFirstLaunch, bool? checkForUpdates, String? updateChannel, @@ -314,6 +317,7 @@ class AppSettings { playbackNormalization: playbackNormalization ?? this.playbackNormalization, autoMix: autoMix ?? this.autoMix, + autoplay: autoplay ?? this.autoplay, isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch, checkForUpdates: checkForUpdates ?? this.checkForUpdates, updateChannel: updateChannel ?? this.updateChannel, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 9be94cc4..86d03044 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( embedReplayGain: json['embedReplayGain'] as bool? ?? false, playbackNormalization: json['playbackNormalization'] as bool? ?? false, autoMix: json['autoMix'] as bool? ?? false, + autoplay: json['autoplay'] as bool? ?? false, isFirstLaunch: json['isFirstLaunch'] as bool? ?? true, checkForUpdates: json['checkForUpdates'] as bool? ?? true, updateChannel: json['updateChannel'] as String? ?? 'stable', @@ -122,6 +123,7 @@ Map _$AppSettingsToJson( 'embedReplayGain': instance.embedReplayGain, 'playbackNormalization': instance.playbackNormalization, 'autoMix': instance.autoMix, + 'autoplay': instance.autoplay, 'isFirstLaunch': instance.isFirstLaunch, 'checkForUpdates': instance.checkForUpdates, 'updateChannel': instance.updateChannel, diff --git a/lib/providers/music_player_provider.dart b/lib/providers/music_player_provider.dart index 55e6539d..e4601433 100644 --- a/lib/providers/music_player_provider.dart +++ b/lib/providers/music_player_provider.dart @@ -244,6 +244,7 @@ PlayableMedia playableFromHistory(DownloadHistoryItem item) { bitrate: item.bitrate, format: item.format, explicit: item.explicit, + genre: item.genre, ); } @@ -270,5 +271,6 @@ PlayableMedia playableFromLocal(LocalLibraryItem item) { bitrate: item.bitrate, format: item.format, explicit: item.explicit, + genre: item.genre, ); } diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index a038823b..2fd02ad4 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -556,6 +556,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setAutoplay(bool enabled) { + state = state.copyWith(autoplay: enabled); + _saveSettings(); + } + void setEmbedMetadata(bool enabled) { state = state.copyWith(embedMetadata: enabled); _saveSettings(); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index fb9140d6..e4d9c1db 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -92,6 +92,10 @@ class _MainShellState extends ConsumerState ref.read(settingsProvider).playbackNormalization, ); setAutoMixEnabled(ref.read(settingsProvider).autoMix); + setAutoplayEnabled( + ref.read(settingsProvider).autoplay, + includeLocal: ref.read(settingsProvider).localLibraryEnabled, + ); // Deezer & co. localize artist/genre names by IP unless told the app's // language (issue #480). unawaited( @@ -738,6 +742,10 @@ class _MainShellState extends ConsumerState ref.listen(settingsProvider.select((s) => s.autoMix), (_, enabled) { setAutoMixEnabled(enabled); }); + ref.listen( + settingsProvider.select((s) => (s.autoplay, s.localLibraryEnabled)), + (_, value) => setAutoplayEnabled(value.$1, includeLocal: value.$2), + ); final queueState = ref.watch( downloadQueueProvider.select((s) => s.queuedCount), ); diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index f0053b25..d61f91d8 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -2356,6 +2356,9 @@ class _NowPlayingScreenState extends ConsumerState { ), ); final textTheme = Theme.of(context).textTheme; + final autoplay = ref.watch( + settingsProvider.select((s) => s.autoplay), + ); return Column( children: [ @@ -2371,6 +2374,17 @@ class _NowPlayingScreenState extends ConsumerState { ), ), const Spacer(), + IconButton( + tooltip: autoplay + ? context.l10n.autoplayOn + : context.l10n.autoplayOff, + isSelected: autoplay, + icon: const Icon(Icons.all_inclusive), + color: autoplay ? colorScheme.primary : null, + onPressed: () => ref + .read(settingsProvider.notifier) + .setAutoplay(!autoplay), + ), IconButton( tooltip: switch (repeatMode) { AudioServiceRepeatMode.one => diff --git a/lib/services/music_player_autoplay.dart b/lib/services/music_player_autoplay.dart new file mode 100644 index 00000000..39f3e3af --- /dev/null +++ b/lib/services/music_player_autoplay.dart @@ -0,0 +1,254 @@ +part of 'music_player_service.dart'; + +typedef AutoplayLibraryLoader = + Future> Function(PlayableMedia seed); + +bool _autoplayEnabled = false; +bool _autoplayIncludeLocal = true; + +void setAutoplayEnabled(bool enabled, {bool includeLocal = true}) { + if (_autoplayEnabled == enabled && _autoplayIncludeLocal == includeLocal) { + return; + } + _autoplayEnabled = enabled; + _autoplayIncludeLocal = includeLocal; + _activeMusicPlayerHandler?._autoplay.updateMode(); +} + +/// Bounded database pages keep recommendation work independent of Library +/// size. Both downloaded tracks and enabled scanned sources are local files. +Future> _loadAutoplayLibrary(PlayableMedia seed) async { + final database = LibraryDatabase.instance; + final query = QueueLibraryDbQuery(includeLocal: _autoplayIncludeLocal); + final count = (await database.getQueueCounts(query)).allTrackCount; + final offset = count > 256 ? Random().nextInt(count - 255) : 0; + final rows = [ + if (seed.artist.trim().isNotEmpty) + ...await database.getQueueTrackPage( + QueueLibraryDbQuery( + includeLocal: _autoplayIncludeLocal, + searchQuery: seed.artist, + limit: 128, + ), + ), + ...await database.getQueueTrackPage( + QueueLibraryDbQuery( + includeLocal: _autoplayIncludeLocal, + offset: offset, + limit: 256, + ), + ), + ]; + final result = []; + for (final row in rows) { + final data = (row['item'] as Map).cast(); + final source = data['filePath'] as String? ?? ''; + if (source.isEmpty) continue; + // Do not interpret remote URLs as streamable Library items. + if (source.startsWith('http://') || source.startsWith('https://')) continue; + if (!source.startsWith('content://') && !await File(source).exists()) { + continue; + } + final cover = data['coverPath'] as String? ?? data['coverUrl'] as String?; + final seconds = readPositiveInt(data['duration']); + result.add( + PlayableMedia( + id: data['id'] as String, + source: source, + title: data['trackName'] as String? ?? '', + artist: data['artistName'] as String? ?? '', + album: data['albumName'] as String? ?? '', + genre: data['genre'] as String?, + artUri: cover == null || cover.isEmpty + ? null + : cover.startsWith('/') + ? Uri.file(cover).toString() + : cover, + duration: seconds == null ? null : Duration(seconds: seconds), + bitDepth: readPositiveInt(data['bitDepth']), + sampleRate: readPositiveInt(data['sampleRate']), + bitrate: readPositiveInt(data['bitrate']), + format: data['format'] as String?, + ), + ); + } + return result; +} + +/// Rank once per refill, so the visible queue is the actual playback order. +/// Related songs score higher, while recent plays and repeated artists lose +/// priority. No model, network lookup or audio analysis is needed. +List selectAutoplayTracks({ + required PlayableMedia seed, + required List candidates, + required List upcoming, + required List recent, + required Set dismissedSources, + required Random random, + int limit = 6, +}) { + String normalized(String? value) => normalizeLookupText(value); + String songKey(PlayableMedia item) => + '${normalized(item.artist)}|${normalized(item.title)}'; + final sources = { + seed.source, + ...upcoming.map((item) => item.source), + ...dismissedSources, + }; + final songs = {songKey(seed), ...upcoming.map(songKey)}; + final scored = <(PlayableMedia, double)>[]; + for (final item in candidates) { + if (item.source.trim().isEmpty || + item.source.startsWith('http://') || + item.source.startsWith('https://') || + sources.contains(item.source) || + songs.contains(songKey(item))) { + continue; + } + sources.add(item.source); + songs.add(songKey(item)); + var score = random.nextDouble() * 2; + if (normalized(seed.artist).isNotEmpty && + normalized(seed.artist) == normalized(item.artist)) { + score += 5; + } + if (normalized(seed.album).isNotEmpty && + normalized(seed.album) == normalized(item.album)) { + score += 3; + } + if (normalized(seed.genre).isNotEmpty && + normalized(seed.genre) == normalized(item.genre)) { + score += 4; + } + final played = recent.lastIndexWhere( + (previous) => + previous.source == item.source || songKey(previous) == songKey(item), + ); + if (played >= 0) score -= 20 + played.toDouble(); + scored.add((item, score)); + } + final selected = []; + while (selected.length < limit && scored.isNotEmpty) { + scored.sort((a, b) => b.$2.compareTo(a.$2)); + final next = scored.removeAt(0).$1; + selected.add(next); + for (var i = 0; i < scored.length; i++) { + final (item, score) = scored[i]; + if (normalized(item.artist) == normalized(next.artist)) { + scored[i] = (item, score - 4); + } + } + } + return selected; +} + +class _MusicAutoplay { + _MusicAutoplay(this.handler, this.loadLibrary); + + final MusicPlayerHandler handler; + final AutoplayLibraryLoader loadLibrary; + final Set _dismissed = {}; + Future? _pending; + int _generation = 0; + (String, int)? _attempt; + + bool get enabled => + _autoplayEnabled && handler._repeatMode == AudioServiceRepeatMode.none; + + int get manualInsertIndex { + for (var i = handler._index + 1; i < handler._media.length; i++) { + if (handler._media[i].autoplay) return i; + } + return handler._media.length; + } + + void reset() { + _generation++; + _pending = null; + _attempt = null; + _dismissed.clear(); + } + + void dismiss(PlayableMedia item) => _dismissed.add(item.source); + + void updateMode() { + reset(); + // Never remove the song already playing or a manually queued song. + // Refresh suggestions when the enabled Library sources change too. + for (var i = handler._media.length - 1; i > handler._index; i--) { + if (handler._media[i].autoplay) { + handler.removeQueuedItem(handler._queueItems[i]); + } + } + _dismissed.clear(); + if (enabled) unawaited(fill()); + } + + Future fill() async { + if (!enabled || + handler._disposed || + handler._index < 0 || + handler._index >= handler._media.length) { + return; + } + if (handler._media.length - handler._index - 1 >= 3) return; + final pending = _pending; + if (pending != null) return pending; + final seed = handler._media[handler._index]; + final attempt = (seed.source, handler._sessionQueueRevision); + if (_attempt == attempt) return; + _attempt = attempt; + final operation = _fill(seed, _generation); + _pending = operation; + try { + await operation; + } finally { + if (identical(_pending, operation)) _pending = null; + } + } + + Future _fill(PlayableMedia seed, int generation) async { + try { + final candidates = await loadLibrary(seed); + if (!enabled || + handler._disposed || + generation != _generation || + handler._index < 0) { + return; + } + // Re-check the live queue after I/O: quick skips and manual additions + // must not duplicate entries or replace the user's selected track. + final current = handler._media[handler._index]; + final upcoming = handler._media.skip(handler._index + 1).toList(); + if (upcoming.length >= 3) return; + final selected = selectAutoplayTracks( + seed: current, + candidates: candidates, + upcoming: upcoming, + recent: handler._playHistory + .where((i) => i >= 0 && i < handler._media.length) + .map((i) => handler._media[i]) + .toList(), + dismissedSources: _dismissed, + random: handler._random, + limit: 6 - upcoming.length, + ); + if (selected.isEmpty) return; + final additions = [ + for (final item in selected) + PlayableMedia.fromJson({...item.toJson(), 'autoplay': true})!, + ]; + final queueItems = additions.map((item) => item.toMediaItem()).toList(); + handler._media.addAll(additions); + handler._queueItems.addAll(queueItems); + handler._originalQueueOrder?.addAll(queueItems); + handler._markSessionQueueChanged(); + handler.queue.add(List.unmodifiable(handler._queueItems)); + unawaited( + handler._persistSession(position: handler.playbackState.value.position), + ); + } catch (error) { + _log.w('Could not prepare Library Autoplay: $error'); + } + } +} diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index f8c595a5..9ba23813 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -12,6 +12,8 @@ import 'package:audioplayers/audioplayers.dart'; import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/services/app_state_database.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/sqlite_helpers.dart' + show normalizeLookupText; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/playback_normalization.dart'; import 'package:spotiflac_android/services/automix_analysis.dart'; @@ -23,6 +25,7 @@ import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; part 'music_player_automix.dart'; +part 'music_player_autoplay.dart'; final _log = AppLogger('MusicPlayer'); @@ -139,6 +142,8 @@ class PlayableMedia { final int? bitrate; final String? format; final bool explicit; + final String? genre; + final bool autoplay; const PlayableMedia({ required this.id, @@ -153,6 +158,8 @@ class PlayableMedia { this.bitrate, this.format, this.explicit = false, + this.genre, + this.autoplay = false, }); bool get isContentUri => source.startsWith('content://'); @@ -170,6 +177,8 @@ class PlayableMedia { if (bitrate != null && bitrate! > 0) 'bitrate': bitrate, if (format != null && format!.trim().isNotEmpty) 'format': format, if (explicit) 'explicit': true, + if (genre != null) 'genre': genre, + if (autoplay) 'autoplay': true, }; static PlayableMedia? fromJson( @@ -210,6 +219,8 @@ class PlayableMedia { bitrate: readPositiveInt(json['bitrate']), format: json['format']?.toString(), explicit: parseExplicitFlag(json['explicit']) == true, + genre: json['genre'] as String?, + autoplay: json['autoplay'] == true, ); } @@ -233,6 +244,7 @@ class PlayableMedia { if (format != null && format!.trim().isNotEmpty) 'format': format!.trim(), if (explicit) 'explicit': true, + if (autoplay) 'autoplay': true, }, ); } @@ -342,6 +354,8 @@ class MusicPlayerHandler extends BaseAudioHandler with QueueHandler, SeekHandler { AudioPlayer _player = AudioPlayer(playerId: 'music-player'); late final _MusicAutoMix _autoMix; + late final _MusicAutoplay _autoplay; + bool _completing = false; double _normalizationVolume = 1; final _playerSubscriptions = >>{}; @@ -396,8 +410,15 @@ class MusicPlayerHandler extends BaseAudioHandler DateTime? get sleepTimerEndsAt => _sleepTimerEndsAt; - MusicPlayerHandler({AutoMixAnalyzer? autoMixAnalyzer}) { + MusicPlayerHandler({ + AutoMixAnalyzer? autoMixAnalyzer, + AutoplayLibraryLoader? autoplayLibraryLoader, + }) { _autoMix = _MusicAutoMix(this, autoMixAnalyzer ?? AutoMixAnalyzer()); + _autoplay = _MusicAutoplay( + this, + autoplayLibraryLoader ?? _loadAutoplayLibrary, + ); _activeMusicPlayerHandler = this; _init(); } @@ -695,6 +716,7 @@ class MusicPlayerHandler extends BaseAudioHandler if (!_sourceReady || _switchingGeneration != 0) return; _broadcastPosition(position); _autoMix.onPosition(position); + unawaited(_autoplay.fill()); if (_restoringSession || _player.state != PlayerState.playing || _media.isEmpty || @@ -943,12 +965,20 @@ class MusicPlayerHandler extends BaseAudioHandler void _shuffleQueue() { _originalQueueOrder ??= List.of(_queueItems); + final manual = [ + for (var i = 0; i < _media.length; i++) + if (i == _index || !_media[i].autoplay) i, + ]; final order = buildShuffledQueueOrder( - mediaCount: _media.length, - currentIndex: _index, + mediaCount: manual.length, + currentIndex: manual.indexOf(_index), random: _random, ); - _applyQueueOrder(order.map((index) => _queueItems[index]).toList()); + _applyQueueOrder([ + ...order.map((index) => _queueItems[manual[index]]), + for (var i = 0; i < _media.length; i++) + if (i != _index && _media[i].autoplay) _queueItems[i], + ]); } void _rememberEnqueued(List items, {required bool playNext}) { @@ -956,7 +986,18 @@ class MusicPlayerHandler extends BaseAudioHandler if (original == null) return; final current = _queueItems[_index]; final at = original.indexWhere((item) => identical(item, current)); - original.insertAll(playNext && at >= 0 ? at + 1 : original.length, items); + final firstAutoplay = original.indexWhere( + (item) => item.extras?['autoplay'] == true && !identical(item, current), + max(0, at + 1), + ); + original.insertAll( + playNext && at >= 0 + ? at + 1 + : firstAutoplay >= 0 + ? firstAutoplay + : original.length, + items, + ); } /// Persists the queue only when it changed. Periodic position updates write @@ -1108,6 +1149,7 @@ class MusicPlayerHandler extends BaseAudioHandler if (queueNeedsRewrite) { await _persistSession(position: position); } + _autoplay.updateMode(); } bool _isCurrentPlayRequest(int generation, PlayableMedia media) { @@ -1124,6 +1166,7 @@ class MusicPlayerHandler extends BaseAudioHandler int initialIndex = 0, }) async { if (items.isEmpty) return; + _autoplay.reset(); _playRequestGeneration++; _media ..clear() @@ -1147,7 +1190,7 @@ class MusicPlayerHandler extends BaseAudioHandler } final insertAt = playNext ? (_index + 1).clamp(0, _media.length) - : _media.length; + : _autoplay.manualInsertIndex; _media.insert(insertAt, item); final queueItem = item.toMediaItem(); _queueItems.insert(insertAt, queueItem); @@ -1172,7 +1215,9 @@ class MusicPlayerHandler extends BaseAudioHandler await setQueueAndPlay(items); return; } - var at = playNext ? (_index + 1).clamp(0, _media.length) : _media.length; + var at = playNext + ? (_index + 1).clamp(0, _media.length) + : _autoplay.manualInsertIndex; final queued = []; for (final item in items) { _media.insert(at, item); @@ -1227,6 +1272,7 @@ class MusicPlayerHandler extends BaseAudioHandler void removeQueuedItem(MediaItem item) { final at = _queueItems.indexWhere((entry) => identical(entry, item)); if (_disposed || at < 0 || at == _index) return; + _autoplay.dismiss(_media[at]); _queueItems.removeAt(at); _media.removeAt(at); _originalQueueOrder?.removeWhere((entry) => identical(entry, item)); @@ -1262,6 +1308,7 @@ class MusicPlayerHandler extends BaseAudioHandler // Advance the requested index synchronously so consecutive Next taps do // not all target the same song while AutoMix/source preparation awaits. _index = index; + unawaited(_autoplay.fill()); final media = _media[index]; _switchingGeneration = generation; await _serializeSourceChange(() async { @@ -1507,6 +1554,16 @@ class MusicPlayerHandler extends BaseAudioHandler await _playIndex(_index, recordHistory: false); return; } + if (_index == _media.length - 1 && + _repeatMode == AudioServiceRepeatMode.none) { + final generation = _playRequestGeneration; + await _autoplay.fill(); + if (_disposed || + generation != _playRequestGeneration || + _shouldIgnoreComplete) { + return; + } + } if (_index >= 0 && _index < _media.length - 1) { await _playIndex(_index + 1); } else if (_repeatMode == AudioServiceRepeatMode.all && _media.isNotEmpty) { @@ -1517,6 +1574,7 @@ class MusicPlayerHandler extends BaseAudioHandler } Future _handlePlayerComplete() async { + if (_completing) return; if (_shouldIgnoreComplete) { if (_userPaused || _interruptionActive) { _broadcastState(playerState: PlayerState.paused); @@ -1524,7 +1582,12 @@ class MusicPlayerHandler extends BaseAudioHandler return; } - await _onComplete(); + _completing = true; + try { + await _onComplete(); + } finally { + _completing = false; + } } @override @@ -1643,6 +1706,7 @@ class MusicPlayerHandler extends BaseAudioHandler _repeatMode = repeatMode == AudioServiceRepeatMode.group ? AudioServiceRepeatMode.all : repeatMode; + _autoplay.updateMode(); _broadcastState(); if (_media.isNotEmpty && _index >= 0) { unawaited(_persistSession(position: playbackState.value.position)); @@ -1651,6 +1715,7 @@ class MusicPlayerHandler extends BaseAudioHandler @override Future stop() async { + _autoplay.reset(); cancelSleepTimer(); final generation = ++_playRequestGeneration; _switchingGeneration = 0; @@ -1686,6 +1751,11 @@ class MusicPlayerHandler extends BaseAudioHandler @override Future skipToNext() async { + if (_index >= 0 && _index == _media.length - 1) { + final generation = _playRequestGeneration; + await _autoplay.fill(); + if (_disposed || generation != _playRequestGeneration) return; + } if (_index < _media.length - 1) await _playIndex(_index + 1); } @@ -1806,6 +1876,7 @@ class MusicPlayerHandler extends BaseAudioHandler } Future dispose() async { + _autoplay.reset(); _disposed = true; if (identical(_activeMusicPlayerHandler, this)) { _activeMusicPlayerHandler = null; diff --git a/lib/widgets/mornye_player_queue.dart b/lib/widgets/mornye_player_queue.dart index ea8d0147..9962cc0b 100644 --- a/lib/widgets/mornye_player_queue.dart +++ b/lib/widgets/mornye_player_queue.dart @@ -47,6 +47,7 @@ class MornyePlayerQueue extends ConsumerWidget { final count = queue.length - start; final controller = ref.read(musicPlayerControllerProvider); final autoMix = ref.watch(settingsProvider.select((s) => s.autoMix)); + final autoplay = ref.watch(settingsProvider.select((s) => s.autoplay)); final type = Theme.of(context).textTheme; final repeatLabel = switch (playback.repeat) { AudioServiceRepeatMode.one => context.l10n.nowPlayingRepeatOne, @@ -71,6 +72,7 @@ class MornyePlayerQueue extends ConsumerWidget { ), foregroundColor: Colors.white, minimumSize: const Size(0, 44), + padding: const EdgeInsets.symmetric(horizontal: 8), shape: const StadiumBorder(), ), onPressed: onPressed, @@ -125,15 +127,41 @@ class MornyePlayerQueue extends ConsumerWidget { _ => AudioServiceRepeatMode.none, }), ), + const SizedBox(width: 12), + modeButton( + icon: CupertinoIcons.infinite, + label: autoplay + ? context.l10n.autoplayOn + : context.l10n.autoplayOff, + selected: autoplay, + onPressed: () => ref + .read(settingsProvider.notifier) + .setAutoplay(!autoplay), + ), ], ), const SizedBox(height: 12), Row( children: [ Expanded( - child: Text( - context.l10n.mornyeContinuePlaying, - style: type.titleLarge?.copyWith(color: Colors.white), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.mornyeContinuePlaying, + style: type.titleLarge?.copyWith( + color: Colors.white, + ), + ), + if (autoplay && + playback.repeat == AudioServiceRepeatMode.none) + Text( + context.l10n.autoplayFromLibrary, + style: type.bodySmall?.copyWith( + color: Colors.white70, + ), + ), + ], ), ), Builder( diff --git a/test/mornye_player_queue_test.dart b/test/mornye_player_queue_test.dart index b1fadfba..03cdb207 100644 --- a/test/mornye_player_queue_test.dart +++ b/test/mornye_player_queue_test.dart @@ -34,6 +34,9 @@ class _Settings extends SettingsNotifier { @override void setAutoMix(bool enabled) => state = state.copyWith(autoMix: enabled); + + @override + void setAutoplay(bool enabled) => state = state.copyWith(autoplay: enabled); } void main() { @@ -76,6 +79,13 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Previous'), findsNothing); expect(find.text('Current'), findsNothing); + await tester.tap(find.byTooltip('Autoplay off')); + await tester.pumpAndSettle(); + expect(find.byTooltip('Autoplay on'), findsOneWidget); + expect(find.text('Autoplay from your Library'), findsOneWidget); + await tester.tap(find.byTooltip('Autoplay on')); + await tester.pumpAndSettle(); + expect(find.byTooltip('Autoplay off'), findsOneWidget); await tester.tap(find.byTooltip('AutoMix off')); await tester.pumpAndSettle(); expect(find.byTooltip('AutoMix on'), findsOneWidget); diff --git a/test/music_player_automix_test.dart b/test/music_player_automix_test.dart index c7484891..d6ba8684 100644 --- a/test/music_player_automix_test.dart +++ b/test/music_player_automix_test.dart @@ -140,12 +140,18 @@ void main() { late _AudioNative native; late _Analyzer analyzer; late MusicPlayerHandler handler; + late AutoplayLibraryLoader autoplayLoader; setUp(() { setAutoMixEnabled(false); + setAutoplayEnabled(false); native = _AudioNative()..install(); analyzer = _Analyzer(); - handler = MusicPlayerHandler(autoMixAnalyzer: analyzer); + autoplayLoader = (_) async => _tracks; + handler = MusicPlayerHandler( + autoMixAnalyzer: analyzer, + autoplayLibraryLoader: (seed) => autoplayLoader(seed), + ); }); tearDown(() async { @@ -156,11 +162,158 @@ void main() { ); analyzer.pending?.complete(null); setAutoMixEnabled(false); + setAutoplayEnabled(false); await Future.delayed(const Duration(milliseconds: 30)); expect(native.live, isEmpty); expect(native.playing, isEmpty); }); + test( + 'Autoplay fills a single-track queue and follows its visible order', + () async { + setAutoplayEnabled(true); + await handler.setQueueAndPlay([_tracks.first]); + await _until(() => handler.queue.value.length == 3); + final planned = handler.queue.value.toList(); + expect(planned.first.id, 'one'); + expect( + planned.skip(1).every((item) => item.extras?['autoplay'] == true), + isTrue, + ); + await native.event('music-player', 'audio.onComplete'); + await _until(() => handler.mediaItem.value?.id == planned[1].id); + expect(native.sources['music-player'], planned[1].extras?['source']); + setAutoplayEnabled(false); + expect(handler.queue.value.map((item) => item.id), [ + 'one', + planned[1].id, + ]); + expect(handler.playbackState.value.playing, isTrue); + }, + ); + + test( + 'manual additions precede Autoplay and survive disabling or repeat', + () async { + setAutoplayEnabled(true); + await handler.setQueueAndPlay([_tracks.first]); + await _until(() => handler.queue.value.length == 3); + const manual = PlayableMedia( + id: 'manual', + source: '/manual.flac', + title: 'Manual', + artist: 'Other', + ); + await handler.enqueue(manual); + expect(handler.queue.value[1].id, 'manual'); + await handler.setShuffleMode(AudioServiceShuffleMode.all); + expect(handler.queue.value[1].id, 'manual'); + await handler.setShuffleMode(AudioServiceShuffleMode.none); + expect(handler.queue.value[1].id, 'manual'); + await handler.setRepeatMode(AudioServiceRepeatMode.all); + expect(handler.queue.value.map((item) => item.id), ['one', 'manual']); + await handler.setRepeatMode(AudioServiceRepeatMode.none); + await _until(() => handler.queue.value.length == 4); + setAutoplayEnabled(false); + expect(handler.queue.value.map((item) => item.id), ['one', 'manual']); + }, + ); + + test( + 'pending Autoplay is discarded after disabling or replacing the queue', + () async { + final pending = Completer>(); + autoplayLoader = (_) => pending.future; + setAutoplayEnabled(true); + await handler.setQueueAndPlay([_tracks.first]); + setAutoplayEnabled(false); + await handler.setQueueAndPlay([_tracks.last]); + pending.complete(_tracks); + await Future.delayed(const Duration(milliseconds: 50)); + expect(handler.queue.value.map((item) => item.id), ['three']); + expect(native.sources['music-player'], '/three.flac'); + }, + ); + + test('pause during an Autoplay refill prevents automatic resume', () async { + final pending = Completer>(); + autoplayLoader = (_) => pending.future; + setAutoplayEnabled(true); + await handler.setQueueAndPlay([_tracks.first]); + await native.event('music-player', 'audio.onComplete'); + await handler.pause(); + pending.complete(_tracks); + await Future.delayed(const Duration(milliseconds: 50)); + expect(handler.mediaItem.value?.id, 'one'); + expect(handler.playbackState.value.playing, isFalse); + expect(native.resumedSources, ['/one.flac']); + }); + + test( + 'replacing a queue invalidates an in-flight recommendation batch', + () async { + final pending = Completer>(); + autoplayLoader = (seed) => + seed.id == 'one' ? pending.future : Future.value([]); + setAutoplayEnabled(true); + await handler.setQueueAndPlay([_tracks.first]); + await handler.setQueueAndPlay([_tracks.last]); + pending.complete(_tracks); + await Future.delayed(const Duration(milliseconds: 50)); + expect(handler.queue.value.map((item) => item.id), ['three']); + expect(native.sources['music-player'], '/three.flac'); + }, + ); + + test( + 'duplicate completion while recommendations load advances only once', + () async { + final pending = Completer>(); + autoplayLoader = (_) => pending.future; + setAutoplayEnabled(true); + await handler.setQueueAndPlay([_tracks.first]); + await native.event('music-player', 'audio.onComplete'); + await native.event('music-player', 'audio.onComplete'); + pending.complete(_tracks); + await _until(() => handler.playbackState.value.queueIndex == 1); + await Future.delayed(const Duration(milliseconds: 50)); + expect(handler.playbackState.value.queueIndex, 1); + expect(handler.mediaItem.value?.id, handler.queue.value[1].id); + }, + ); + + test( + 'Autoplay never adds a song dismissed from the current queue again', + () async { + setAutoplayEnabled(true); + await handler.setQueueAndPlay([_tracks.first]); + await _until(() => handler.queue.value.length == 3); + final removed = handler.queue.value[1]; + handler.removeQueuedItem(removed); + await handler.skipToNext(); + await Future.delayed(const Duration(milliseconds: 50)); + expect(handler.queue.value.any((item) => item.id == removed.id), isFalse); + }, + ); + + test('empty Library ends playback and does not repeatedly refill', () async { + var loads = 0; + autoplayLoader = (_) async { + loads++; + return []; + }; + setAutoplayEnabled(true); + await handler.setQueueAndPlay([_tracks.first]); + await native.event('music-player', 'audio.onComplete'); + await _until( + () => + handler.playbackState.value.processingState == + AudioProcessingState.completed, + ); + expect(handler.queue.value, hasLength(1)); + expect(loads, 1); + }); + test( 'rapid Next keeps the final audible source and displayed title together', () async { diff --git a/test/music_player_autoplay_test.dart b/test/music_player_autoplay_test.dart new file mode 100644 index 00000000..2c45ace8 --- /dev/null +++ b/test/music_player_autoplay_test.dart @@ -0,0 +1,74 @@ +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/services/music_player_service.dart'; + +PlayableMedia track( + String id, { + String artist = 'Artist', + String? genre, + String? source, +}) => PlayableMedia( + id: id, + source: source ?? '/$id.flac', + title: id, + artist: artist, + genre: genre, +); + +void main() { + test('Autoplay prefers related unplayed Library tracks and deduplicates', () { + final seed = track('seed', genre: 'Rock'); + final related = track('related', genre: 'Rock'); + final recent = track('recent', genre: 'Rock'); + final selected = selectAutoplayTracks( + seed: seed, + candidates: [ + seed, + related, + related, + recent, + track('unrelated', artist: 'Other'), + track('queued'), + track('dismissed'), + track('remote', source: 'https://example.com/audio.flac'), + track('related', source: '/copy.flac', genre: 'Rock'), + ], + upcoming: [track('queued')], + recent: [recent], + dismissedSources: {'/dismissed.flac'}, + random: Random(1), + ); + expect(selected.map((item) => item.id), ['related', 'unrelated', 'recent']); + }); + + test('a small Library never recommends the current song to itself', () { + final seed = track('only'); + expect( + selectAutoplayTracks( + seed: seed, + candidates: [seed], + upcoming: [], + recent: [seed], + dismissedSources: {}, + random: Random(1), + ), + isEmpty, + ); + }); + + test('Autoplay preference and recommendation provenance survive restore', () { + expect(AppSettings.fromJson({}).autoplay, isFalse); + final settings = const AppSettings().copyWith(autoplay: true); + expect(AppSettings.fromJson(settings.toJson()).autoplay, isTrue); + final media = PlayableMedia.fromJson({ + ...track('one', genre: 'Rock').toJson(), + 'autoplay': true, + })!; + final restored = PlayableMedia.fromJson(media.toJson())!; + expect(restored.autoplay, isTrue); + expect(restored.genre, 'Rock'); + expect(restored.toMediaItem().extras?['autoplay'], isTrue); + }); +}