mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
feat(player): continue playback with Library autoplay
This commit is contained in:
1 parent
9e4f24ba24
commit
5f0463a7a4
14 files changed
+643
-12
No files matched your search
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user