feat(player): continue playback with Library autoplay

This commit is contained in:
zarzet committed 2026-09-27 11:53:40 +07:00
1 parent 9e4f24ba24
commit 5f0463a7a4
14 files changed
+643 -12

No files matched your search

+254
View File
@@ -0,0 +1,254 @@
part of 'music_player_service.dart';
typedef AutoplayLibraryLoader =
Future<List<PlayableMedia>> 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<List<PlayableMedia>> _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 = <PlayableMedia>[];
for (final row in rows) {
final data = (row['item'] as Map).cast<String, dynamic>();
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<PlayableMedia> selectAutoplayTracks({
required PlayableMedia seed,
required List<PlayableMedia> candidates,
required List<PlayableMedia> upcoming,
required List<PlayableMedia> recent,
required Set<String> 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 = <PlayableMedia>[];
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<String> _dismissed = {};
Future<void>? _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<void> 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<void> _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');
}
}
}
+79 -8
View File
@@ -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 =
<AudioPlayer, List<StreamSubscription<dynamic>>>{};
@@ -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<MediaItem> 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 = <MediaItem>[];
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<void> _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<void> stop() async {
_autoplay.reset();
cancelSleepTimer();
final generation = ++_playRequestGeneration;
_switchingGeneration = 0;
@@ -1686,6 +1751,11 @@ class MusicPlayerHandler extends BaseAudioHandler
@override
Future<void> 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<void> dispose() async {
_autoplay.reset();
_disposed = true;
if (identical(_activeMusicPlayerHandler, this)) {
_activeMusicPlayerHandler = null;