feat(autoplay): add opt-in on-device recommendation learning

This commit is contained in:
zarzet committed 2026-10-11 14:34:11 +07:00
1 parent e22e934525
commit d2dcf28622
15 files changed
+1357 -10

No files matched your search

+4
View File
@@ -7079,6 +7079,10 @@
"autoplayOn": "Autoplay on",
"autoplayOff": "Autoplay off",
"autoplayFromLibrary": "Autoplay from your Library",
"autoplayDescription": "Continue with related songs from your Library when the queue ends.",
"autoplayPersonalization": "Personalized autoplay",
"autoplayPersonalizationDescription": "Learn from songs you listen to and skip, privately on this device. Turning this off stops learning and restores ordinary autoplay.",
"autoplayResetLearning": "Reset recommendation learning",
"profileTitle": "Profile",
"profileSetUp": "Set up your profile",
"profileEdit": "Edit name and photo",
+4
View File
@@ -7152,6 +7152,10 @@
"autoplayOn": "Autoplay hidup",
"autoplayOff": "Autoplay mati",
"autoplayFromLibrary": "Autoplay dari Koleksi Anda",
"autoplayDescription": "Lanjutkan dengan lagu terkait dari Koleksi saat antrean berakhir.",
"autoplayPersonalization": "Autoplay personal",
"autoplayPersonalizationDescription": "Pelajari lagu yang kamu dengarkan dan lewati secara privat di perangkat ini. Mematikannya menghentikan pembelajaran dan mengembalikan autoplay biasa.",
"autoplayResetLearning": "Reset pembelajaran rekomendasi",
"profileTitle": "Profil",
"profileSetUp": "Atur profil Anda",
"profileEdit": "Edit nama dan foto",
+5
View File
@@ -62,6 +62,7 @@ class AppSettings {
final bool usbAllowFixedVolume;
final bool dapExclusive;
final bool autoplay;
final bool autoplayPersonalization;
final bool isFirstLaunch;
final bool checkForUpdates;
final String updateChannel;
@@ -194,6 +195,7 @@ class AppSettings {
this.usbAllowFixedVolume = false,
this.dapExclusive = false,
this.autoplay = false,
this.autoplayPersonalization = false,
this.isFirstLaunch = true,
this.checkForUpdates = true,
this.updateChannel = 'stable',
@@ -301,6 +303,7 @@ class AppSettings {
bool? usbAllowFixedVolume,
bool? dapExclusive,
bool? autoplay,
bool? autoplayPersonalization,
bool? isFirstLaunch,
bool? checkForUpdates,
String? updateChannel,
@@ -407,6 +410,8 @@ class AppSettings {
usbAllowFixedVolume: usbAllowFixedVolume ?? this.usbAllowFixedVolume,
dapExclusive: dapExclusive ?? this.dapExclusive,
autoplay: autoplay ?? this.autoplay,
autoplayPersonalization:
autoplayPersonalization ?? this.autoplayPersonalization,
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
updateChannel: updateChannel ?? this.updateChannel,
+2
View File
@@ -54,6 +54,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
usbAllowFixedVolume: json['usbAllowFixedVolume'] as bool? ?? false,
dapExclusive: json['dapExclusive'] as bool? ?? false,
autoplay: json['autoplay'] as bool? ?? false,
autoplayPersonalization: json['autoplayPersonalization'] as bool? ?? false,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
updateChannel: json['updateChannel'] as String? ?? 'stable',
@@ -174,6 +175,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'usbAllowFixedVolume': instance.usbAllowFixedVolume,
'dapExclusive': instance.dapExclusive,
'autoplay': instance.autoplay,
'autoplayPersonalization': instance.autoplayPersonalization,
'isFirstLaunch': instance.isFirstLaunch,
'checkForUpdates': instance.checkForUpdates,
'updateChannel': instance.updateChannel,
+5
View File
@@ -643,6 +643,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setAutoplayPersonalization(bool enabled) {
state = state.copyWith(autoplayPersonalization: enabled);
_saveSettings();
}
void setUsbBitPerfect(bool enabled) {
state = state.copyWith(usbBitPerfect: enabled);
_saveSettings();
@@ -5,6 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/services/music_playback_deck.dart';
import 'package:spotiflac_android/services/music_player_service.dart'
show resetAutoplayLearning;
import 'package:spotiflac_android/screens/listening_statistics_screen.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
@@ -123,6 +125,44 @@ class _PlaybackSettingsPageState extends ConsumerState<PlaybackSettingsPage> {
),
const SliverToBoxAdapter(child: AutoMixSettings()),
],
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.autoplayFromLibrary,
),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsSwitchItem(
icon: Icons.playlist_play_rounded,
title: context.l10n.autoplayFromLibrary,
subtitle: context.l10n.autoplayDescription,
value: settings.autoplay,
enabled: settings.playerMode == 'internal',
onChanged: (value) =>
ref.read(settingsProvider.notifier).setAutoplay(value),
),
SettingsSwitchItem(
icon: Icons.auto_awesome_outlined,
title: context.l10n.autoplayPersonalization,
subtitle: context.l10n.autoplayPersonalizationDescription,
value: settings.autoplayPersonalization,
enabled: settings.playerMode == 'internal',
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setAutoplayPersonalization(value),
showDivider: settings.autoplayPersonalization,
),
if (settings.autoplayPersonalization)
SettingsItem(
icon: Icons.restart_alt,
title: context.l10n.autoplayResetLearning,
onTap: resetAutoplayLearning,
showDivider: false,
),
],
),
),
SliverToBoxAdapter(
child: SettingsSectionHeader(
title: context.l10n.playbackAutomation,
@@ -238,6 +238,18 @@ class SettingsSearchCatalog {
),
],
playback = [
SettingsSearchEntry(
icon: Icons.playlist_play_rounded,
title: l10n.autoplayFromLibrary,
subtitle: l10n.autoplayDescription,
keywords: const ['autoplay', 'queue', 'recommendation'],
),
SettingsSearchEntry(
icon: Icons.auto_awesome_outlined,
title: l10n.autoplayPersonalization,
subtitle: l10n.autoplayPersonalizationDescription,
keywords: const ['machine learning', 'recommendation', 'personal'],
),
SettingsSearchEntry(
icon: Icons.insights_outlined,
title: l10n.listeningStats,
+430
View File
@@ -0,0 +1,430 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'dart:typed_data';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('AutoplayLearning');
const _modelKey = 'autoplay_learning_model_v1';
/// Metadata only: signed URLs, credentials, artwork and audio never enter the
/// model. Source is used by the live session, not persisted as a feature.
class AutoplayLearningTrack {
const AutoplayLearningTrack({
required this.source,
required this.title,
required this.artist,
this.album = '',
this.genre = '',
this.duration,
});
final String source, title, artist, album, genre;
final Duration? duration;
}
String _text(String value) {
final normalized = value.trim().toLowerCase();
return normalized.length > 160 ? normalized.substring(0, 160) : normalized;
}
/// Online logistic regression with AdaGrad and hashed context interactions.
/// Two fixed arrays occupy 64 KiB regardless of Library/listening-history size.
/// The prior ranker supplies cold-start relevance; this model learns engagement.
class AutoplayPreferenceModel {
static const int dimensions = 4096;
final Float64List _weights = Float64List(dimensions);
final Float64List _gradientSquares = Float64List(dimensions);
int _observations = 0;
DateTime? _updatedAt;
int get observations => _observations;
AutoplayPreferenceModel snapshot() {
final copy = AutoplayPreferenceModel();
copy._weights.setAll(0, _weights);
copy._observations = _observations;
return copy;
}
Map<int, double> _features(
AutoplayLearningTrack track,
AutoplayLearningTrack? previous,
DateTime at,
) {
final artist = _text(track.artist), album = _text(track.album);
final genres = track.genre
.split(RegExp(r'[,;/]'))
.map(_text)
.where((value) => value.isNotEmpty)
.take(3)
.toSet();
final previousArtist = _text(previous?.artist ?? '');
final previousGenre = _text(previous?.genre ?? '');
final period = at.hour ~/ 6;
final tokens = <String>[
if (artist.isNotEmpty) 'artist:$artist',
if (artist.isNotEmpty && album.isNotEmpty) 'album:$artist|$album',
if (track.title.trim().isNotEmpty) 'song:$artist|${_text(track.title)}',
for (final genre in genres) 'genre:$genre',
if (artist.isNotEmpty) 'time:$period|artist:$artist',
for (final genre in genres) 'time:$period|genre:$genre',
if (previousArtist.isNotEmpty && artist.isNotEmpty)
'transition:$previousArtist|$artist',
for (final genre in genres)
if (previousGenre.isNotEmpty) 'transition:$previousGenre|$genre',
if (track.duration != null && track.duration! > Duration.zero)
'duration:${min(5, track.duration!.inSeconds ~/ 90)}',
];
final features = <int, double>{};
for (final token in tokens) {
// Stable FNV-1a, rather than String.hashCode (not stable across runtimes).
var hash = 2166136261;
for (final unit in token.codeUnits) {
hash = ((hash ^ unit) * 16777619) & 0xffffffff;
}
final index = hash & (dimensions - 1);
final sign = (hash & 0x80000000) == 0 ? 1.0 : -1.0;
features[index] = (features[index] ?? 0) + sign;
}
final norm = sqrt(features.values.fold(0.0, (sum, v) => sum + v * v));
if (norm > 0) features.updateAll((_, value) => value / norm);
return features;
}
double _probability(Map<int, double> features) {
var logit = 0.0;
for (final entry in features.entries) {
logit += _weights[entry.key] * entry.value;
}
return 1 / (1 + exp(-logit.clamp(-20.0, 20.0)));
}
double preference(
AutoplayLearningTrack track, {
AutoplayLearningTrack? previous,
required DateTime at,
}) => _probability(_features(track, previous, at));
double rankAdjustment(
AutoplayLearningTrack track, {
required AutoplayLearningTrack previous,
required DateTime at,
}) =>
20 *
min(1.0, _observations / 20) *
(preference(track, previous: previous, at: at) - 0.5);
void learn(
AutoplayLearningTrack track, {
AutoplayLearningTrack? previous,
required DateTime at,
required double reward,
}) {
if (!reward.isFinite) return;
final features = _features(track, previous, at);
if (features.isEmpty) return;
final last = _updatedAt;
if (last != null) {
final days = at.difference(last).inDays;
if (days > 0) {
final decay = pow(0.98, min(90, days)).toDouble();
for (var i = 0; i < dimensions; i++) {
_weights[i] *= decay;
_gradientSquares[i] *= decay;
}
}
}
final error = _probability(features) - reward.clamp(0.0, 1.0);
for (final entry in features.entries) {
final i = entry.key;
final gradient = error * entry.value + 0.001 * _weights[i];
_gradientSquares[i] += gradient * gradient;
_weights[i] =
(_weights[i] - 0.18 * gradient / sqrt(_gradientSquares[i] + 0.01))
.clamp(-8.0, 8.0);
}
_observations = min(1000000, _observations + 1);
if (last == null || at.isAfter(last)) _updatedAt = at;
}
String encode() {
final data = ByteData(dimensions * 16);
for (var i = 0; i < dimensions; i++) {
data.setFloat64(i * 16, _weights[i], Endian.little);
data.setFloat64(i * 16 + 8, _gradientSquares[i], Endian.little);
}
return jsonEncode({
'version': 1,
'observations': _observations,
'updatedAt': _updatedAt?.millisecondsSinceEpoch,
'parameters': base64Encode(data.buffer.asUint8List()),
});
}
static AutoplayPreferenceModel decode(String? raw) {
final empty = AutoplayPreferenceModel();
if (raw == null || raw.length > 100000) return empty;
try {
final decoded = jsonDecode(raw);
if (decoded is! Map || decoded['version'] != 1) return empty;
final count = decoded['observations'];
final parameters = decoded['parameters'];
final updated = decoded['updatedAt'];
if (count is! int ||
count < 0 ||
count > 1000000 ||
parameters is! String ||
(updated != null && updated is! int)) {
return empty;
}
final bytes = base64Decode(parameters);
if (bytes.length != dimensions * 16) return empty;
final data = ByteData.sublistView(bytes);
final model = AutoplayPreferenceModel();
for (var i = 0; i < dimensions; i++) {
final weight = data.getFloat64(i * 16, Endian.little);
final square = data.getFloat64(i * 16 + 8, Endian.little);
if (!weight.isFinite ||
weight.abs() > 8 ||
!square.isFinite ||
square < 0 ||
square > 1e9) {
return empty;
}
model._weights[i] = weight;
model._gradientSquares[i] = square;
}
model._observations = count;
if (updated is int) {
model._updatedAt = DateTime.fromMillisecondsSinceEpoch(updated);
}
return model;
} catch (_) {
// Invalid/older snapshots are a cold start, never a playback failure.
return empty;
}
}
}
class _LearningSession {
_LearningSession(this.track, this.previous, this.at);
AutoplayLearningTrack track;
final AutoplayLearningTrack? previous;
final DateTime at;
Duration listened = Duration.zero;
Duration? lastElapsed;
bool playing = false;
}
/// Independent of listening-statistics opt-in. This explicitly enabled feature
/// records actual audible elapsed time, not seek position or paused wall time.
class AutoplayPersonalization {
AutoplayPersonalization({
Future<String?> Function()? load,
Future<void> Function(String)? save,
Duration Function()? elapsed,
DateTime Function()? now,
}) : _load = load ?? _loadPreferences,
_save = save ?? _savePreferences,
_elapsed = elapsed ?? (Stopwatch()..start()).elapsedGetter,
_now = now ?? DateTime.now;
final Future<String?> Function() _load;
final Future<void> Function(String) _save;
final Duration Function() _elapsed;
final DateTime Function() _now;
AutoplayPreferenceModel _model = AutoplayPreferenceModel();
Future<void>? _loading;
Future<void> _writing = Future<void>.value();
bool _loaded = false, _enabled = false, _disposed = false, _dirty = false;
Timer? _saveTimer;
_LearningSession? _session;
AutoplayLearningTrack? _previous;
final _pending = <(_LearningSession, double)>[];
int get observations => _model.observations;
AutoplayPreferenceModel? get rankingSnapshot =>
_enabled && _loaded && !_disposed && _model.observations > 0
? _model.snapshot()
: null;
static Future<String?> _loadPreferences() async =>
(await SharedPreferences.getInstance()).getString(_modelKey);
static Future<void> _savePreferences(String value) async {
if (!await (await SharedPreferences.getInstance()).setString(
_modelKey,
value,
)) {
throw StateError('Could not save autoplay learning');
}
}
Future<void> ensureLoaded() => _loading ??= () async {
try {
_model = AutoplayPreferenceModel.decode(await _load());
} catch (error) {
_log.w('Learning unavailable; using ordinary autoplay: $error');
}
_loaded = true;
// Feedback completed before disposal still belongs in the saved model.
// Opt-out clears this buffer and prevents its application.
if (_enabled) {
for (final (session, reward) in _pending) {
_learn(session, reward);
}
}
_pending.clear();
}();
void setEnabled(bool enabled) {
if (_disposed || _enabled == enabled) return;
_enabled = enabled;
_session = null;
_previous = null;
_pending.clear();
if (enabled) {
unawaited(ensureLoaded());
} else {
unawaited(flush());
}
}
void begin(AutoplayLearningTrack track) {
if (!_enabled || _disposed) return;
finish();
_session = _LearningSession(track, _previous, _now());
_session!.lastElapsed = _elapsed();
_previous = track;
}
void updatePlayback({
required String? source,
required bool playing,
Duration? duration,
}) {
checkpoint();
final session = _session;
if (session == null) return;
if (session.track.source != source) {
session.playing = false;
return;
}
if (duration != null && duration > Duration.zero) {
final track = session.track;
session.track = AutoplayLearningTrack(
source: track.source,
title: track.title,
artist: track.artist,
album: track.album,
genre: track.genre,
duration: duration,
);
}
session.playing = playing;
}
void checkpoint() {
final session = _session;
if (session == null) return;
final elapsed = _elapsed(), last = session.lastElapsed;
session.lastElapsed = elapsed;
if (session.playing && last != null && elapsed > last) {
session.listened += elapsed - last;
}
}
void finish({bool skipped = false}) {
checkpoint();
final session = _session;
_session = null;
if (session == null || !_enabled || _disposed) return;
final seconds = session.listened.inMilliseconds / 1000;
// Rapid queue taps and loading failures carry no preference signal.
if (seconds < 3) return;
final duration = session.track.duration?.inMilliseconds;
final fraction = duration != null && duration > 0
? session.listened.inMilliseconds / duration
: null;
double? reward;
if (skipped && seconds < 30 && (fraction == null || fraction < 0.25)) {
reward = 0;
} else if (fraction != null && fraction >= 0.5) {
reward = (0.5 + fraction.clamp(0.0, 1.0) / 2).clamp(0.0, 1.0);
} else if (fraction == null && seconds >= 120) {
reward = 0.75;
}
if (reward == null) return;
if (_loaded) {
_learn(session, reward);
} else {
if (_pending.length == 32) _pending.removeAt(0);
_pending.add((session, reward));
}
}
void _learn(_LearningSession session, double reward) {
_model.learn(
session.track,
previous: session.previous,
at: session.at,
reward: reward,
);
_dirty = true;
if (!_disposed) {
_saveTimer ??= Timer(
const Duration(seconds: 30),
() => unawaited(flush()),
);
}
}
double adjustment(
AutoplayLearningTrack track,
AutoplayLearningTrack seed,
DateTime at,
) => !_enabled || !_loaded || _disposed
? 0
: _model.rankAdjustment(track, previous: seed, at: at);
Future<void> flush() {
_saveTimer?.cancel();
_saveTimer = null;
if (!_dirty || !_loaded) return _writing;
final snapshot = _model.encode();
_dirty = false;
return _writing = _writing.then((_) async {
try {
await _save(snapshot);
} catch (error) {
_dirty = true;
_log.w('Could not persist autoplay learning: $error');
}
});
}
Future<void> reset() async {
_session = null;
_previous = null;
_pending.clear();
await ensureLoaded();
_model = AutoplayPreferenceModel();
_dirty = true;
await flush();
}
Future<void> dispose() async {
finish();
_disposed = true;
await _loading;
await flush();
}
}
extension on Stopwatch {
Duration elapsedGetter() => elapsed;
}
+1
View File
@@ -281,6 +281,7 @@ class _MusicAutoMix {
handler._activeResolvedPath = next.isContentUri ? _nextPath : null;
handler._pendingRestorePosition = null;
rate = plan.rate;
handler._runtime.personalization.begin(_learningTrack(next));
handler.mediaItem.add(
handler
._toMediaItem(
+105 -6
View File
@@ -3,6 +3,25 @@ part of 'music_player_service.dart';
typedef AutoplayLibraryLoader =
Future<List<PlayableMedia>> Function(PlayableMedia seed);
Future<void> resetAutoplayLearning({MusicPlayerRuntime? runtime}) async {
final target = runtime ?? musicPlayerRuntime;
await target.personalization.reset();
final handler = target.handler;
if (handler == null || handler.isDisposed) return;
handler._configurePersonalization();
handler._autoplay.updateMode();
}
AutoplayLearningTrack _learningTrack(PlayableMedia media) =>
AutoplayLearningTrack(
source: media.source,
title: media.title,
artist: media.artist,
album: media.album,
genre: media.genre ?? '',
duration: media.duration,
);
void setAutoplayEnabled(bool enabled, {bool includeLocal = true}) =>
musicPlayerRuntime.configure(
musicPlayerRuntime.settings.copyWith(
@@ -77,7 +96,7 @@ Future<List<PlayableMedia>> _loadAutoplayLibrary(
/// 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.
/// priority. Optional local learning adjusts this prior without audio analysis.
List<PlayableMedia> selectAutoplayTracks({
required PlayableMedia seed,
required List<PlayableMedia> candidates,
@@ -86,6 +105,7 @@ List<PlayableMedia> selectAutoplayTracks({
required Set<String> dismissedSources,
required Random random,
int limit = 6,
double Function(PlayableMedia)? preferenceAdjustment,
}) {
String normalized(String? value) => normalizeLookupText(value);
String songKey(PlayableMedia item) =>
@@ -128,6 +148,7 @@ List<PlayableMedia> selectAutoplayTracks({
if (seedGenre.isNotEmpty && seedGenre == normalized(item.genre)) {
score += 4;
}
if (preferenceAdjustment != null) score += preferenceAdjustment(item);
// Match the latest source OR normalized song occurrence without walking
// the entire listening history again for every candidate.
final played = max(
@@ -232,18 +253,54 @@ class _MusicAutoplay {
final current = handler._media[handler._index];
final upcoming = handler._media.skip(handler._index + 1).toList();
if (upcoming.length >= 3) return;
final selected = selectAutoplayTracks(
final recent = handler._playHistory
.where((i) => i >= 0 && i < handler._media.length)
.map((i) => handler._media[i])
.toList();
final model = handler._runtime.personalization.rankingSnapshot;
final revision = handler._sessionQueueRevision;
List<PlayableMedia> ordinaryRanking() => selectAutoplayTracks(
seed: current,
candidates: candidates,
upcoming: upcoming,
recent: handler._playHistory
.where((i) => i >= 0 && i < handler._media.length)
.map((i) => handler._media[i])
.toList(),
recent: recent,
dismissedSources: _dismissed,
random: handler._random,
limit: 6 - upcoming.length,
);
List<PlayableMedia> selected;
if (model == null) {
selected = ordinaryRanking();
} else {
try {
selected = await compute(_rankPersonalizedAutoplay, (
current,
candidates,
upcoming,
recent,
Set<String>.of(_dismissed),
model,
handler._random.nextInt(1 << 31),
DateTime.now(),
6 - upcoming.length,
), debugLabel: 'autoplay-ranking');
} catch (error) {
_log.w(
'Personalized ranking unavailable; using ordinary autoplay: $error',
);
selected = ordinaryRanking();
}
}
// The worker must not publish suggestions for an obsolete queue/seed.
if (!enabled ||
handler._disposed ||
generation != _generation ||
revision != handler._sessionQueueRevision ||
handler._index < 0 ||
handler._index >= handler._media.length ||
handler._media[handler._index].source != current.source) {
return;
}
if (selected.isEmpty) return;
final additions = [
for (final item in selected)
@@ -263,3 +320,45 @@ class _MusicAutoplay {
}
}
}
typedef _AutoplayRankingInput = (
PlayableMedia,
List<PlayableMedia>,
List<PlayableMedia>,
List<PlayableMedia>,
Set<String>,
AutoplayPreferenceModel,
int,
DateTime,
int,
);
/// Pass metadata and a frozen 64 KiB model only, never the player/runtime.
List<PlayableMedia> _rankPersonalizedAutoplay(_AutoplayRankingInput input) {
final (
seed,
candidates,
upcoming,
recent,
dismissed,
model,
randomSeed,
at,
limit,
) = input;
final learningSeed = _learningTrack(seed);
return selectAutoplayTracks(
seed: seed,
candidates: candidates,
upcoming: upcoming,
recent: recent,
dismissedSources: dismissed,
random: Random(randomSeed),
limit: limit,
preferenceAdjustment: (track) => model.rankAdjustment(
_learningTrack(track),
previous: learningSeed,
at: at,
),
);
}
+7
View File
@@ -6,6 +6,7 @@ import 'package:audio_session/audio_session.dart';
import 'package:flutter/services.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/app_state_database.dart';
import 'package:spotiflac_android/services/autoplay_personalization.dart';
import 'package:spotiflac_android/services/discord_presence_service.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/listening_statistics.dart';
@@ -32,6 +33,7 @@ class PlaybackDependencies {
this.readNetworkMetadata = _readNetworkMetadata,
this.resolveNetworkSource = _resolveNetworkSource,
this.initializeHandler = _initializeAudioHandler,
this.createPersonalization = AutoplayPersonalization.new,
AppStateDatabase? sessionDatabase,
LibraryDatabase? libraryDatabase,
ListeningRecorder? recorder,
@@ -59,6 +61,7 @@ class PlaybackDependencies {
final Future<MusicPlayerHandler> Function(MusicPlayerHandler Function())
initializeHandler;
final MethodChannel notificationChannel;
final AutoplayPersonalization Function() createPersonalization;
final AppStateDatabase? _sessionDatabase;
final LibraryDatabase? _libraryDatabase;
final ListeningRecorder? _recorder;
@@ -107,6 +110,9 @@ class MusicPlayerRuntime {
}) : _settings = settings;
final PlaybackDependencies dependencies;
AutoplayPersonalization? _personalization;
AutoplayPersonalization get personalization =>
_personalization ??= dependencies.createPersonalization();
AppSettings _settings;
MusicPlayerHandler? _handler;
void Function()? _unsubscribeSourceDeletion;
@@ -273,6 +279,7 @@ class MusicPlayerRuntime {
} catch (_) {
// Initialization may have failed because its pending handler was closed.
}
await _personalization?.dispose();
await _handlers.close();
await _mutedPlaybackEvents.close();
}
+59 -4
View File
@@ -4,6 +4,7 @@ import 'dart:math';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart' show compute;
import 'package:flutter/widgets.dart'
show AppLifecycleState, WidgetsBinding, WidgetsBindingObserver;
import 'package:spotiflac_android/services/playback_notification.dart';
@@ -28,6 +29,7 @@ import 'package:spotiflac_android/services/content_uri_playback_cache.dart';
import 'package:spotiflac_android/services/music_player_runtime.dart';
import 'package:spotiflac_android/services/music_playback_deck.dart';
import 'package:spotiflac_android/services/automix_analysis.dart';
import 'package:spotiflac_android/services/autoplay_personalization.dart';
import 'package:spotiflac_android/services/automix_analyzer.dart';
import 'package:spotiflac_android/models/automix_options.dart';
import 'package:spotiflac_android/models/settings.dart';
@@ -361,6 +363,10 @@ class MusicPlayerHandler extends BaseAudioHandler
/// Reconcile only the playback fields affected by the new settings snapshot.
void applySettings(AppSettings previous) {
final settings = _settings;
if (previous.autoplayPersonalization != settings.autoplayPersonalization ||
previous.playerMode != settings.playerMode) {
_configurePersonalization();
}
if (previous.playbackNormalization != settings.playbackNormalization) {
reapplyNormalization();
}
@@ -379,6 +385,7 @@ class MusicPlayerHandler extends BaseAudioHandler
_refreshPlaybackAutomation();
}
if (previous.autoplay != settings.autoplay ||
previous.autoplayPersonalization != settings.autoplayPersonalization ||
previous.localLibraryEnabled != settings.localLibraryEnabled) {
_autoplay.updateMode();
}
@@ -456,9 +463,17 @@ class MusicPlayerHandler extends BaseAudioHandler
void _init() {
if (_initialized) return;
_initialized = true;
_configurePersonalization();
void recordListening() {
final item = mediaItem.value;
final state = playbackState.value;
_runtime.personalization.updatePlayback(
source: item?.extras?['source'] as String?,
duration: item?.duration,
playing:
state.playing &&
state.processingState == AudioProcessingState.ready,
);
_dependencies.recorder.update(
item == null
? null
@@ -511,6 +526,10 @@ class MusicPlayerHandler extends BaseAudioHandler
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.inactive ||
state == AppLifecycleState.paused) {
unawaited(_runtime.personalization.flush());
}
if (state == AppLifecycleState.resumed ||
state == AppLifecycleState.inactive ||
state == AppLifecycleState.paused) {
@@ -518,6 +537,23 @@ class MusicPlayerHandler extends BaseAudioHandler
}
}
void _configurePersonalization() {
final enabled =
_settings.autoplayPersonalization && _settings.playerMode == 'internal';
_runtime.personalization.setEnabled(enabled);
if (enabled && _index >= 0 && _index < _media.length) {
_runtime.personalization.begin(_learningTrack(_media[_index]));
final state = playbackState.value;
_runtime.personalization.updatePlayback(
source: mediaItem.value?.extras?['source'] as String?,
duration: mediaItem.value?.duration,
playing:
state.playing &&
state.processingState == AudioProcessingState.ready,
);
}
}
/// Recover system controls without reloading, seeking or restarting audio.
/// Never reclaim audio focus for a paused/interrupted/stopped player.
Future<void> restoreIosNowPlaying() async {
@@ -1359,9 +1395,15 @@ class MusicPlayerHandler extends BaseAudioHandler
Future<void> _playIndex(
int index, {
bool recordHistory = true,
bool userSkip = false,
Duration startPosition = Duration.zero,
}) async {
if (_disposed || index < 0 || index >= _media.length) return;
_runtime.personalization.finish(
skipped:
userSkip &&
mediaItem.value?.extras?['source'] != _media[index].source,
);
_playbackAutomation.cancelPendingActions();
// A normal track change supersedes a pending restore. A restored start
// keeps it until the source is actually ready so a transient failure can
@@ -1456,6 +1498,7 @@ class MusicPlayerHandler extends BaseAudioHandler
startPosition,
duration: media.duration,
);
_runtime.personalization.begin(_learningTrack(media));
mediaItem.add(_toMediaItem(media));
_lastBroadcastPosition = Duration.zero;
_lastPositionBroadcastAt = null;
@@ -1765,6 +1808,7 @@ class MusicPlayerHandler extends BaseAudioHandler
_completing = true;
try {
_runtime.personalization.finish();
await _onComplete();
} finally {
_completing = false;
@@ -1865,6 +1909,11 @@ class MusicPlayerHandler extends BaseAudioHandler
}
Future<void> _pausePlayback() async {
_runtime.personalization.updatePlayback(
source: mediaItem.value?.extras?['source'] as String?,
playing: false,
);
unawaited(_runtime.personalization.flush());
final generation = ++_playRequestGeneration;
_switchingGeneration = 0;
_userPaused = true;
@@ -1927,6 +1976,8 @@ class MusicPlayerHandler extends BaseAudioHandler
@override
Future<void> stop() async {
_runtime.personalization.finish();
unawaited(_runtime.personalization.flush());
_playbackAutomation.cancelPendingActions();
_autoplay.reset();
cancelSleepTimer();
@@ -1967,7 +2018,9 @@ class MusicPlayerHandler extends BaseAudioHandler
await _autoplay.fill();
if (_disposed || generation != _playRequestGeneration) return;
}
if (_index < _media.length - 1) await _playIndex(_index + 1);
if (_index < _media.length - 1) {
await _playIndex(_index + 1, userSkip: true);
}
}
@override
@@ -1982,18 +2035,18 @@ class MusicPlayerHandler extends BaseAudioHandler
if (_playHistory.length >= 2) {
_playHistory.removeLast();
final prev = _playHistory.last;
await _playIndex(prev, recordHistory: false);
await _playIndex(prev, recordHistory: false, userSkip: true);
} else {
await _player.seek(Duration.zero);
_broadcastPosition(Duration.zero, force: true);
}
return;
}
if (_index > 0) await _playIndex(_index - 1);
if (_index > 0) await _playIndex(_index - 1, userSkip: true);
}
@override
Future<void> skipToQueueItem(int index) => _playIndex(index);
Future<void> skipToQueueItem(int index) => _playIndex(index, userSkip: true);
@override
Future<List<MediaItem>> getChildren(
@@ -2095,6 +2148,8 @@ class MusicPlayerHandler extends BaseAudioHandler
Future<void> _dispose() async {
_autoplay.reset();
_disposed = true;
_runtime.personalization.finish();
await _runtime.personalization.flush();
if (_dependencies.isIOS) WidgetsBinding.instance.removeObserver(this);
if (identical(musicPlayerRuntime.handler, this)) {
autoMixStatus.value = AutoMixStatus.idle;
+426
View File
@@ -0,0 +1,426 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/foundation.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/services/autoplay_personalization.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
final _at = DateTime(2026, 10, 11, 9);
const _rock = AutoplayLearningTrack(
source: '/rock.flac',
title: 'Rock',
artist: 'Rock Artist',
genre: 'Rock',
duration: Duration(seconds: 120),
);
const _jazz = AutoplayLearningTrack(
source: '/jazz.flac',
title: 'Jazz',
artist: 'Jazz Artist',
genre: 'Jazz',
duration: Duration(seconds: 120),
);
void main() {
test(
'disposal persists completed feedback even while initial loading is pending',
() async {
var elapsed = Duration.zero;
final gate = Completer<String?>();
String? saved;
final service = AutoplayPersonalization(
load: () => gate.future,
save: (value) async => saved = value,
elapsed: () => elapsed,
now: () => _at,
);
service.setEnabled(true);
service.begin(_rock);
service.updatePlayback(source: _rock.source, playing: true);
elapsed += const Duration(seconds: 10);
service.finish(skipped: true);
final disposed = service.dispose();
gate.complete(null);
await disposed;
expect(AutoplayPreferenceModel.decode(saved).observations, 1);
},
);
test('bounded 384-candidate ranking stress and snapshot isolation', () {
final model = AutoplayPreferenceModel();
for (var i = 0; i < 60; i++) {
model.learn(_jazz, previous: _rock, at: _at, reward: 1);
}
final snapshot = model.snapshot();
final before = snapshot.preference(_jazz, previous: _rock, at: _at);
for (var i = 0; i < 20; i++) {
model.learn(_jazz, previous: _rock, at: _at, reward: 0);
}
expect(snapshot.preference(_jazz, previous: _rock, at: _at), before);
final candidates = List.generate(
384,
(i) => PlayableMedia(
id: '$i',
source: '/$i.flac',
title: 'Track $i',
artist: 'Artist ${i % 24}',
album: 'Album ${i % 50}',
genre: i.isEven ? 'Jazz' : 'Rock',
duration: const Duration(seconds: 180),
),
);
const seed = PlayableMedia(
id: 'seed',
source: '/seed.flac',
title: 'Seed',
artist: 'Seed',
);
final durations = <int>[];
for (var i = 0; i < 40; i++) {
final clock = Stopwatch()..start();
final selected = selectAutoplayTracks(
seed: seed,
candidates: candidates,
upcoming: [],
recent: [],
dismissedSources: {},
random: Random(i),
preferenceAdjustment: (track) => snapshot.rankAdjustment(
AutoplayLearningTrack(
source: track.source,
title: track.title,
artist: track.artist,
album: track.album,
genre: track.genre ?? '',
duration: track.duration,
),
previous: _rock,
at: _at,
),
);
clock.stop();
expect(selected, hasLength(6));
expect(selected.map((track) => track.source).toSet(), hasLength(6));
if (i >= 10) durations.add(clock.elapsedMicroseconds);
}
durations.sort();
debugPrint(
'Autoplay host ranking: 384 candidates, median '
'${durations[durations.length ~/ 2]} us, p95 '
'${durations[(durations.length * .95).floor()]} us; model parameters 65536 bytes.',
);
});
test(
'cold start is neutral and personalization is an independent opt-in',
() {
final model = AutoplayPreferenceModel();
expect(model.preference(_rock, at: _at), 0.5);
expect(model.rankAdjustment(_rock, previous: _jazz, at: _at), 0);
expect(AppSettings.fromJson({}).autoplayPersonalization, isFalse);
final settings = const AppSettings().copyWith(
autoplayPersonalization: true,
);
final restored = AppSettings.fromJson(settings.toJson());
expect(restored.autoplayPersonalization, isTrue);
expect(restored.autoplay, isFalse);
},
);
test('learning favors engagement, transfers genre and handles context', () {
final model = AutoplayPreferenceModel();
for (var i = 0; i < 100; i++) {
model.learn(_jazz, previous: _rock, at: _at, reward: 1);
model.learn(_rock, previous: _rock, at: _at, reward: 0);
}
expect(model.preference(_jazz, previous: _rock, at: _at), greaterThan(0.8));
expect(model.preference(_rock, previous: _rock, at: _at), lessThan(0.2));
const unseenJazz = AutoplayLearningTrack(
source: '/new',
title: 'New',
artist: 'Unseen Artist',
genre: 'Jazz',
);
const unseenRock = AutoplayLearningTrack(
source: '/other',
title: 'Other',
artist: 'Different Artist',
genre: 'Rock',
);
expect(
model.preference(unseenJazz, previous: _rock, at: _at),
greaterThan(model.preference(unseenRock, previous: _rock, at: _at)),
);
expect(
model.preference(_jazz, previous: _rock, at: _at),
greaterThan(
model.preference(
_jazz,
previous: _rock,
at: _at.add(const Duration(hours: 12)),
),
),
);
});
test(
'learned rank changes recommendations but preserves queue exclusions',
() {
final model = AutoplayPreferenceModel();
for (var i = 0; i < 100; i++) {
model.learn(_jazz, previous: _rock, at: _at, reward: 1);
model.learn(_rock, previous: _rock, at: _at, reward: 0);
}
const seed = PlayableMedia(
id: 'seed',
source: '/seed',
title: 'Seed',
artist: 'Seed',
genre: 'Rock',
);
const rock = PlayableMedia(
id: 'rock',
source: '/rock.flac',
title: 'Rock',
artist: 'Rock Artist',
genre: 'Rock',
);
const jazz = PlayableMedia(
id: 'jazz',
source: '/jazz.flac',
title: 'Jazz',
artist: 'Jazz Artist',
genre: 'Jazz',
);
List<PlayableMedia> rank({
bool personalized = false,
Set<String> dismissed = const {},
}) => selectAutoplayTracks(
seed: seed,
candidates: [rock, jazz, jazz, seed],
upcoming: [],
recent: [],
dismissedSources: dismissed,
random: Random(1),
preferenceAdjustment: personalized
? (track) => model.rankAdjustment(
track.id == 'rock' ? _rock : _jazz,
previous: _rock,
at: _at,
)
: null,
);
expect(rank().first.id, 'rock');
expect(rank(personalized: true).first.id, 'jazz');
expect(rank(personalized: true), hasLength(2));
expect(
rank(personalized: true, dismissed: {'/jazz.flac'}).single.id,
'rock',
);
},
);
test(
'snapshot is bounded, stable, contains no metadata, rejects bad input',
() {
final model = AutoplayPreferenceModel();
model.learn(_jazz, previous: _rock, at: _at, reward: 1);
final raw = model.encode();
final restored = AutoplayPreferenceModel.decode(raw);
expect(raw.length, lessThan(100000));
expect(raw, isNot(contains('/jazz.flac')));
expect(raw, isNot(contains('Jazz Artist')));
expect(restored.observations, 1);
expect(
restored.preference(_jazz, previous: _rock, at: _at),
model.preference(_jazz, previous: _rock, at: _at),
);
for (final input in [
'garbage',
'{"version":2}',
'{"version":1}',
'{"version":1,"observations":-1,"parameters":""}',
]) {
expect(AutoplayPreferenceModel.decode(input).observations, 0);
}
final bad = jsonDecode(raw) as Map<String, dynamic>;
final bytes = base64Decode(bad['parameters'] as String);
ByteData.sublistView(bytes).setFloat64(0, double.nan, Endian.little);
bad['parameters'] = base64Encode(bytes);
expect(AutoplayPreferenceModel.decode(jsonEncode(bad)).observations, 0);
},
);
test(
'elapsed listening excludes pauses, loading, rapid taps and seeks',
() async {
var elapsed = Duration.zero;
final service = AutoplayPersonalization(
load: () async => null,
save: (_) async {},
elapsed: () => elapsed,
now: () => _at,
);
addTearDown(service.dispose);
service.setEnabled(true);
await service.ensureLoaded();
service.begin(_rock);
elapsed += const Duration(seconds: 60); // Loading, never playing.
service.updatePlayback(source: _rock.source, playing: true);
elapsed += const Duration(seconds: 2);
service.finish(skipped: true);
expect(service.observations, 0);
service.begin(_rock);
service.updatePlayback(source: _rock.source, playing: true);
elapsed += const Duration(seconds: 10);
service.updatePlayback(source: _rock.source, playing: false);
elapsed += const Duration(
hours: 2,
); // Paused wall time is not engagement.
service.updatePlayback(source: _rock.source, playing: true);
// Playback position is deliberately not an input: seeking to the end
// cannot manufacture completed listening.
service.finish(skipped: true);
expect(service.observations, 1);
expect(service.adjustment(_rock, _jazz, _at), lessThan(0));
service.finish(skipped: true);
expect(service.observations, 1);
},
);
test('duration updates and long listening yield positive feedback', () async {
var elapsed = Duration.zero;
final service = AutoplayPersonalization(
load: () async => null,
save: (_) async {},
elapsed: () => elapsed,
now: () => _at,
);
addTearDown(service.dispose);
service.setEnabled(true);
await service.ensureLoaded();
service.begin(
const AutoplayLearningTrack(
source: '/jazz.flac',
title: 'Jazz',
artist: 'Jazz Artist',
genre: 'Jazz',
),
);
service.updatePlayback(
source: _jazz.source,
playing: true,
duration: const Duration(seconds: 120),
);
elapsed += const Duration(seconds: 95);
service.finish();
expect(service.observations, 1);
expect(service.adjustment(_jazz, _rock, _at), greaterThan(0));
});
test(
'off performs no loading/training and disables a pending load',
() async {
var elapsed = Duration.zero;
var loads = 0;
final gate = Completer<String?>();
final service = AutoplayPersonalization(
load: () {
loads++;
return gate.future;
},
save: (_) async {},
elapsed: () => elapsed,
now: () => _at,
);
addTearDown(service.dispose);
service.begin(_rock);
expect(loads, 0);
service.setEnabled(true);
service.begin(_rock);
service.updatePlayback(source: _rock.source, playing: true);
elapsed += const Duration(seconds: 90);
service.finish();
service.setEnabled(false);
gate.complete(null);
await service.ensureLoaded();
expect(service.observations, 0);
expect(service.adjustment(_rock, _jazz, _at), 0);
},
);
test(
'ordered writes, reload, reset, and disabling preserve explicit control',
() async {
var elapsed = Duration.zero;
String? saved;
final firstSave = Completer<void>();
var saves = 0;
final service = AutoplayPersonalization(
load: () async => null,
save: (raw) async {
if (saves++ == 0) await firstSave.future;
saved = raw;
},
elapsed: () => elapsed,
now: () => _at,
);
addTearDown(service.dispose);
service.setEnabled(true);
await service.ensureLoaded();
service.begin(_rock);
service.updatePlayback(source: _rock.source, playing: true);
elapsed += const Duration(seconds: 15);
service.finish(skipped: true);
final write = service.flush();
final reset = service.reset();
firstSave.complete();
await Future.wait([write, reset]);
expect(AutoplayPreferenceModel.decode(saved).observations, 0);
expect(service.observations, 0);
service.begin(_jazz);
service.updatePlayback(source: _jazz.source, playing: true);
elapsed += const Duration(seconds: 100);
service.finish();
service.setEnabled(false);
await service.flush();
expect(service.adjustment(_jazz, _rock, _at), 0);
final next = AutoplayPersonalization(
load: () async => saved,
save: (_) async {},
);
addTearDown(next.dispose);
next.setEnabled(true);
await next.ensureLoaded();
expect(next.observations, 1);
},
);
test(
'failed persistence is retried without blocking recommendation',
() async {
var elapsed = Duration.zero, saves = 0;
String? saved;
final service = AutoplayPersonalization(
load: () async => null,
save: (raw) async {
if (saves++ == 0) throw StateError('Unavailable');
saved = raw;
},
elapsed: () => elapsed,
now: () => _at,
);
addTearDown(service.dispose);
service.setEnabled(true);
await service.ensureLoaded();
service.begin(_rock);
service.updatePlayback(source: _rock.source, playing: true);
elapsed += const Duration(seconds: 10);
service.finish(skipped: true);
await service.flush();
await service.flush();
expect(AutoplayPreferenceModel.decode(saved).observations, 1);
},
);
}
+95
View File
@@ -0,0 +1,95 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/theme_settings.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/settings/playback_settings_page.dart';
import 'package:spotiflac_android/screens/settings/settings_search_catalog.dart';
import 'package:spotiflac_android/theme/app_theme.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
void main() {
for (final mornye in [false, true]) {
testWidgets('personalization switch persists and is searchable ($mornye)', (
tester,
) async {
SharedPreferences.setMockInitialValues({});
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final container = ProviderContainer(
overrides: [
initialSettingsProvider.overrideWithValue(const AppSettings()),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
theme: mornye
? MornyeTheme.build(Brightness.dark, accent: MornyeAccent.blue)
: AppTheme.light(),
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const PlaybackSettingsPage(),
),
),
);
final label = find.text('Personalized autoplay');
await tester.scrollUntilVisible(
label,
200,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
expect(find.text('Reset recommendation learning'), findsNothing);
await tester.tap(label);
await tester.pumpAndSettle();
expect(container.read(settingsProvider).autoplayPersonalization, isTrue);
expect(container.read(settingsProvider).autoplay, isFalse);
final prefs = await SharedPreferences.getInstance();
expect(
AppSettings.fromJson(
jsonDecode(prefs.getString('app_settings')!) as Map<String, dynamic>,
).autoplayPersonalization,
isTrue,
);
expect(find.text('Reset recommendation learning'), findsOneWidget);
await tester.ensureVisible(label);
await tester.pumpAndSettle();
await tester.tap(label);
await tester.pumpAndSettle();
expect(container.read(settingsProvider).autoplayPersonalization, isFalse);
final l10n = AppLocalizations.of(tester.element(label));
expect(
SettingsSearchCatalog(l10n).playback.any(
(entry) => entry.matches(SettingsSearchQuery('machine learning')),
),
isTrue,
);
container.read(settingsProvider.notifier).setPlayerMode('external');
await tester.pump();
expect(
tester
.widget<SettingsSwitchItem>(
find.byWidgetPredicate(
(widget) =>
widget is SettingsSwitchItem &&
widget.title == 'Personalized autoplay',
),
)
.enabled,
isFalse,
);
expect(tester.takeException(), isNull);
});
}
}
+162
View File
@@ -11,6 +11,7 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/services/app_state_database.dart';
import 'package:spotiflac_android/services/autoplay_personalization.dart';
import 'package:spotiflac_android/services/listening_statistics.dart';
import 'package:spotiflac_android/services/music_playback_deck.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
@@ -181,6 +182,7 @@ MusicPlayerRuntime _runtime({
Stream<double> volumeChanges = const Stream.empty(),
SourceDeletionEvents? sourceDeletionEvents,
ListeningRecorder? recorder,
AutoplayPersonalization Function()? createPersonalization,
bool isIOS = false,
_Session? session,
}) {
@@ -189,6 +191,8 @@ MusicPlayerRuntime _runtime({
final runtime = MusicPlayerRuntime(
settings: settings,
dependencies: PlaybackDependencies(
createPersonalization:
createPersonalization ?? AutoplayPersonalization.new,
createDeck:
createDeck ??
(_) {
@@ -233,6 +237,164 @@ const _track = PlayableMedia(
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'trained personalized ranking runs through the real autoplay worker',
() async {
final model = AutoplayPreferenceModel();
const preferred = AutoplayLearningTrack(
source: '/jazz.flac',
title: 'Jazz',
artist: 'Jazz Artist',
genre: 'Jazz',
duration: Duration(seconds: 60),
);
const avoided = AutoplayLearningTrack(
source: '/rock.flac',
title: 'Rock',
artist: 'Rock Artist',
genre: 'Rock',
duration: Duration(seconds: 60),
);
const seed = AutoplayLearningTrack(
source: '/one.flac',
title: 'One',
artist: 'Artist',
);
for (var i = 0; i < 80; i++) {
model.learn(preferred, previous: seed, at: DateTime.now(), reward: 1);
model.learn(avoided, previous: seed, at: DateTime.now(), reward: 0);
}
final learning = AutoplayPersonalization(
load: () async => model.encode(),
save: (_) async {},
);
final runtime = _runtime(
settings: const AppSettings(
pauseOnMute: false,
autoplay: true,
autoplayPersonalization: true,
),
createPersonalization: () => learning,
);
final handler = MusicPlayerHandler(
runtime: runtime,
autoplayLibraryLoader: (_) async => [
const PlayableMedia(
id: 'rock',
source: '/rock.flac',
title: 'Rock',
artist: 'Rock Artist',
genre: 'Rock',
duration: Duration(seconds: 60),
),
const PlayableMedia(
id: 'jazz',
source: '/jazz.flac',
title: 'Jazz',
artist: 'Jazz Artist',
genre: 'Jazz',
duration: Duration(seconds: 60),
),
],
);
await learning.ensureLoaded();
await handler.setQueueAndPlay([_track]);
await handler.queue
.firstWhere((queue) => queue.length == 3)
.timeout(const Duration(seconds: 5));
expect(handler.queue.value.map((item) => item.id), [
'one',
'jazz',
'rock',
]);
expect(handler.playbackState.value.playing, isTrue);
await resetAutoplayLearning(runtime: runtime);
expect(learning.observations, 0);
await handler.queue
.firstWhere((queue) => queue.length == 3)
.timeout(const Duration(seconds: 5));
expect(handler.queue.value.first.id, 'one');
runtime.configure(
runtime.settings.copyWith(autoplayPersonalization: false),
);
await handler.queue
.firstWhere((queue) => queue.length == 3)
.timeout(const Duration(seconds: 5));
expect(handler.queue.value.first.id, 'one');
expect(handler.playbackState.value.playing, isTrue);
},
);
test(
'player learns explicit skips, excludes pauses and obeys live opt-out',
() async {
var elapsed = Duration.zero;
final learning = AutoplayPersonalization(
load: () async => null,
save: (_) async {},
elapsed: () => elapsed,
now: () => DateTime(2026),
);
final runtime = _runtime(
settings: const AppSettings(
pauseOnMute: false,
autoplayPersonalization: true,
),
createPersonalization: () => learning,
);
final handler = await runtime.initialize();
await learning.ensureLoaded();
const second = PlayableMedia(
id: 'two',
source: '/two.flac',
title: 'Two',
artist: 'Other',
duration: Duration(seconds: 60),
);
await handler.setQueueAndPlay([_track, second]);
await _settle();
elapsed += const Duration(seconds: 10);
await handler.pause();
elapsed += const Duration(hours: 1);
await handler.play();
await _settle();
await handler.skipToNext();
await _settle();
expect(learning.observations, 1);
expect(
learning.adjustment(
const AutoplayLearningTrack(
source: '/one.flac',
title: 'One',
artist: 'Artist',
duration: Duration(seconds: 60),
),
const AutoplayLearningTrack(
source: '/two.flac',
title: 'Two',
artist: 'Other',
),
DateTime(2026),
),
lessThan(0),
);
elapsed += const Duration(seconds: 10);
await handler.skipToQueueItem(
1,
); // Restarting is not rejecting this song.
await _settle();
expect(learning.observations, 1);
runtime.configure(
runtime.settings.copyWith(autoplayPersonalization: false),
);
elapsed += const Duration(seconds: 50);
await handler.skipToQueueItem(0);
expect(learning.observations, 1);
expect(handler.playbackState.value.playing, isTrue);
expect(handler.mediaItem.value?.id, 'one');
},
);
test(
'iOS restores controls across unlock/background without changing track',
() async {