mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
255 lines
8.4 KiB
Dart
255 lines
8.4 KiB
Dart
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');
|
|
}
|
|
}
|
|
}
|