mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 04:42:02 +02:00
feat(player): continue playback with Library autoplay
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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<String, dynamic> _$AppSettingsToJson(
|
||||
'embedReplayGain': instance.embedReplayGain,
|
||||
'playbackNormalization': instance.playbackNormalization,
|
||||
'autoMix': instance.autoMix,
|
||||
'autoplay': instance.autoplay,
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
'checkForUpdates': instance.checkForUpdates,
|
||||
'updateChannel': instance.updateChannel,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -556,6 +556,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAutoplay(bool enabled) {
|
||||
state = state.copyWith(autoplay: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEmbedMetadata(bool enabled) {
|
||||
state = state.copyWith(embedMetadata: enabled);
|
||||
_saveSettings();
|
||||
|
||||
@@ -92,6 +92,10 @@ class _MainShellState extends ConsumerState<MainShell>
|
||||
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<MainShell>
|
||||
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),
|
||||
);
|
||||
|
||||
@@ -2356,6 +2356,9 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
|
||||
),
|
||||
);
|
||||
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<NowPlayingScreen> {
|
||||
),
|
||||
),
|
||||
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 =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<void>.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<List<PlayableMedia>>();
|
||||
autoplayLoader = (_) => pending.future;
|
||||
setAutoplayEnabled(true);
|
||||
await handler.setQueueAndPlay([_tracks.first]);
|
||||
setAutoplayEnabled(false);
|
||||
await handler.setQueueAndPlay([_tracks.last]);
|
||||
pending.complete(_tracks);
|
||||
await Future<void>.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<List<PlayableMedia>>();
|
||||
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<void>.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<List<PlayableMedia>>();
|
||||
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<void>.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<List<PlayableMedia>>();
|
||||
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<void>.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<void>.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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user