mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 06:52:18 +02:00
feat(autoplay): add opt-in on-device recommendation learning
This commit is contained in:
1 parent
e22e934525
commit
d2dcf28622
15 files changed
+1357
-10
No files matched your search
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user