mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-18 21:47:34 +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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -54,6 +54,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
usbAllowFixedVolume: json['usbAllowFixedVolume'] as bool? ?? false,
|
||||
dapExclusive: json['dapExclusive'] as bool? ?? false,
|
||||
autoplay: json['autoplay'] as bool? ?? false,
|
||||
autoplayPersonalization: json['autoplayPersonalization'] as bool? ?? false,
|
||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
|
||||
updateChannel: json['updateChannel'] as String? ?? 'stable',
|
||||
@@ -174,6 +175,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'usbAllowFixedVolume': instance.usbAllowFixedVolume,
|
||||
'dapExclusive': instance.dapExclusive,
|
||||
'autoplay': instance.autoplay,
|
||||
'autoplayPersonalization': instance.autoplayPersonalization,
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
'checkForUpdates': instance.checkForUpdates,
|
||||
'updateChannel': instance.updateChannel,
|
||||
|
||||
@@ -643,6 +643,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setAutoplayPersonalization(bool enabled) {
|
||||
state = state.copyWith(autoplayPersonalization: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setUsbBitPerfect(bool enabled) {
|
||||
state = state.copyWith(usbBitPerfect: enabled);
|
||||
_saveSettings();
|
||||
|
||||
@@ -5,6 +5,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/music_playback_deck.dart';
|
||||
import 'package:spotiflac_android/services/music_player_service.dart'
|
||||
show resetAutoplayLearning;
|
||||
import 'package:spotiflac_android/screens/listening_statistics_screen.dart';
|
||||
import 'package:spotiflac_android/widgets/animation_utils.dart';
|
||||
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
|
||||
@@ -123,6 +125,44 @@ class _PlaybackSettingsPageState extends ConsumerState<PlaybackSettingsPage> {
|
||||
),
|
||||
const SliverToBoxAdapter(child: AutoMixSettings()),
|
||||
],
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.autoplayFromLibrary,
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.playlist_play_rounded,
|
||||
title: context.l10n.autoplayFromLibrary,
|
||||
subtitle: context.l10n.autoplayDescription,
|
||||
value: settings.autoplay,
|
||||
enabled: settings.playerMode == 'internal',
|
||||
onChanged: (value) =>
|
||||
ref.read(settingsProvider.notifier).setAutoplay(value),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.auto_awesome_outlined,
|
||||
title: context.l10n.autoplayPersonalization,
|
||||
subtitle: context.l10n.autoplayPersonalizationDescription,
|
||||
value: settings.autoplayPersonalization,
|
||||
enabled: settings.playerMode == 'internal',
|
||||
onChanged: (value) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setAutoplayPersonalization(value),
|
||||
showDivider: settings.autoplayPersonalization,
|
||||
),
|
||||
if (settings.autoplayPersonalization)
|
||||
SettingsItem(
|
||||
icon: Icons.restart_alt,
|
||||
title: context.l10n.autoplayResetLearning,
|
||||
onTap: resetAutoplayLearning,
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsSectionHeader(
|
||||
title: context.l10n.playbackAutomation,
|
||||
|
||||
@@ -238,6 +238,18 @@ class SettingsSearchCatalog {
|
||||
),
|
||||
],
|
||||
playback = [
|
||||
SettingsSearchEntry(
|
||||
icon: Icons.playlist_play_rounded,
|
||||
title: l10n.autoplayFromLibrary,
|
||||
subtitle: l10n.autoplayDescription,
|
||||
keywords: const ['autoplay', 'queue', 'recommendation'],
|
||||
),
|
||||
SettingsSearchEntry(
|
||||
icon: Icons.auto_awesome_outlined,
|
||||
title: l10n.autoplayPersonalization,
|
||||
subtitle: l10n.autoplayPersonalizationDescription,
|
||||
keywords: const ['machine learning', 'recommendation', 'personal'],
|
||||
),
|
||||
SettingsSearchEntry(
|
||||
icon: Icons.insights_outlined,
|
||||
title: l10n.listeningStats,
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('AutoplayLearning');
|
||||
const _modelKey = 'autoplay_learning_model_v1';
|
||||
|
||||
/// Metadata only: signed URLs, credentials, artwork and audio never enter the
|
||||
/// model. Source is used by the live session, not persisted as a feature.
|
||||
class AutoplayLearningTrack {
|
||||
const AutoplayLearningTrack({
|
||||
required this.source,
|
||||
required this.title,
|
||||
required this.artist,
|
||||
this.album = '',
|
||||
this.genre = '',
|
||||
this.duration,
|
||||
});
|
||||
|
||||
final String source, title, artist, album, genre;
|
||||
final Duration? duration;
|
||||
}
|
||||
|
||||
String _text(String value) {
|
||||
final normalized = value.trim().toLowerCase();
|
||||
return normalized.length > 160 ? normalized.substring(0, 160) : normalized;
|
||||
}
|
||||
|
||||
/// Online logistic regression with AdaGrad and hashed context interactions.
|
||||
/// Two fixed arrays occupy 64 KiB regardless of Library/listening-history size.
|
||||
/// The prior ranker supplies cold-start relevance; this model learns engagement.
|
||||
class AutoplayPreferenceModel {
|
||||
static const int dimensions = 4096;
|
||||
final Float64List _weights = Float64List(dimensions);
|
||||
final Float64List _gradientSquares = Float64List(dimensions);
|
||||
int _observations = 0;
|
||||
DateTime? _updatedAt;
|
||||
|
||||
int get observations => _observations;
|
||||
|
||||
AutoplayPreferenceModel snapshot() {
|
||||
final copy = AutoplayPreferenceModel();
|
||||
copy._weights.setAll(0, _weights);
|
||||
copy._observations = _observations;
|
||||
return copy;
|
||||
}
|
||||
|
||||
Map<int, double> _features(
|
||||
AutoplayLearningTrack track,
|
||||
AutoplayLearningTrack? previous,
|
||||
DateTime at,
|
||||
) {
|
||||
final artist = _text(track.artist), album = _text(track.album);
|
||||
final genres = track.genre
|
||||
.split(RegExp(r'[,;/]'))
|
||||
.map(_text)
|
||||
.where((value) => value.isNotEmpty)
|
||||
.take(3)
|
||||
.toSet();
|
||||
final previousArtist = _text(previous?.artist ?? '');
|
||||
final previousGenre = _text(previous?.genre ?? '');
|
||||
final period = at.hour ~/ 6;
|
||||
final tokens = <String>[
|
||||
if (artist.isNotEmpty) 'artist:$artist',
|
||||
if (artist.isNotEmpty && album.isNotEmpty) 'album:$artist|$album',
|
||||
if (track.title.trim().isNotEmpty) 'song:$artist|${_text(track.title)}',
|
||||
for (final genre in genres) 'genre:$genre',
|
||||
if (artist.isNotEmpty) 'time:$period|artist:$artist',
|
||||
for (final genre in genres) 'time:$period|genre:$genre',
|
||||
if (previousArtist.isNotEmpty && artist.isNotEmpty)
|
||||
'transition:$previousArtist|$artist',
|
||||
for (final genre in genres)
|
||||
if (previousGenre.isNotEmpty) 'transition:$previousGenre|$genre',
|
||||
if (track.duration != null && track.duration! > Duration.zero)
|
||||
'duration:${min(5, track.duration!.inSeconds ~/ 90)}',
|
||||
];
|
||||
final features = <int, double>{};
|
||||
for (final token in tokens) {
|
||||
// Stable FNV-1a, rather than String.hashCode (not stable across runtimes).
|
||||
var hash = 2166136261;
|
||||
for (final unit in token.codeUnits) {
|
||||
hash = ((hash ^ unit) * 16777619) & 0xffffffff;
|
||||
}
|
||||
final index = hash & (dimensions - 1);
|
||||
final sign = (hash & 0x80000000) == 0 ? 1.0 : -1.0;
|
||||
features[index] = (features[index] ?? 0) + sign;
|
||||
}
|
||||
final norm = sqrt(features.values.fold(0.0, (sum, v) => sum + v * v));
|
||||
if (norm > 0) features.updateAll((_, value) => value / norm);
|
||||
return features;
|
||||
}
|
||||
|
||||
double _probability(Map<int, double> features) {
|
||||
var logit = 0.0;
|
||||
for (final entry in features.entries) {
|
||||
logit += _weights[entry.key] * entry.value;
|
||||
}
|
||||
return 1 / (1 + exp(-logit.clamp(-20.0, 20.0)));
|
||||
}
|
||||
|
||||
double preference(
|
||||
AutoplayLearningTrack track, {
|
||||
AutoplayLearningTrack? previous,
|
||||
required DateTime at,
|
||||
}) => _probability(_features(track, previous, at));
|
||||
|
||||
double rankAdjustment(
|
||||
AutoplayLearningTrack track, {
|
||||
required AutoplayLearningTrack previous,
|
||||
required DateTime at,
|
||||
}) =>
|
||||
20 *
|
||||
min(1.0, _observations / 20) *
|
||||
(preference(track, previous: previous, at: at) - 0.5);
|
||||
|
||||
void learn(
|
||||
AutoplayLearningTrack track, {
|
||||
AutoplayLearningTrack? previous,
|
||||
required DateTime at,
|
||||
required double reward,
|
||||
}) {
|
||||
if (!reward.isFinite) return;
|
||||
final features = _features(track, previous, at);
|
||||
if (features.isEmpty) return;
|
||||
final last = _updatedAt;
|
||||
if (last != null) {
|
||||
final days = at.difference(last).inDays;
|
||||
if (days > 0) {
|
||||
final decay = pow(0.98, min(90, days)).toDouble();
|
||||
for (var i = 0; i < dimensions; i++) {
|
||||
_weights[i] *= decay;
|
||||
_gradientSquares[i] *= decay;
|
||||
}
|
||||
}
|
||||
}
|
||||
final error = _probability(features) - reward.clamp(0.0, 1.0);
|
||||
for (final entry in features.entries) {
|
||||
final i = entry.key;
|
||||
final gradient = error * entry.value + 0.001 * _weights[i];
|
||||
_gradientSquares[i] += gradient * gradient;
|
||||
_weights[i] =
|
||||
(_weights[i] - 0.18 * gradient / sqrt(_gradientSquares[i] + 0.01))
|
||||
.clamp(-8.0, 8.0);
|
||||
}
|
||||
_observations = min(1000000, _observations + 1);
|
||||
if (last == null || at.isAfter(last)) _updatedAt = at;
|
||||
}
|
||||
|
||||
String encode() {
|
||||
final data = ByteData(dimensions * 16);
|
||||
for (var i = 0; i < dimensions; i++) {
|
||||
data.setFloat64(i * 16, _weights[i], Endian.little);
|
||||
data.setFloat64(i * 16 + 8, _gradientSquares[i], Endian.little);
|
||||
}
|
||||
return jsonEncode({
|
||||
'version': 1,
|
||||
'observations': _observations,
|
||||
'updatedAt': _updatedAt?.millisecondsSinceEpoch,
|
||||
'parameters': base64Encode(data.buffer.asUint8List()),
|
||||
});
|
||||
}
|
||||
|
||||
static AutoplayPreferenceModel decode(String? raw) {
|
||||
final empty = AutoplayPreferenceModel();
|
||||
if (raw == null || raw.length > 100000) return empty;
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map || decoded['version'] != 1) return empty;
|
||||
final count = decoded['observations'];
|
||||
final parameters = decoded['parameters'];
|
||||
final updated = decoded['updatedAt'];
|
||||
if (count is! int ||
|
||||
count < 0 ||
|
||||
count > 1000000 ||
|
||||
parameters is! String ||
|
||||
(updated != null && updated is! int)) {
|
||||
return empty;
|
||||
}
|
||||
final bytes = base64Decode(parameters);
|
||||
if (bytes.length != dimensions * 16) return empty;
|
||||
final data = ByteData.sublistView(bytes);
|
||||
final model = AutoplayPreferenceModel();
|
||||
for (var i = 0; i < dimensions; i++) {
|
||||
final weight = data.getFloat64(i * 16, Endian.little);
|
||||
final square = data.getFloat64(i * 16 + 8, Endian.little);
|
||||
if (!weight.isFinite ||
|
||||
weight.abs() > 8 ||
|
||||
!square.isFinite ||
|
||||
square < 0 ||
|
||||
square > 1e9) {
|
||||
return empty;
|
||||
}
|
||||
model._weights[i] = weight;
|
||||
model._gradientSquares[i] = square;
|
||||
}
|
||||
model._observations = count;
|
||||
if (updated is int) {
|
||||
model._updatedAt = DateTime.fromMillisecondsSinceEpoch(updated);
|
||||
}
|
||||
return model;
|
||||
} catch (_) {
|
||||
// Invalid/older snapshots are a cold start, never a playback failure.
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _LearningSession {
|
||||
_LearningSession(this.track, this.previous, this.at);
|
||||
AutoplayLearningTrack track;
|
||||
final AutoplayLearningTrack? previous;
|
||||
final DateTime at;
|
||||
Duration listened = Duration.zero;
|
||||
Duration? lastElapsed;
|
||||
bool playing = false;
|
||||
}
|
||||
|
||||
/// Independent of listening-statistics opt-in. This explicitly enabled feature
|
||||
/// records actual audible elapsed time, not seek position or paused wall time.
|
||||
class AutoplayPersonalization {
|
||||
AutoplayPersonalization({
|
||||
Future<String?> Function()? load,
|
||||
Future<void> Function(String)? save,
|
||||
Duration Function()? elapsed,
|
||||
DateTime Function()? now,
|
||||
}) : _load = load ?? _loadPreferences,
|
||||
_save = save ?? _savePreferences,
|
||||
_elapsed = elapsed ?? (Stopwatch()..start()).elapsedGetter,
|
||||
_now = now ?? DateTime.now;
|
||||
|
||||
final Future<String?> Function() _load;
|
||||
final Future<void> Function(String) _save;
|
||||
final Duration Function() _elapsed;
|
||||
final DateTime Function() _now;
|
||||
AutoplayPreferenceModel _model = AutoplayPreferenceModel();
|
||||
Future<void>? _loading;
|
||||
Future<void> _writing = Future<void>.value();
|
||||
bool _loaded = false, _enabled = false, _disposed = false, _dirty = false;
|
||||
Timer? _saveTimer;
|
||||
_LearningSession? _session;
|
||||
AutoplayLearningTrack? _previous;
|
||||
final _pending = <(_LearningSession, double)>[];
|
||||
|
||||
int get observations => _model.observations;
|
||||
|
||||
AutoplayPreferenceModel? get rankingSnapshot =>
|
||||
_enabled && _loaded && !_disposed && _model.observations > 0
|
||||
? _model.snapshot()
|
||||
: null;
|
||||
|
||||
static Future<String?> _loadPreferences() async =>
|
||||
(await SharedPreferences.getInstance()).getString(_modelKey);
|
||||
|
||||
static Future<void> _savePreferences(String value) async {
|
||||
if (!await (await SharedPreferences.getInstance()).setString(
|
||||
_modelKey,
|
||||
value,
|
||||
)) {
|
||||
throw StateError('Could not save autoplay learning');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> ensureLoaded() => _loading ??= () async {
|
||||
try {
|
||||
_model = AutoplayPreferenceModel.decode(await _load());
|
||||
} catch (error) {
|
||||
_log.w('Learning unavailable; using ordinary autoplay: $error');
|
||||
}
|
||||
_loaded = true;
|
||||
// Feedback completed before disposal still belongs in the saved model.
|
||||
// Opt-out clears this buffer and prevents its application.
|
||||
if (_enabled) {
|
||||
for (final (session, reward) in _pending) {
|
||||
_learn(session, reward);
|
||||
}
|
||||
}
|
||||
_pending.clear();
|
||||
}();
|
||||
|
||||
void setEnabled(bool enabled) {
|
||||
if (_disposed || _enabled == enabled) return;
|
||||
_enabled = enabled;
|
||||
_session = null;
|
||||
_previous = null;
|
||||
_pending.clear();
|
||||
if (enabled) {
|
||||
unawaited(ensureLoaded());
|
||||
} else {
|
||||
unawaited(flush());
|
||||
}
|
||||
}
|
||||
|
||||
void begin(AutoplayLearningTrack track) {
|
||||
if (!_enabled || _disposed) return;
|
||||
finish();
|
||||
_session = _LearningSession(track, _previous, _now());
|
||||
_session!.lastElapsed = _elapsed();
|
||||
_previous = track;
|
||||
}
|
||||
|
||||
void updatePlayback({
|
||||
required String? source,
|
||||
required bool playing,
|
||||
Duration? duration,
|
||||
}) {
|
||||
checkpoint();
|
||||
final session = _session;
|
||||
if (session == null) return;
|
||||
if (session.track.source != source) {
|
||||
session.playing = false;
|
||||
return;
|
||||
}
|
||||
if (duration != null && duration > Duration.zero) {
|
||||
final track = session.track;
|
||||
session.track = AutoplayLearningTrack(
|
||||
source: track.source,
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
album: track.album,
|
||||
genre: track.genre,
|
||||
duration: duration,
|
||||
);
|
||||
}
|
||||
session.playing = playing;
|
||||
}
|
||||
|
||||
void checkpoint() {
|
||||
final session = _session;
|
||||
if (session == null) return;
|
||||
final elapsed = _elapsed(), last = session.lastElapsed;
|
||||
session.lastElapsed = elapsed;
|
||||
if (session.playing && last != null && elapsed > last) {
|
||||
session.listened += elapsed - last;
|
||||
}
|
||||
}
|
||||
|
||||
void finish({bool skipped = false}) {
|
||||
checkpoint();
|
||||
final session = _session;
|
||||
_session = null;
|
||||
if (session == null || !_enabled || _disposed) return;
|
||||
final seconds = session.listened.inMilliseconds / 1000;
|
||||
// Rapid queue taps and loading failures carry no preference signal.
|
||||
if (seconds < 3) return;
|
||||
final duration = session.track.duration?.inMilliseconds;
|
||||
final fraction = duration != null && duration > 0
|
||||
? session.listened.inMilliseconds / duration
|
||||
: null;
|
||||
double? reward;
|
||||
if (skipped && seconds < 30 && (fraction == null || fraction < 0.25)) {
|
||||
reward = 0;
|
||||
} else if (fraction != null && fraction >= 0.5) {
|
||||
reward = (0.5 + fraction.clamp(0.0, 1.0) / 2).clamp(0.0, 1.0);
|
||||
} else if (fraction == null && seconds >= 120) {
|
||||
reward = 0.75;
|
||||
}
|
||||
if (reward == null) return;
|
||||
if (_loaded) {
|
||||
_learn(session, reward);
|
||||
} else {
|
||||
if (_pending.length == 32) _pending.removeAt(0);
|
||||
_pending.add((session, reward));
|
||||
}
|
||||
}
|
||||
|
||||
void _learn(_LearningSession session, double reward) {
|
||||
_model.learn(
|
||||
session.track,
|
||||
previous: session.previous,
|
||||
at: session.at,
|
||||
reward: reward,
|
||||
);
|
||||
_dirty = true;
|
||||
if (!_disposed) {
|
||||
_saveTimer ??= Timer(
|
||||
const Duration(seconds: 30),
|
||||
() => unawaited(flush()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
double adjustment(
|
||||
AutoplayLearningTrack track,
|
||||
AutoplayLearningTrack seed,
|
||||
DateTime at,
|
||||
) => !_enabled || !_loaded || _disposed
|
||||
? 0
|
||||
: _model.rankAdjustment(track, previous: seed, at: at);
|
||||
|
||||
Future<void> flush() {
|
||||
_saveTimer?.cancel();
|
||||
_saveTimer = null;
|
||||
if (!_dirty || !_loaded) return _writing;
|
||||
final snapshot = _model.encode();
|
||||
_dirty = false;
|
||||
return _writing = _writing.then((_) async {
|
||||
try {
|
||||
await _save(snapshot);
|
||||
} catch (error) {
|
||||
_dirty = true;
|
||||
_log.w('Could not persist autoplay learning: $error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> reset() async {
|
||||
_session = null;
|
||||
_previous = null;
|
||||
_pending.clear();
|
||||
await ensureLoaded();
|
||||
_model = AutoplayPreferenceModel();
|
||||
_dirty = true;
|
||||
await flush();
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
finish();
|
||||
_disposed = true;
|
||||
await _loading;
|
||||
await flush();
|
||||
}
|
||||
}
|
||||
|
||||
extension on Stopwatch {
|
||||
Duration elapsedGetter() => elapsed;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -3,6 +3,25 @@ part of 'music_player_service.dart';
|
||||
typedef AutoplayLibraryLoader =
|
||||
Future<List<PlayableMedia>> Function(PlayableMedia seed);
|
||||
|
||||
Future<void> resetAutoplayLearning({MusicPlayerRuntime? runtime}) async {
|
||||
final target = runtime ?? musicPlayerRuntime;
|
||||
await target.personalization.reset();
|
||||
final handler = target.handler;
|
||||
if (handler == null || handler.isDisposed) return;
|
||||
handler._configurePersonalization();
|
||||
handler._autoplay.updateMode();
|
||||
}
|
||||
|
||||
AutoplayLearningTrack _learningTrack(PlayableMedia media) =>
|
||||
AutoplayLearningTrack(
|
||||
source: media.source,
|
||||
title: media.title,
|
||||
artist: media.artist,
|
||||
album: media.album,
|
||||
genre: media.genre ?? '',
|
||||
duration: media.duration,
|
||||
);
|
||||
|
||||
void setAutoplayEnabled(bool enabled, {bool includeLocal = true}) =>
|
||||
musicPlayerRuntime.configure(
|
||||
musicPlayerRuntime.settings.copyWith(
|
||||
@@ -77,7 +96,7 @@ Future<List<PlayableMedia>> _loadAutoplayLibrary(
|
||||
|
||||
/// Rank once per refill, so the visible queue is the actual playback order.
|
||||
/// Related songs score higher, while recent plays and repeated artists lose
|
||||
/// priority. No model, network lookup or audio analysis is needed.
|
||||
/// priority. Optional local learning adjusts this prior without audio analysis.
|
||||
List<PlayableMedia> selectAutoplayTracks({
|
||||
required PlayableMedia seed,
|
||||
required List<PlayableMedia> candidates,
|
||||
@@ -86,6 +105,7 @@ List<PlayableMedia> selectAutoplayTracks({
|
||||
required Set<String> dismissedSources,
|
||||
required Random random,
|
||||
int limit = 6,
|
||||
double Function(PlayableMedia)? preferenceAdjustment,
|
||||
}) {
|
||||
String normalized(String? value) => normalizeLookupText(value);
|
||||
String songKey(PlayableMedia item) =>
|
||||
@@ -128,6 +148,7 @@ List<PlayableMedia> selectAutoplayTracks({
|
||||
if (seedGenre.isNotEmpty && seedGenre == normalized(item.genre)) {
|
||||
score += 4;
|
||||
}
|
||||
if (preferenceAdjustment != null) score += preferenceAdjustment(item);
|
||||
// Match the latest source OR normalized song occurrence without walking
|
||||
// the entire listening history again for every candidate.
|
||||
final played = max(
|
||||
@@ -232,18 +253,54 @@ class _MusicAutoplay {
|
||||
final current = handler._media[handler._index];
|
||||
final upcoming = handler._media.skip(handler._index + 1).toList();
|
||||
if (upcoming.length >= 3) return;
|
||||
final selected = selectAutoplayTracks(
|
||||
final recent = handler._playHistory
|
||||
.where((i) => i >= 0 && i < handler._media.length)
|
||||
.map((i) => handler._media[i])
|
||||
.toList();
|
||||
final model = handler._runtime.personalization.rankingSnapshot;
|
||||
final revision = handler._sessionQueueRevision;
|
||||
List<PlayableMedia> ordinaryRanking() => selectAutoplayTracks(
|
||||
seed: current,
|
||||
candidates: candidates,
|
||||
upcoming: upcoming,
|
||||
recent: handler._playHistory
|
||||
.where((i) => i >= 0 && i < handler._media.length)
|
||||
.map((i) => handler._media[i])
|
||||
.toList(),
|
||||
recent: recent,
|
||||
dismissedSources: _dismissed,
|
||||
random: handler._random,
|
||||
limit: 6 - upcoming.length,
|
||||
);
|
||||
List<PlayableMedia> selected;
|
||||
if (model == null) {
|
||||
selected = ordinaryRanking();
|
||||
} else {
|
||||
try {
|
||||
selected = await compute(_rankPersonalizedAutoplay, (
|
||||
current,
|
||||
candidates,
|
||||
upcoming,
|
||||
recent,
|
||||
Set<String>.of(_dismissed),
|
||||
model,
|
||||
handler._random.nextInt(1 << 31),
|
||||
DateTime.now(),
|
||||
6 - upcoming.length,
|
||||
), debugLabel: 'autoplay-ranking');
|
||||
} catch (error) {
|
||||
_log.w(
|
||||
'Personalized ranking unavailable; using ordinary autoplay: $error',
|
||||
);
|
||||
selected = ordinaryRanking();
|
||||
}
|
||||
}
|
||||
// The worker must not publish suggestions for an obsolete queue/seed.
|
||||
if (!enabled ||
|
||||
handler._disposed ||
|
||||
generation != _generation ||
|
||||
revision != handler._sessionQueueRevision ||
|
||||
handler._index < 0 ||
|
||||
handler._index >= handler._media.length ||
|
||||
handler._media[handler._index].source != current.source) {
|
||||
return;
|
||||
}
|
||||
if (selected.isEmpty) return;
|
||||
final additions = [
|
||||
for (final item in selected)
|
||||
@@ -263,3 +320,45 @@ class _MusicAutoplay {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typedef _AutoplayRankingInput = (
|
||||
PlayableMedia,
|
||||
List<PlayableMedia>,
|
||||
List<PlayableMedia>,
|
||||
List<PlayableMedia>,
|
||||
Set<String>,
|
||||
AutoplayPreferenceModel,
|
||||
int,
|
||||
DateTime,
|
||||
int,
|
||||
);
|
||||
|
||||
/// Pass metadata and a frozen 64 KiB model only, never the player/runtime.
|
||||
List<PlayableMedia> _rankPersonalizedAutoplay(_AutoplayRankingInput input) {
|
||||
final (
|
||||
seed,
|
||||
candidates,
|
||||
upcoming,
|
||||
recent,
|
||||
dismissed,
|
||||
model,
|
||||
randomSeed,
|
||||
at,
|
||||
limit,
|
||||
) = input;
|
||||
final learningSeed = _learningTrack(seed);
|
||||
return selectAutoplayTracks(
|
||||
seed: seed,
|
||||
candidates: candidates,
|
||||
upcoming: upcoming,
|
||||
recent: recent,
|
||||
dismissedSources: dismissed,
|
||||
random: Random(randomSeed),
|
||||
limit: limit,
|
||||
preferenceAdjustment: (track) => model.rankAdjustment(
|
||||
_learningTrack(track),
|
||||
previous: learningSeed,
|
||||
at: at,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:audio_session/audio_session.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
import 'package:spotiflac_android/services/app_state_database.dart';
|
||||
import 'package:spotiflac_android/services/autoplay_personalization.dart';
|
||||
import 'package:spotiflac_android/services/discord_presence_service.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/listening_statistics.dart';
|
||||
@@ -32,6 +33,7 @@ class PlaybackDependencies {
|
||||
this.readNetworkMetadata = _readNetworkMetadata,
|
||||
this.resolveNetworkSource = _resolveNetworkSource,
|
||||
this.initializeHandler = _initializeAudioHandler,
|
||||
this.createPersonalization = AutoplayPersonalization.new,
|
||||
AppStateDatabase? sessionDatabase,
|
||||
LibraryDatabase? libraryDatabase,
|
||||
ListeningRecorder? recorder,
|
||||
@@ -59,6 +61,7 @@ class PlaybackDependencies {
|
||||
final Future<MusicPlayerHandler> Function(MusicPlayerHandler Function())
|
||||
initializeHandler;
|
||||
final MethodChannel notificationChannel;
|
||||
final AutoplayPersonalization Function() createPersonalization;
|
||||
final AppStateDatabase? _sessionDatabase;
|
||||
final LibraryDatabase? _libraryDatabase;
|
||||
final ListeningRecorder? _recorder;
|
||||
@@ -107,6 +110,9 @@ class MusicPlayerRuntime {
|
||||
}) : _settings = settings;
|
||||
|
||||
final PlaybackDependencies dependencies;
|
||||
AutoplayPersonalization? _personalization;
|
||||
AutoplayPersonalization get personalization =>
|
||||
_personalization ??= dependencies.createPersonalization();
|
||||
AppSettings _settings;
|
||||
MusicPlayerHandler? _handler;
|
||||
void Function()? _unsubscribeSourceDeletion;
|
||||
@@ -273,6 +279,7 @@ class MusicPlayerRuntime {
|
||||
} catch (_) {
|
||||
// Initialization may have failed because its pending handler was closed.
|
||||
}
|
||||
await _personalization?.dispose();
|
||||
await _handlers.close();
|
||||
await _mutedPlaybackEvents.close();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:math';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/foundation.dart' show compute;
|
||||
import 'package:flutter/widgets.dart'
|
||||
show AppLifecycleState, WidgetsBinding, WidgetsBindingObserver;
|
||||
import 'package:spotiflac_android/services/playback_notification.dart';
|
||||
@@ -28,6 +29,7 @@ import 'package:spotiflac_android/services/content_uri_playback_cache.dart';
|
||||
import 'package:spotiflac_android/services/music_player_runtime.dart';
|
||||
import 'package:spotiflac_android/services/music_playback_deck.dart';
|
||||
import 'package:spotiflac_android/services/automix_analysis.dart';
|
||||
import 'package:spotiflac_android/services/autoplay_personalization.dart';
|
||||
import 'package:spotiflac_android/services/automix_analyzer.dart';
|
||||
import 'package:spotiflac_android/models/automix_options.dart';
|
||||
import 'package:spotiflac_android/models/settings.dart';
|
||||
@@ -361,6 +363,10 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
/// Reconcile only the playback fields affected by the new settings snapshot.
|
||||
void applySettings(AppSettings previous) {
|
||||
final settings = _settings;
|
||||
if (previous.autoplayPersonalization != settings.autoplayPersonalization ||
|
||||
previous.playerMode != settings.playerMode) {
|
||||
_configurePersonalization();
|
||||
}
|
||||
if (previous.playbackNormalization != settings.playbackNormalization) {
|
||||
reapplyNormalization();
|
||||
}
|
||||
@@ -379,6 +385,7 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
_refreshPlaybackAutomation();
|
||||
}
|
||||
if (previous.autoplay != settings.autoplay ||
|
||||
previous.autoplayPersonalization != settings.autoplayPersonalization ||
|
||||
previous.localLibraryEnabled != settings.localLibraryEnabled) {
|
||||
_autoplay.updateMode();
|
||||
}
|
||||
@@ -456,9 +463,17 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
void _init() {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
_configurePersonalization();
|
||||
void recordListening() {
|
||||
final item = mediaItem.value;
|
||||
final state = playbackState.value;
|
||||
_runtime.personalization.updatePlayback(
|
||||
source: item?.extras?['source'] as String?,
|
||||
duration: item?.duration,
|
||||
playing:
|
||||
state.playing &&
|
||||
state.processingState == AudioProcessingState.ready,
|
||||
);
|
||||
_dependencies.recorder.update(
|
||||
item == null
|
||||
? null
|
||||
@@ -511,6 +526,10 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.inactive ||
|
||||
state == AppLifecycleState.paused) {
|
||||
unawaited(_runtime.personalization.flush());
|
||||
}
|
||||
if (state == AppLifecycleState.resumed ||
|
||||
state == AppLifecycleState.inactive ||
|
||||
state == AppLifecycleState.paused) {
|
||||
@@ -518,6 +537,23 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
}
|
||||
}
|
||||
|
||||
void _configurePersonalization() {
|
||||
final enabled =
|
||||
_settings.autoplayPersonalization && _settings.playerMode == 'internal';
|
||||
_runtime.personalization.setEnabled(enabled);
|
||||
if (enabled && _index >= 0 && _index < _media.length) {
|
||||
_runtime.personalization.begin(_learningTrack(_media[_index]));
|
||||
final state = playbackState.value;
|
||||
_runtime.personalization.updatePlayback(
|
||||
source: mediaItem.value?.extras?['source'] as String?,
|
||||
duration: mediaItem.value?.duration,
|
||||
playing:
|
||||
state.playing &&
|
||||
state.processingState == AudioProcessingState.ready,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover system controls without reloading, seeking or restarting audio.
|
||||
/// Never reclaim audio focus for a paused/interrupted/stopped player.
|
||||
Future<void> restoreIosNowPlaying() async {
|
||||
@@ -1359,9 +1395,15 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
Future<void> _playIndex(
|
||||
int index, {
|
||||
bool recordHistory = true,
|
||||
bool userSkip = false,
|
||||
Duration startPosition = Duration.zero,
|
||||
}) async {
|
||||
if (_disposed || index < 0 || index >= _media.length) return;
|
||||
_runtime.personalization.finish(
|
||||
skipped:
|
||||
userSkip &&
|
||||
mediaItem.value?.extras?['source'] != _media[index].source,
|
||||
);
|
||||
_playbackAutomation.cancelPendingActions();
|
||||
// A normal track change supersedes a pending restore. A restored start
|
||||
// keeps it until the source is actually ready so a transient failure can
|
||||
@@ -1456,6 +1498,7 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
startPosition,
|
||||
duration: media.duration,
|
||||
);
|
||||
_runtime.personalization.begin(_learningTrack(media));
|
||||
mediaItem.add(_toMediaItem(media));
|
||||
_lastBroadcastPosition = Duration.zero;
|
||||
_lastPositionBroadcastAt = null;
|
||||
@@ -1765,6 +1808,7 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
|
||||
_completing = true;
|
||||
try {
|
||||
_runtime.personalization.finish();
|
||||
await _onComplete();
|
||||
} finally {
|
||||
_completing = false;
|
||||
@@ -1865,6 +1909,11 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
}
|
||||
|
||||
Future<void> _pausePlayback() async {
|
||||
_runtime.personalization.updatePlayback(
|
||||
source: mediaItem.value?.extras?['source'] as String?,
|
||||
playing: false,
|
||||
);
|
||||
unawaited(_runtime.personalization.flush());
|
||||
final generation = ++_playRequestGeneration;
|
||||
_switchingGeneration = 0;
|
||||
_userPaused = true;
|
||||
@@ -1927,6 +1976,8 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
|
||||
@override
|
||||
Future<void> stop() async {
|
||||
_runtime.personalization.finish();
|
||||
unawaited(_runtime.personalization.flush());
|
||||
_playbackAutomation.cancelPendingActions();
|
||||
_autoplay.reset();
|
||||
cancelSleepTimer();
|
||||
@@ -1967,7 +2018,9 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
await _autoplay.fill();
|
||||
if (_disposed || generation != _playRequestGeneration) return;
|
||||
}
|
||||
if (_index < _media.length - 1) await _playIndex(_index + 1);
|
||||
if (_index < _media.length - 1) {
|
||||
await _playIndex(_index + 1, userSkip: true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -1982,18 +2035,18 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
if (_playHistory.length >= 2) {
|
||||
_playHistory.removeLast();
|
||||
final prev = _playHistory.last;
|
||||
await _playIndex(prev, recordHistory: false);
|
||||
await _playIndex(prev, recordHistory: false, userSkip: true);
|
||||
} else {
|
||||
await _player.seek(Duration.zero);
|
||||
_broadcastPosition(Duration.zero, force: true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_index > 0) await _playIndex(_index - 1);
|
||||
if (_index > 0) await _playIndex(_index - 1, userSkip: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> skipToQueueItem(int index) => _playIndex(index);
|
||||
Future<void> skipToQueueItem(int index) => _playIndex(index, userSkip: true);
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> getChildren(
|
||||
@@ -2095,6 +2148,8 @@ class MusicPlayerHandler extends BaseAudioHandler
|
||||
Future<void> _dispose() async {
|
||||
_autoplay.reset();
|
||||
_disposed = true;
|
||||
_runtime.personalization.finish();
|
||||
await _runtime.personalization.flush();
|
||||
if (_dependencies.isIOS) WidgetsBinding.instance.removeObserver(this);
|
||||
if (identical(musicPlayerRuntime.handler, this)) {
|
||||
autoMixStatus.value = AutoMixStatus.idle;
|
||||
|
||||
@@ -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