From d2dcf286224c1927e00497e7be3a3f12bc2229b0 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:34:11 +0700 Subject: [PATCH] feat(autoplay): add opt-in on-device recommendation learning --- lib/l10n/arb/app_en.arb | 4 + lib/l10n/arb/app_id.arb | 4 + lib/models/settings.dart | 5 + lib/models/settings.g.dart | 2 + lib/providers/settings_provider.dart | 5 + .../settings/playback_settings_page.dart | 40 ++ .../settings/settings_search_catalog.dart | 12 + lib/services/autoplay_personalization.dart | 430 ++++++++++++++++++ lib/services/music_player_automix.dart | 1 + lib/services/music_player_autoplay.dart | 111 ++++- lib/services/music_player_runtime.dart | 7 + lib/services/music_player_service.dart | 63 ++- test/autoplay_personalization_test.dart | 426 +++++++++++++++++ test/autoplay_settings_test.dart | 95 ++++ test/music_player_runtime_test.dart | 162 +++++++ 15 files changed, 1357 insertions(+), 10 deletions(-) create mode 100644 lib/services/autoplay_personalization.dart create mode 100644 test/autoplay_personalization_test.dart create mode 100644 test/autoplay_settings_test.dart diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 54a6c19c..bab25f67 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 51363bf7..f611399f 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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", diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 14b77cd5..b45c6dcc 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -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, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 55687fb7..ffa20af7 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -54,6 +54,7 @@ AppSettings _$AppSettingsFromJson(Map 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 _$AppSettingsToJson( 'usbAllowFixedVolume': instance.usbAllowFixedVolume, 'dapExclusive': instance.dapExclusive, 'autoplay': instance.autoplay, + 'autoplayPersonalization': instance.autoplayPersonalization, 'isFirstLaunch': instance.isFirstLaunch, 'checkForUpdates': instance.checkForUpdates, 'updateChannel': instance.updateChannel, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 6ce529f5..3a7fc933 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -643,6 +643,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setAutoplayPersonalization(bool enabled) { + state = state.copyWith(autoplayPersonalization: enabled); + _saveSettings(); + } + void setUsbBitPerfect(bool enabled) { state = state.copyWith(usbBitPerfect: enabled); _saveSettings(); diff --git a/lib/screens/settings/playback_settings_page.dart b/lib/screens/settings/playback_settings_page.dart index 02a4f9fe..b6dbca06 100644 --- a/lib/screens/settings/playback_settings_page.dart +++ b/lib/screens/settings/playback_settings_page.dart @@ -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 { ), 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, diff --git a/lib/screens/settings/settings_search_catalog.dart b/lib/screens/settings/settings_search_catalog.dart index 94fb745a..f7993364 100644 --- a/lib/screens/settings/settings_search_catalog.dart +++ b/lib/screens/settings/settings_search_catalog.dart @@ -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, diff --git a/lib/services/autoplay_personalization.dart b/lib/services/autoplay_personalization.dart new file mode 100644 index 00000000..7652924a --- /dev/null +++ b/lib/services/autoplay_personalization.dart @@ -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 _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 = [ + 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 = {}; + 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 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 Function()? load, + Future 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 Function() _load; + final Future Function(String) _save; + final Duration Function() _elapsed; + final DateTime Function() _now; + AutoplayPreferenceModel _model = AutoplayPreferenceModel(); + Future? _loading; + Future _writing = Future.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 _loadPreferences() async => + (await SharedPreferences.getInstance()).getString(_modelKey); + + static Future _savePreferences(String value) async { + if (!await (await SharedPreferences.getInstance()).setString( + _modelKey, + value, + )) { + throw StateError('Could not save autoplay learning'); + } + } + + Future 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 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 reset() async { + _session = null; + _previous = null; + _pending.clear(); + await ensureLoaded(); + _model = AutoplayPreferenceModel(); + _dirty = true; + await flush(); + } + + Future dispose() async { + finish(); + _disposed = true; + await _loading; + await flush(); + } +} + +extension on Stopwatch { + Duration elapsedGetter() => elapsed; +} diff --git a/lib/services/music_player_automix.dart b/lib/services/music_player_automix.dart index f0555958..36e415cb 100644 --- a/lib/services/music_player_automix.dart +++ b/lib/services/music_player_automix.dart @@ -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( diff --git a/lib/services/music_player_autoplay.dart b/lib/services/music_player_autoplay.dart index 68ff81da..377bbe05 100644 --- a/lib/services/music_player_autoplay.dart +++ b/lib/services/music_player_autoplay.dart @@ -3,6 +3,25 @@ part of 'music_player_service.dart'; typedef AutoplayLibraryLoader = Future> Function(PlayableMedia seed); +Future 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> _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 selectAutoplayTracks({ required PlayableMedia seed, required List candidates, @@ -86,6 +105,7 @@ List selectAutoplayTracks({ required Set 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 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 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 selected; + if (model == null) { + selected = ordinaryRanking(); + } else { + try { + selected = await compute(_rankPersonalizedAutoplay, ( + current, + candidates, + upcoming, + recent, + Set.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, + List, + List, + Set, + AutoplayPreferenceModel, + int, + DateTime, + int, +); + +/// Pass metadata and a frozen 64 KiB model only, never the player/runtime. +List _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, + ), + ); +} diff --git a/lib/services/music_player_runtime.dart b/lib/services/music_player_runtime.dart index 17e07971..6f18f4dc 100644 --- a/lib/services/music_player_runtime.dart +++ b/lib/services/music_player_runtime.dart @@ -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 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(); } diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index 803167ae..f0afb0ed 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -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 restoreIosNowPlaying() async { @@ -1359,9 +1395,15 @@ class MusicPlayerHandler extends BaseAudioHandler Future _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 _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 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 skipToQueueItem(int index) => _playIndex(index); + Future skipToQueueItem(int index) => _playIndex(index, userSkip: true); @override Future> getChildren( @@ -2095,6 +2148,8 @@ class MusicPlayerHandler extends BaseAudioHandler Future _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; diff --git a/test/autoplay_personalization_test.dart b/test/autoplay_personalization_test.dart new file mode 100644 index 00000000..bd684aff --- /dev/null +++ b/test/autoplay_personalization_test.dart @@ -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? 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 = []; + 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 rank({ + bool personalized = false, + Set 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; + 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(); + 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(); + 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); + }, + ); +} diff --git a/test/autoplay_settings_test.dart b/test/autoplay_settings_test.dart new file mode 100644 index 00000000..13b8f27f --- /dev/null +++ b/test/autoplay_settings_test.dart @@ -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, + ).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( + find.byWidgetPredicate( + (widget) => + widget is SettingsSwitchItem && + widget.title == 'Personalized autoplay', + ), + ) + .enabled, + isFalse, + ); + expect(tester.takeException(), isNull); + }); + } +} diff --git a/test/music_player_runtime_test.dart b/test/music_player_runtime_test.dart index 4469ef3b..ee0e205c 100644 --- a/test/music_player_runtime_test.dart +++ b/test/music_player_runtime_test.dart @@ -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 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 {