Files
SpotiFLAC-Mobile/test/music_player_runtime_test.dart
T

852 lines
29 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
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/listening_statistics.dart';
import 'package:spotiflac_android/services/music_playback_deck.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
import 'package:spotiflac_android/services/playback_notification.dart';
import 'package:spotiflac_android/services/source_deletion_events.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
class _Deck implements MusicPlaybackDeck {
@override
PlayerState state = PlayerState.stopped;
final volumes = <double>[];
Map<String, bool>? outputOptions;
Duration position = Duration.zero;
int disposals = 0;
bool failStop = false;
@override
bool get isDirect => false;
@override
bool get needsSourceReload => false;
@override
Stream<PlayerState> get onPlayerStateChanged => const Stream.empty();
@override
Stream<Duration> get onPositionChanged => const Stream.empty();
@override
Stream<Duration> get onDurationChanged => const Stream.empty();
@override
Stream<void> get onPlayerComplete => const Stream.empty();
@override
set positionUpdater(PositionUpdater updater) {}
@override
Future<void> setReleaseMode(ReleaseMode mode) async {}
@override
Future<void> setAudioContext(AudioContext context) async {}
@override
Future<void> setVolume(double volume) async => volumes.add(volume);
@override
Future<void> cancelPreparation() async {}
@override
Future<void> stop() async {
if (failStop) throw StateError('Transport failed');
state = PlayerState.stopped;
}
@override
Future<void> pause() async => state = PlayerState.paused;
@override
Future<void> resume() async => state = PlayerState.playing;
@override
Future<void> seek(Duration value) async => position = value;
@override
Future<Duration?> getDuration() async => const Duration(seconds: 60);
@override
Future<Duration?> getCurrentPosition() async => position;
@override
Future<void> dispose() async => disposals++;
@override
Future<void> setSource(
DeviceFileSource source, {
bool preferBitPerfect = false,
bool directUsb = false,
bool allowDop = false,
bool requiresDsd = false,
bool allowFixedVolume = false,
bool dapExclusive = false,
}) async {
outputOptions = {
'bitPerfect': preferBitPerfect,
'direct': directUsb,
'dop': allowDop,
'fixedVolume': allowFixedVolume,
'exclusive': dapExclusive,
};
}
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _Session implements AudioSession {
@override
Future<void> configure(AudioSessionConfiguration configuration) async {}
@override
Future<bool> setActive(
bool active, {
AVAudioSessionSetActiveOptions? avAudioSessionSetActiveOptions,
AndroidAudioFocusGainType? androidAudioFocusGainType,
AndroidAudioAttributes? androidAudioAttributes,
bool? androidWillPauseWhenDucked,
AudioSessionConfiguration fallbackConfiguration =
const AudioSessionConfiguration.music(),
}) async => true;
@override
Stream<AudioInterruptionEvent> get interruptionEventStream =>
const Stream.empty();
@override
Stream<void> get becomingNoisyEventStream => const Stream.empty();
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _SessionStore implements AppStateDatabase {
@override
Future<void> savePlaybackSession(Map<String, dynamic> session) async {}
@override
Future<bool> updatePlaybackSessionState({
required int index,
required int positionMs,
required bool shuffle,
required String repeatMode,
}) async => true;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
class _DeletionHandler extends MusicPlayerHandler {
_DeletionHandler(MusicPlayerRuntime runtime) : super(runtime: runtime);
final deletedSources = <String>[];
@override
Future<void> onSourceDeleted(String source) async =>
deletedSources.add(source);
}
class _Recorder extends ListeningRecorder {
_Recorder({required super.write, required super.elapsed, required super.now});
int updates = 0;
ListeningTrack? latestTrack;
@override
void update(
ListeningTrack? track, {
required bool playing,
bool ended = false,
}) {
updates++;
latestTrack = track;
super.update(track, playing: playing, ended: ended);
}
}
MusicPlayerRuntime _runtime({
AppSettings settings = const AppSettings(pauseOnMute: false),
Future<MusicPlayerHandler> Function(MusicPlayerHandler Function())?
initialize,
List<_Deck>? decks,
MusicPlaybackDeck Function(String)? createDeck,
Stream<double> volumeChanges = const Stream.empty(),
SourceDeletionEvents? sourceDeletionEvents,
ListeningRecorder? recorder,
}) {
final runtime = MusicPlayerRuntime(
settings: settings,
dependencies: PlaybackDependencies(
createDeck:
createDeck ??
(_) {
final deck = _Deck();
decks?.add(deck);
return deck;
},
audioSession: () async => _Session(),
initializeHandler: initialize ?? (builder) async => builder(),
readMetadata: (_, {displayName}) async => {
'replaygain_track_gain': '-6 dB',
},
sessionDatabase: _SessionStore(),
recorder:
recorder ??
ListeningRecorder(
write: (_) async {},
elapsed: () => Duration.zero,
now: () => DateTime(2026),
),
volumeChanges: volumeChanges,
sourceDeletionEvents: sourceDeletionEvents ?? SourceDeletionEvents(),
isAndroid: false,
isIOS: false,
),
);
addTearDown(runtime.dispose);
return runtime;
}
Future<void> _settle() => Future<void>.delayed(Duration.zero);
const _item = MediaItem(id: 'one', title: 'One');
const _track = PlayableMedia(
id: 'one',
source: '/one.flac',
title: 'One',
artist: 'Artist',
duration: Duration(seconds: 60),
);
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test(
'position ticks skip listening updates while flushes retain time',
() async {
var elapsed = Duration.zero;
final writes = <ListeningDelta>[];
final recorder = _Recorder(
write: (batch) async => writes.addAll(batch),
elapsed: () => elapsed,
now: () => DateTime(2026, 10, 1, 23, 59, 50).add(elapsed),
);
final handler = await _runtime(recorder: recorder).initialize();
handler.mediaItem.add(_item);
handler.playbackState.add(
PlaybackState(
playing: true,
processingState: AudioProcessingState.ready,
),
);
await _settle();
final updates = recorder.updates;
for (var tick = 1; tick <= 1200; tick++) {
elapsed = Duration(milliseconds: tick * 500);
handler.playbackState.add(
handler.playbackState.value.copyWith(updatePosition: elapsed),
);
if (tick % 20 == 0) await recorder.flush();
}
await _settle();
expect(recorder.updates, updates);
expect(
writes.fold<int>(0, (sum, delta) => sum + delta.milliseconds),
600000,
);
expect(writes.fold<int>(0, (sum, delta) => sum + delta.plays), 1);
expect(writes.first.day, '2026-10-01');
expect(writes.first.milliseconds, 10000);
expect(
writes.skip(1).every((delta) => delta.day == '2026-10-02'),
isTrue,
);
},
);
test(
'listening transitions retain buffering, repeat and metadata semantics',
() async {
var elapsed = Duration.zero;
final writes = <ListeningDelta>[];
final recorder = _Recorder(
write: (batch) async => writes.addAll(batch),
elapsed: () => elapsed,
now: () => DateTime(2026, 10, 1, 23, 59, 50).add(elapsed),
);
final handler = await _runtime(recorder: recorder).initialize();
Future<void> state(bool playing, AudioProcessingState processing) async {
handler.playbackState.add(
handler.playbackState.value.copyWith(
playing: playing,
processingState: processing,
),
);
await _settle();
}
handler.mediaItem.add(_item);
await state(true, AudioProcessingState.ready);
elapsed += const Duration(seconds: 5);
final beforeSeek = recorder.updates;
handler.playbackState.add(
handler.playbackState.value.copyWith(
updatePosition: const Duration(minutes: 10),
),
);
await _settle();
expect(recorder.updates, beforeSeek);
await recorder.flush();
await state(true, AudioProcessingState.buffering);
elapsed += const Duration(seconds: 20);
await state(false, AudioProcessingState.ready);
await state(true, AudioProcessingState.ready);
elapsed += const Duration(seconds: 25);
await state(false, AudioProcessingState.ready);
elapsed += const Duration(seconds: 10);
await state(true, AudioProcessingState.ready);
elapsed += const Duration(seconds: 10);
await state(false, AudioProcessingState.ready);
await state(false, AudioProcessingState.completed);
await state(true, AudioProcessingState.ready);
elapsed += const Duration(seconds: 35);
await recorder.flush();
final beforeMetadata = recorder.updates;
handler.mediaItem.add(_item.copyWith(title: 'Revised', artist: 'Guest'));
await _settle();
expect(recorder.updates, beforeMetadata + 1);
expect(recorder.latestTrack?.title, 'Revised');
expect(recorder.latestTrack?.artist, 'Guest');
recorder.setEnabled(false);
elapsed += const Duration(seconds: 25);
await recorder.flush();
recorder.setEnabled(true);
elapsed += const Duration(seconds: 5);
await recorder.flush();
handler.mediaItem.add(const MediaItem(id: 'two', title: 'Two'));
await _settle();
elapsed += const Duration(seconds: 10);
await recorder.flush();
final one = writes.where((delta) => delta.track.key == 'one').toList();
expect(one.fold<int>(0, (sum, delta) => sum + delta.milliseconds), 80000);
expect(one.fold<int>(0, (sum, delta) => sum + delta.plays), 2);
expect(one.first.day, '2026-10-01');
expect(one.first.milliseconds, 5000);
expect(one.skip(1).every((delta) => delta.day == '2026-10-02'), isTrue);
expect(writes.last.track.key, 'two');
expect(writes.last.milliseconds, 10000);
expect(writes.last.plays, 0);
},
);
test(
'deletion subscriptions follow handler ownership and scoped overrides',
() async {
final events = SourceDeletionEvents();
late MusicPlayerRuntime first;
late MusicPlayerRuntime second;
first = _runtime(
sourceDeletionEvents: events,
initialize: (_) async => _DeletionHandler(first),
);
second = _runtime(
sourceDeletionEvents: events,
initialize: (_) async => _DeletionHandler(second),
);
late MusicPlayerRuntime isolated;
isolated = _runtime(initialize: (_) async => _DeletionHandler(isolated));
final firstHandler = await first.initialize() as _DeletionHandler;
final secondHandler = await second.initialize() as _DeletionHandler;
final isolatedHandler = await isolated.initialize() as _DeletionHandler;
first.attach(firstHandler); // Reattaching must not subscribe twice.
await events.publish('/one.flac');
expect(firstHandler.deletedSources, ['/one.flac']);
expect(secondHandler.deletedSources, ['/one.flac']);
expect(isolatedHandler.deletedSources, isEmpty);
await firstHandler.dispose();
final replacement = await first.initialize() as _DeletionHandler;
await events.publish('/two.flac');
expect(firstHandler.deletedSources, ['/one.flac']);
expect(replacement.deletedSources, ['/two.flac']);
expect(secondHandler.deletedSources, ['/one.flac', '/two.flac']);
await first.dispose();
await second.dispose();
await events.publish('/three.flac');
expect(replacement.deletedSources, ['/two.flac']);
expect(secondHandler.deletedSources, ['/one.flac', '/two.flac']);
},
);
test(
'storage deletion removes duplicates and advances every attached player',
() async {
final directory = await Directory.systemTemp.createTemp(
'queued-deletion-',
);
addTearDown(() => directory.delete(recursive: true));
final file = await File(
'${directory.path}/one.flac',
).writeAsString('audio');
final events = SourceDeletionEvents();
final runtimes = [
_runtime(sourceDeletionEvents: events),
_runtime(sourceDeletionEvents: events),
];
final removed = PlayableMedia(
id: 'removed',
source: file.path,
title: 'Removed',
artist: '',
);
for (final runtime in runtimes) {
await (await runtime.initialize()).setQueueAndPlay([
removed,
removed,
_track,
]);
}
expect(await deleteFile(file.path, deletionEvents: events), isTrue);
for (final runtime in runtimes) {
expect(runtime.handler!.queue.value.map((item) => item.id), ['one']);
expect(runtime.handler!.mediaItem.value?.id, 'one');
expect(runtime.handler!.playbackState.value.queueIndex, 0);
}
},
);
test(
'SAF deletion releases cached playback copies before returning',
() async {
final directory = await Directory.systemTemp.createTemp(
'cached-deletion-',
);
addTearDown(() => directory.delete(recursive: true));
final cached = await File(
'${directory.path}/copy.flac',
).writeAsString('audio');
const channel = MethodChannel('com.zarz.spotiflac/backend');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
channel,
(call) async => switch (call.method) {
'safCopyToTemp' => cached.path,
'safDelete' => true,
_ => null,
},
);
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final events = SourceDeletionEvents();
final runtime = _runtime(sourceDeletionEvents: events);
final handler = await runtime.initialize();
const source = 'content://test.provider/document/song.flac';
const removed = PlayableMedia(
id: 'removed',
source: source,
title: 'Removed',
artist: '',
artUri: 'https://example.com/cover.jpg',
);
await handler.setQueueAndPlay([removed, _track]);
expect(await cached.exists(), isTrue);
expect(await deleteFile(source, deletionEvents: events), isTrue);
expect(await cached.exists(), isFalse);
expect(handler.queue.value.map((item) => item.id), ['one']);
expect(handler.mediaItem.value?.id, 'one');
},
);
test('failed player stop cannot undo a confirmed storage deletion', () async {
final directory = await Directory.systemTemp.createTemp('failed-reaction-');
addTearDown(() => directory.delete(recursive: true));
final file = await File(
'${directory.path}/song.flac',
).writeAsString('audio');
final events = SourceDeletionEvents();
final decks = <_Deck>[];
final runtime = _runtime(decks: decks, sourceDeletionEvents: events);
final handler = await runtime.initialize();
await handler.setQueueAndPlay([
PlayableMedia(
id: 'removed',
source: file.path,
title: 'Removed',
artist: '',
),
]);
decks.single.failStop = true;
expect(await deleteFile(file.path, deletionEvents: events), isTrue);
expect(await file.exists(), isFalse);
expect(handler.queue.value, isEmpty);
});
test(
'deletion retires a pending SAF copy without blocking a later copy',
() async {
final directory = await Directory.systemTemp.createTemp(
'pending-deletion-',
);
addTearDown(() => directory.delete(recursive: true));
final stale = await File(
'${directory.path}/stale.flac',
).writeAsString('old');
final replacement = await File(
'${directory.path}/new.flac',
).writeAsString('new');
final copying = Completer<void>();
final release = Completer<String>();
const channel = MethodChannel('com.zarz.spotiflac/backend');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
var copies = 0;
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'safDelete') return true;
if (call.method != 'safCopyToTemp') return null;
if (++copies > 1) return replacement.path;
copying.complete();
return release.future;
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final events = SourceDeletionEvents();
final handler = await _runtime(sourceDeletionEvents: events).initialize();
const source = 'content://test.provider/document/pending.flac';
const removed = PlayableMedia(
id: 'removed',
source: source,
title: 'Removed',
artist: '',
artUri: 'https://example.com/cover.jpg',
);
final playing = handler.setQueueAndPlay([removed]);
await copying.future;
try {
expect(
await deleteFile(
source,
deletionEvents: events,
).timeout(const Duration(seconds: 1)),
isTrue,
);
expect(handler.queue.value, isEmpty);
} finally {
release.complete(stale.path);
}
final restarted = handler.setQueueAndPlay([removed]);
await playing;
await restarted;
expect(copies, 2);
expect(await stale.exists(), isFalse);
expect(await replacement.exists(), isTrue);
expect(handler.mediaItem.value?.id, 'removed');
expect(handler.playbackState.value.playing, isTrue);
},
);
test(
'pre-init events have empty values and cancellation never waits for init',
() async {
var calls = 0;
final runtime = _runtime(
initialize: (builder) async {
calls++;
return builder();
},
);
final items = <MediaItem?>[];
final queues = <List<MediaItem>>[];
final states = <PlaybackState>[];
final itemEvents = runtime.mediaItemEvents().listen(items.add);
final queueEvents = runtime.queueEvents().listen(queues.add);
final stateEvents = runtime.playbackStateEvents().listen(states.add);
await _settle();
expect(items, [null]);
expect(queues, [isEmpty]);
expect(states, isEmpty);
await Future.wait([
itemEvents.cancel(),
queueEvents.cancel(),
stateEvents.cancel(),
]).timeout(const Duration(seconds: 1));
expect(calls, 0);
await runtime.initialize();
await _settle();
expect(items, [null]);
expect(queues, [isEmpty]);
expect(states, isEmpty);
},
);
test(
'pending init is deduplicated and uses the latest live configuration',
() async {
final ready = Completer<MusicPlayerHandler>();
final volume = StreamController<double>.broadcast();
addTearDown(volume.close);
var calls = 0;
late MusicPlayerHandler created;
final runtime = _runtime(
volumeChanges: volume.stream,
initialize: (builder) {
calls++;
created = builder();
return ready.future;
},
);
final items = <MediaItem?>[];
final events = runtime.mediaItemEvents().listen(items.add);
addTearDown(events.cancel);
runtime.configure(runtime.settings.copyWith(autoMix: true));
final first = runtime.initialize();
expect(runtime.initialize(), same(first));
await _settle();
expect(calls, 1);
expect(runtime.handler, isNull);
expect(volume.hasListener, isFalse);
runtime.configure(runtime.settings.copyWith(pauseOnMute: true));
created.mediaItem.add(_item);
await _settle();
expect(volume.hasListener, isTrue);
expect(items, [null]);
ready.complete(created);
expect(await first, same(created));
expect(await runtime.initialize(), same(created));
await _settle();
expect(items, [null, _item]);
expect(calls, 1);
expect(runtime.settings.autoMix, isTrue);
await runtime.dispose();
expect(volume.hasListener, isFalse);
},
);
test('failed initialization releases its handler and can retry', () async {
var calls = 0;
final decks = <_Deck>[];
final runtime = _runtime(
decks: decks,
initialize: (builder) async {
final handler = builder();
if (++calls == 1) throw StateError('Initialization failed');
return handler;
},
);
await expectLater(runtime.initialize(), throwsStateError);
expect(
LogBuffer().entries.any(
(entry) =>
entry.tag == 'MusicPlayerRuntime' &&
entry.level == 'ERROR' &&
entry.message == 'Failed to initialize playback' &&
entry.error?.contains('Initialization failed') == true,
),
isTrue,
);
expect(runtime.handler, isNull);
expect(decks.single.disposals, 1);
final retried = await runtime.initialize();
expect(runtime.handler, same(retried));
expect(calls, 2);
await runtime.dispose();
expect(decks.map((deck) => deck.disposals), [1, 1]);
});
test(
'deck construction failure leaves no partial handler and can retry',
() async {
var calls = 0;
final deck = _Deck();
final runtime = _runtime(
createDeck: (_) {
if (++calls == 1) throw StateError('Deck creation failed');
return deck;
},
);
await expectLater(runtime.initialize(), throwsStateError);
expect(runtime.handler, isNull);
final handler = await runtime.initialize();
expect(runtime.handler, same(handler));
expect(calls, 2);
await runtime.dispose();
expect(deck.disposals, 1);
},
);
test('an initializer returning a disposed handler can retry', () async {
var calls = 0;
final runtime = _runtime(
initialize: (builder) async {
final handler = builder();
if (++calls == 1) await handler.dispose();
return handler;
},
);
await expectLater(runtime.initialize(), throwsStateError);
final handler = await runtime.initialize();
expect(runtime.handler, same(handler));
expect(handler.isDisposed, isFalse);
expect(calls, 2);
});
test(
'runtime disposal retires a pending handler and closes its events',
() async {
final ready = Completer<MusicPlayerHandler>();
final decks = <_Deck>[];
late MusicPlayerHandler created;
final runtime = _runtime(
decks: decks,
initialize: (builder) {
created = builder();
return ready.future;
},
);
final closed = Completer<void>();
final events = runtime.mediaItemEvents().listen(
(_) {},
onDone: closed.complete,
);
addTearDown(events.cancel);
final pending = runtime.initialize();
final failed = expectLater(pending, throwsStateError);
await _settle();
final disposed = runtime.dispose();
expect(runtime.dispose(), same(disposed));
expect(created.isDisposed, isTrue);
expect(runtime.handler, isNull);
ready.complete(created);
await failed;
await disposed;
await closed.future.timeout(const Duration(seconds: 1));
expect(decks.single.disposals, 1);
await expectLater(runtime.initialize(), throwsStateError);
},
);
test(
'a delayed builder cannot create playback resources after disposal',
() async {
final build = Completer<void>();
final decks = <_Deck>[];
final runtime = _runtime(
decks: decks,
initialize: (builder) async {
await build.future;
return builder();
},
);
final failed = expectLater(runtime.initialize(), throwsStateError);
await _settle();
final disposed = runtime.dispose();
build.complete();
await failed;
await disposed;
expect(runtime.handler, isNull);
expect(decks, isEmpty);
},
);
test(
'event subscribers follow a replacement and ignore the retired handler',
() async {
final runtime = _runtime();
final items = <MediaItem?>[];
final queues = <List<MediaItem>>[];
final itemEvents = runtime.mediaItemEvents().listen(items.add);
final queueEvents = runtime.queueEvents().listen(queues.add);
addTearDown(itemEvents.cancel);
addTearDown(queueEvents.cancel);
final first = await runtime.initialize();
first.mediaItem.add(_item);
first.queue.add([_item]);
await _settle();
expect(items.last, _item);
expect(queues.last, [_item]);
await first.dispose();
await _settle();
expect(items.last, isNull);
expect(queues.last, isEmpty);
first.mediaItem.add(const MediaItem(id: 'stale', title: 'Stale'));
first.queue.add([_item]);
final second = await runtime.initialize();
const replacement = MediaItem(id: 'two', title: 'Two');
second.mediaItem.add(replacement);
second.queue.add([replacement]);
await _settle();
expect(items.last, replacement);
expect(queues.last, [replacement]);
expect(items.whereType<MediaItem>().map((item) => item.id), [
'one',
'two',
]);
},
);
test(
'provider scopes isolate transport settings and notification favorites',
() async {
final firstDecks = <_Deck>[];
final secondDecks = <_Deck>[];
final first = _runtime(
decks: firstDecks,
settings: const AppSettings(
pauseOnMute: false,
playbackNormalization: true,
),
);
final second = _runtime(
decks: secondDecks,
settings: const AppSettings(
pauseOnMute: false,
usbBitPerfect: true,
usbDirect: true,
usbDsdOverPcm: true,
usbAllowFixedVolume: true,
dapExclusive: true,
),
);
final containers = [
for (final runtime in [first, second])
ProviderContainer(
overrides: [musicPlayerRuntimeProvider.overrideWithValue(runtime)],
),
];
for (final container in containers) {
addTearDown(container.dispose);
await container.read(musicPlayerControllerProvider).playSingle(_track);
}
expect(firstDecks.single.state, PlayerState.playing);
expect(secondDecks.single.state, PlayerState.playing);
expect(firstDecks.single.volumes.last, closeTo(0.501, 0.001));
expect(secondDecks.single.volumes.last, 1);
expect(firstDecks.single.outputOptions!.values, everyElement(isFalse));
expect(secondDecks.single.outputOptions!.values, everyElement(isTrue));
final toggled = <String>[];
first.configureNotification(
presentation: const PlaybackNotification(mornye: true),
toggleFavorite: (item) async {
toggled.add('first:${item.id}');
return true;
},
);
second.configureNotification(
presentation: const PlaybackNotification(mornye: true),
toggleFavorite: (item) async {
toggled.add('second:${item.id}');
return false;
},
);
await first.handler!.customAction(PlaybackNotification.favoriteAction);
expect(first.notification.loved, isTrue);
expect(second.notification.loved, isFalse);
await second.handler!.customAction(PlaybackNotification.favoriteAction);
expect(toggled, ['first:one', 'second:one']);
expect(
first.handler!.playbackState.value.controls.first.label,
'Remove from favorites',
);
expect(
second.handler!.playbackState.value.controls.first.label,
'Favorite',
);
first.configure(first.settings.copyWith(playbackNormalization: false));
await _settle();
expect(firstDecks.single.volumes.last, 1);
expect(second.settings.usbBitPerfect, isTrue);
expect(secondDecks.single.volumes, [1]);
},
);
}