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

+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 {