test(player): split music_player_automix_test.dart by group

The 2,100-line file mixed four unrelated groups with the AutoMix tests.
The native-channel fake, analyzer and renderer fakes, the shared setUp
and tearDown, and fakeTimeTest move to
test/support/music_player_automix_harness.dart with public names. Each
top-level group gets its own file (haptics transport, playback failures,
system volume, USB transport); the ungrouped AutoMix, Autoplay, queue
and notification tests stay in music_player_automix_test.dart.

Test bodies are moved verbatim; each file aliases the shared tracks and
wait helper to their previous private names. The test count is
unchanged at 81 (50 + 4 + 5 + 6 + 16).
This commit is contained in:
zarzet committed 2026-10-19 05:10:50 +07:00
1 parent 8ef3bca69f
commit fe60a8ac46
6 files changed
+1128 -1058

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -0,0 +1,83 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
import 'support/music_player_automix_harness.dart';
const _tracks = testTracks;
const _until = waitUntil;
void main() {
setUpMusicPlayerHarness();
group('music haptics transport', () {
void enableHaptics() => musicPlayerRuntime.configure(
musicPlayerRuntime.settings.copyWith(musicHaptics: true),
);
test('direct documents work without AutoMix resolved metadata', () async {
enableHaptics();
const source = 'content://music/document/first.flac';
handler.mediaItem.add(
const MediaItem(
id: 'document',
title: 'Document',
extras: {'source': source},
),
);
handler.playbackState.add(
PlaybackState(
playing: true,
processingState: AudioProcessingState.ready,
),
);
await _until(() => hapticSongs.isNotEmpty);
expect(hapticSongs.first, 'document');
expect(hapticsAnalyzer.paths, [source]);
// A subsequent AutoMix-style metadata update keeps the same analysis.
handler.mediaItem.add(
handler.mediaItem.value!.copyWith(
extras: {'source': source, 'resolvedSource': '/cache/first.flac'},
),
);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(hapticsAnalyzer.paths, [source]);
});
for (final autoMix in [false, true]) {
test(
'first song and manual skips retain haptics (AutoMix: $autoMix)',
() async {
enableHaptics();
setAutoMixEnabled(autoMix);
await handler.setQueueAndPlay(_tracks);
await _until(() => hapticSongs.contains('one'));
await handler.skipToNext();
await _until(() => hapticSongs.contains('two'));
final beforePrevious = hapticSongs.length;
await handler.skipToPrevious();
await _until(() => hapticSongs.skip(beforePrevious).contains('one'));
expect(hapticSongs.last, 'one');
expect(hapticsAnalyzer.paths, [
'/one.flac',
'/two.flac',
'/one.flac',
]);
},
);
}
test(
'ordinary completion and a subsequent skip both keep haptics',
() async {
enableHaptics();
await handler.setQueueAndPlay(_tracks);
await _until(() => hapticSongs.contains('one'));
await native.event('music-player', 'audio.onComplete');
await _until(() => hapticSongs.contains('two'));
await handler.skipToNext();
await _until(() => hapticSongs.contains('three'));
},
);
});
}
@@ -0,0 +1,122 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'support/music_player_automix_harness.dart';
const _tracks = testTracks;
const _until = waitUntil;
void main() {
setUpMusicPlayerHarness();
group('playback failures', () {
test('runtime error stays on the song and Play retries it', () async {
await handler.setQueueAndPlay(_tracks);
await native.error('music-player');
await _until(
() =>
handler.playbackState.value.processingState ==
AudioProcessingState.error &&
native.playing.isEmpty,
);
expect(handler.playbackState.value.playing, isFalse);
expect(
handler.playbackState.value.errorMessage,
contains('Audio output'),
);
expect(handler.mediaItem.value?.id, 'one');
// Even a late completion from a failed output must not advance the queue.
await native.event('music-player', 'audio.onComplete');
await Future<void>.delayed(const Duration(milliseconds: 30));
expect(handler.playbackState.value.queueIndex, 0);
expect(
handler.playbackState.value.processingState,
AudioProcessingState.error,
);
await handler.play();
expect(handler.playbackState.value.playing, isTrue);
expect(handler.playbackState.value.errorMessage, isNull);
expect(handler.playbackState.value.errorCode, isNull);
expect(native.resumedSources, ['/one.flac', '/one.flac']);
await native.event('music-player', 'audio.onComplete');
await _until(() => handler.playbackState.value.queueIndex == 1);
});
test('source failure is reported and can be retried', () async {
native.sourceFailures.add('/one.flac');
await handler.setQueueAndPlay(_tracks);
expect(
handler.playbackState.value.processingState,
AudioProcessingState.error,
);
expect(handler.playbackState.value.playing, isFalse);
expect(handler.playbackState.value.queueIndex, 0);
expect(native.resumedSources, isEmpty);
native.sourceFailures.clear();
await handler.play();
expect(handler.playbackState.value.playing, isTrue);
expect(handler.mediaItem.value?.id, 'one');
expect(handler.playbackState.value.errorMessage, isNull);
});
test('error during resume cannot be overwritten by ready state', () async {
native.resumeFailures.add('music-player');
await handler.setQueueAndPlay(_tracks);
await _until(
() =>
handler.playbackState.value.processingState ==
AudioProcessingState.error,
);
expect(handler.playbackState.value.playing, isFalse);
expect(handler.playbackState.value.queueIndex, 0);
native.resumeFailures.clear();
await handler.skipToNext();
expect(handler.mediaItem.value?.id, 'two');
expect(handler.playbackState.value.playing, isTrue);
expect(handler.playbackState.value.errorMessage, isNull);
});
test(
'failed resume from pause reports an error without throwing',
() async {
await handler.setQueueAndPlay(_tracks);
await handler.pause();
native.resumeFailures.add('music-player');
await handler.play();
await _until(
() =>
handler.playbackState.value.processingState ==
AudioProcessingState.error,
);
expect(
handler.playbackState.value.processingState,
AudioProcessingState.error,
);
expect(handler.playbackState.value.playing, isFalse);
expect(handler.mediaItem.value?.id, 'one');
native.resumeFailures.clear();
await handler.play();
expect(handler.playbackState.value.playing, isTrue);
expect(handler.playbackState.value.errorMessage, isNull);
},
);
test('resume method failure is reported without escaping Play', () async {
await handler.setQueueAndPlay(_tracks);
await handler.pause();
native.failResumeMethod = true;
await handler.play();
expect(
handler.playbackState.value.processingState,
AudioProcessingState.error,
);
expect(handler.playbackState.value.playing, isFalse);
native.failResumeMethod = false;
await handler.play();
expect(handler.playbackState.value.playing, isTrue);
});
});
}
+152
View File
@@ -0,0 +1,152 @@
import 'dart:async';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/system_volume_service.dart';
import 'support/music_player_automix_harness.dart';
const _tracks = testTracks;
const _until = waitUntil;
void main() {
setUpMusicPlayerHarness();
group('automatic playback on system volume', () {
const events = MethodChannel(
'com.kurenai7968.volume_controller.volume_listener_event',
);
late List<String> volumeCalls;
Future<void> volume(double value) => native.messenger.handlePlatformMessage(
events.name,
const StandardMethodCodec().encodeSuccessEnvelope(value),
(_) {},
);
setUp(() {
volumeCalls = [];
native.messenger.setMockMethodCallHandler(events, (call) async {
volumeCalls.add(call.method);
return null;
});
});
tearDown(() async {
setPlaybackAutomationOptions(
enabled: true,
pauseOnMute: false,
playOnHeadphonesConnected: false,
);
await Future<void>.delayed(Duration.zero);
native.messenger.setMockMethodCallHandler(events, null);
});
Future<void> enable() async {
await handler.setQueueAndPlay(_tracks);
setPlaybackAutomationOptions(
enabled: true,
pauseOnMute: true,
playOnHeadphonesConnected: false,
);
await _until(() => volumeCalls.contains('listen'));
await volume(0.5);
}
test(
'mute pauses the real transport and unmute resumes the same song',
() async {
await enable();
await volume(0);
await _until(() => !handler.playbackState.value.playing);
expect(native.playing, isEmpty);
final current = handler.mediaItem.value!.id;
await volume(0.5);
await _until(() => handler.playbackState.value.playing);
expect(native.playing, {'music-player'});
expect(handler.mediaItem.value!.id, current);
},
);
test('manual pause cancels automatic resume in the handler', () async {
await enable();
await volume(0);
await _until(() => !handler.playbackState.value.playing);
await handler.pause();
await volume(0.5);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(handler.playbackState.value.playing, isFalse);
expect(native.playing, isEmpty);
});
test('manual pause wins while unmute resume is awaiting native', () async {
await enable();
await volume(0);
await _until(() => !handler.playbackState.value.playing);
final resumeCount = native.calls
.where((call) => call.$2 == 'resume')
.length;
native.resumeGate = Completer<void>();
await volume(0.5);
await _until(
() =>
native.calls.where((call) => call.$2 == 'resume').length >
resumeCount,
);
final paused = handler.pause();
native.resumeGate!.complete();
await paused;
expect(handler.playbackState.value.playing, isFalse);
expect(native.playing, isEmpty);
});
test(
'changing song during unmute cannot pause the replacement song',
() async {
await enable();
await volume(0);
await _until(() => !handler.playbackState.value.playing);
final resumeCount = native.calls
.where((call) => call.$2 == 'resume')
.length;
native.resumeGate = Completer<void>();
await volume(0.5);
await _until(
() =>
native.calls.where((call) => call.$2 == 'resume').length >
resumeCount,
);
final changed = handler.skipToNext();
native.resumeGate!.complete();
await changed;
expect(handler.mediaItem.value!.id, 'two');
expect(handler.playbackState.value.playing, isTrue);
expect(native.playing, {'music-player'});
},
);
test(
'opening and closing a volume slider preserves the background listener',
() async {
await enable();
final slider = SystemVolumeService.instance.changes.listen((_) {});
await Future<void>.delayed(Duration.zero);
await slider.cancel();
expect(volumeCalls.where((method) => method == 'listen'), hasLength(1));
expect(volumeCalls, isNot(contains('cancel')));
await volume(0);
await _until(() => !handler.playbackState.value.playing);
expect(native.playing, isEmpty);
},
);
test('USB output bypasses system mute automation', () async {
await enable();
setUsbBitPerfectEnabled(true);
await _until(() => volumeCalls.contains('cancel'));
await volume(0);
await Future<void>.delayed(const Duration(milliseconds: 50));
expect(handler.playbackState.value.playing, isTrue);
});
});
}
+412
View File
@@ -0,0 +1,412 @@
import 'dart:async';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/music_playback_deck.dart';
import 'support/music_player_automix_harness.dart';
const _until = waitUntil;
void main() {
setUpMusicPlayerHarness();
group('USB playback transport', () {
const methods = MethodChannel('com.zarz.spotiflac/usb_pcm');
const events = MethodChannel('com.zarz.spotiflac/usb_pcm/events');
late MusicPlaybackDeck deck;
late List<String> usbCalls;
late int token;
late String? fallbackReason;
late bool failResume;
late bool fatal;
late bool volumeBlocked;
Map<String, dynamic>? hardwareVolume;
late String transport;
late Map<dynamic, dynamic> prepareArguments;
Completer<void>? pauseReply;
Completer<Map<String, dynamic>>? prepareReply;
var position = 0;
Future<void> event(String name, {int? withToken}) =>
native.messenger.handlePlatformMessage(
events.name,
const StandardMethodCodec().encodeSuccessEnvelope({
'event': name,
'token': withToken ?? token,
}),
(_) {},
);
setUp(() {
usbCalls = [];
token = 0;
fallbackReason = null;
failResume = false;
fatal = false;
volumeBlocked = false;
hardwareVolume = null;
transport = 'pcm';
prepareArguments = {};
pauseReply = null;
prepareReply = null;
position = 0;
native.messenger.setMockMethodCallHandler(events, (_) async => null);
native.messenger.setMockMethodCallHandler(methods, (call) async {
usbCalls.add(call.method);
switch (call.method) {
case 'prepare':
prepareArguments = call.arguments as Map;
token = (call.arguments as Map)['token'] as int;
if (prepareReply != null) return prepareReply!.future;
if (fallbackReason != null) {
return {'reason': fallbackReason, 'fatal': fatal};
}
return {
'ready': true,
'duration': 60000,
'device': 'Test DAC',
'sampleRate': 96000,
'bitDepth': 24,
'transport': transport,
'driver': prepareArguments['direct'] == true
? 'usb_direct'
: 'android',
'volumeBlocked': volumeBlocked,
if (hardwareVolume != null)
'volume': {...hardwareVolume!, 'token': token},
};
case 'volume':
expect((call.arguments as Map)['token'], token);
return {
...hardwareVolume!,
'token': token,
'currentDb': (call.arguments as Map)['db'],
};
case 'position':
return position;
case 'seek':
position = (call.arguments as Map)['position'] as int;
case 'resume':
if (failResume) throw PlatformException(code: 'route_changed');
case 'pause':
await pauseReply?.future;
case 'stop':
if (prepareReply != null && !prepareReply!.isCompleted) {
prepareReply!.complete({'reason': 'cancelled'});
}
}
return null;
});
deck = MusicPlaybackDeck(playerId: 'usb-test', nativeAvailable: true);
});
test('direct driver and DoP are explicit and passed to native', () async {
await deck.setSource(
DeviceFileSource('/one.dsf'),
preferBitPerfect: true,
directUsb: true,
allowDop: true,
);
expect(prepareArguments['direct'], isTrue);
expect(prepareArguments['allowDop'], isTrue);
});
test('hardware volume changes the DAC, never PCM gain', () async {
hardwareVolume = {
'available': true,
'minDb': -90.0,
'maxDb': 0.0,
'currentDb': -40.0,
};
await deck.setSource(
DeviceFileSource('/one.flac'),
preferBitPerfect: true,
directUsb: true,
);
expect(usbHardwareVolume.value?.currentDb, -40);
await setUsbHardwareVolume(0.5);
expect(usbHardwareVolume.value?.currentDb, -45);
expect(usbCalls, ['prepare', 'volume']);
expect(native.calls.where((call) => call.$2 == 'setVolume'), isEmpty);
await deck.stop();
expect(usbHardwareVolume.value, isNull);
});
test(
'uncontrolled DAC is blocked without ordinary full-volume fallback',
() async {
volumeBlocked = true;
hardwareVolume = {'available': false};
await deck.setSource(
DeviceFileSource('/one.flac'),
preferBitPerfect: true,
directUsb: true,
);
await expectLater(deck.resume(), throwsA(isA<UsbOutputUnavailable>()));
expect(usbCalls, ['prepare']);
expect(usbAudioStatus.value.reason, 'volume_unavailable');
expect(native.sources['usb-test'], isNull);
await setUsbHardwareVolume(1);
expect(usbCalls, ['prepare']);
},
);
test(
'AAudio exclusive and fixed volume require explicit options',
() async {
await deck.setSource(
DeviceFileSource('/one.wav'),
preferBitPerfect: true,
dapExclusive: true,
);
expect(prepareArguments['dapExclusive'], isTrue);
expect(prepareArguments['allowFixedVolume'], isFalse);
expect(prepareArguments['direct'], isFalse);
fallbackReason = 'exclusive_unavailable';
await deck.setSource(
DeviceFileSource('/one.wav'),
preferBitPerfect: true,
dapExclusive: true,
);
expect(usbAudioStatus.value.reason, 'exclusive_unavailable');
expect(deck.isDirect, isFalse);
expect(native.sources['usb-test'], '/one.wav');
},
);
test(
'changing tracks cancels a pending USB permission without fallback',
() async {
prepareReply = Completer<Map<String, dynamic>>();
final preparing = deck.setSource(
DeviceFileSource('/one.flac'),
preferBitPerfect: true,
directUsb: true,
);
await _until(() => usbCalls.contains('prepare'));
await deck.cancelPreparation();
await preparing;
expect(usbCalls, ['prepare', 'stop']);
expect(native.sources['usb-test'], isNull);
expect(deck.isDirect, isFalse);
},
);
test(
'unsupported DSD never falls back through ordinary PCM output',
() async {
fallbackReason = 'dsd_unsupported';
fatal = true;
await expectLater(
deck.setSource(
DeviceFileSource('/one.dsf'),
preferBitPerfect: true,
directUsb: true,
),
throwsStateError,
);
expect(native.sources['usb-test'], isNull);
expect(usbAudioStatus.value.reason, 'dsd_unsupported');
},
);
test('failed first DSD start does not switch to normal playback', () async {
transport = 'dop';
await deck.setSource(
DeviceFileSource('/one.dsf'),
preferBitPerfect: true,
directUsb: true,
);
failResume = true;
await expectLater(deck.resume(), throwsA(isA<UsbOutputUnavailable>()));
expect(native.sources['usb-test'], isNull);
expect(deck.needsSourceReload, isTrue);
});
test('failed first DoP start pauses without normal playback', () async {
transport = 'dop';
failResume = true;
await deck.setSource(
DeviceFileSource('/one.dsf'),
preferBitPerfect: true,
allowDop: true,
);
await expectLater(deck.resume(), throwsA(isA<UsbDsdUnavailable>()));
expect(deck.state, PlayerState.paused);
expect(deck.needsSourceReload, isTrue);
expect(usbAudioStatus.value.reason, 'dsd_unsupported');
expect(native.sources['usb-test'], isNull);
expect(native.playing, isEmpty);
expect(usbCalls, ['prepare', 'resume']);
});
test('DSD without a DAC never loads a normal audio source', () async {
fallbackReason = 'no_usb';
await expectLater(
deck.setSource(
DeviceFileSource('/one.dsf'),
preferBitPerfect: true,
directUsb: true,
requiresDsd: true,
),
throwsA(isA<UsbDsdUnavailable>()),
);
expect(native.sources['usb-test'], isNull);
});
tearDown(() async {
await deck.dispose();
native.messenger.setMockMethodCallHandler(methods, null);
native.messenger.setMockMethodCallHandler(events, null);
});
test(
'verified route plays, pauses and seeks without software gain or rate changes',
() async {
await deck.setSource(
DeviceFileSource('/one.flac'),
preferBitPerfect: true,
);
expect(usbAudioStatus.value.reason, 'ready');
await deck.resume();
await deck.setVolume(0.4);
await deck.setPlaybackRate(1.1);
expect(native.playing, isEmpty);
expect(usbAudioStatus.value.reason, 'active');
expect(usbAudioStatus.value.sampleRate, 96000);
await deck.seek(const Duration(seconds: 21));
expect(await deck.getCurrentPosition(), const Duration(seconds: 21));
await deck.pause();
expect(deck.state, PlayerState.paused);
expect(usbCalls, ['prepare', 'resume', 'seek', 'position', 'pause']);
expect(
native.calls.where(
(c) =>
c.$1 == 'usb-test' &&
['setVolume', 'setPlaybackRate'].contains(c.$2),
),
isEmpty,
);
},
);
test('late pause reply does not pause a newly prepared source', () async {
await deck.setSource(
DeviceFileSource('/one.flac'),
preferBitPerfect: true,
);
await deck.resume();
pauseReply = Completer<void>();
final pausing = deck.pause();
await _until(() => usbCalls.contains('pause'));
await deck.setSource(
DeviceFileSource('/two.flac'),
preferBitPerfect: true,
);
await deck.resume();
pauseReply!.complete();
await pausing;
expect(deck.state, PlayerState.playing);
expect(usbAudioStatus.value.reason, 'active');
});
test('missing support falls back without reporting bit-perfect', () async {
fallbackReason = 'no_usb';
await deck.setSource(
DeviceFileSource('/one.flac'),
preferBitPerfect: true,
);
await deck.resume();
expect(deck.isDirect, isFalse);
expect(native.playing, {'usb-test'});
expect(usbAudioStatus.value.reason, 'no_usb');
});
test('unverified route falls back before starting USB audio', () async {
failResume = true;
await deck.setSource(
DeviceFileSource('/one.flac'),
preferBitPerfect: true,
);
await deck.resume();
expect(deck.isDirect, isFalse);
expect(native.sources['usb-test'], '/one.flac');
expect(native.playing, {'usb-test'});
expect(usbAudioStatus.value.reason, 'unsupported');
});
test(
'direct USB failure before first sound never redirects to speaker',
() async {
failResume = true;
await deck.setSource(
DeviceFileSource('/one.flac'),
preferBitPerfect: true,
directUsb: true,
);
await expectLater(deck.resume(), throwsA(isA<UsbOutputUnavailable>()));
expect(native.sources['usb-test'], isNull);
expect(native.playing, isEmpty);
expect(deck.state, PlayerState.paused);
expect(deck.needsSourceReload, isTrue);
expect(usbAudioStatus.value.reason, 'route_changed');
},
);
test(
'route loss pauses and never starts the speaker automatically',
() async {
await deck.setSource(
DeviceFileSource('/one.flac'),
preferBitPerfect: true,
);
await deck.resume();
var statusChanges = 0;
void onStatusChanged() => statusChanges++;
usbAudioStatus.addListener(onStatusChanged);
addTearDown(() => usbAudioStatus.removeListener(onStatusChanged));
await event('paused');
await _until(() => deck.state == PlayerState.paused);
expect(native.playing, isEmpty);
expect(usbAudioStatus.value.reason, 'route_changed');
expect(statusChanges, 1);
position = 19000;
expect(deck.needsSourceReload, isTrue);
await expectLater(deck.resume(), throwsA(isA<PlatformException>()));
expect(native.playing, isEmpty);
expect(deck.state, PlayerState.paused);
expect(statusChanges, 1);
expect(await deck.getCurrentPosition(), const Duration(seconds: 19));
},
);
test(
'events from an old song cannot pause or complete the new song',
() async {
await deck.setSource(
DeviceFileSource('/one.flac'),
preferBitPerfect: true,
);
final oldToken = token;
await deck.setSource(
DeviceFileSource('/two.flac'),
preferBitPerfect: true,
);
await deck.resume();
var completed = 0;
final subscription = deck.onPlayerComplete.listen((_) => completed++);
await event('complete', withToken: oldToken);
await event('paused', withToken: oldToken);
await Future<void>.delayed(const Duration(milliseconds: 20));
expect(deck.state, PlayerState.playing);
expect(completed, 0);
await event('complete');
await _until(() => completed == 1);
expect(deck.state, PlayerState.completed);
await subscription.cancel();
},
);
});
}
@@ -0,0 +1,355 @@
import 'dart:async';
import 'dart:io';
import 'dart:typed_data';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/services/automix_analysis.dart';
import 'package:spotiflac_android/services/automix_analyzer.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
import 'package:spotiflac_android/services/music_haptics.dart';
import 'package:spotiflac_android/services/playback_notification.dart';
import 'package:spotiflac_android/models/automix_options.dart';
import 'package:spotiflac_android/services/automix_effect_renderer.dart';
import 'package:spotiflac_android/services/automix_status.dart';
import 'virtual_time.dart';
class FakeEffectRenderer extends AutoMixEffectRenderer {
final tails = <RenderedMixTail>[];
bool fail = false;
@override
Future<RenderedMixTail?> render(
String path, {
required Duration start,
required Duration duration,
required AutoMixOptions options,
}) async {
if (fail) return null;
final work = await Directory.systemTemp.createTemp('fake-mix-tail-');
final tail = RenderedMixTail(work, '${work.path}/tail.wav');
tails.add(tail);
return tail;
}
}
class FakeAutoMixAnalyzer extends AutoMixAnalyzer {
final calls = <String>[];
Completer<AutoMixBeatGrid?>? pending;
bool matchBeats = false;
@override
Future<AutoMixBeatGrid?> analyze(String path, {double offset = 0}) async {
calls.add(path);
if (matchBeats) {
return AutoMixBeatGrid(
bpm: offset > 0 ? 120 : 124,
phase: 0.17,
confidence: 0.9,
firstSound: 0.1,
);
}
return pending?.future;
}
}
class FakeHapticsAnalyzer extends MusicHapticsAnalyzer {
final paths = <String>[];
@override
Future<HapticTrack?> analyze(String path) async {
paths.add(path);
return HapticTrack(
Int32List.fromList(List.generate(20, (index) => (index + 1) * 150)),
Uint8List.fromList(List.filled(20, 255)),
Uint8List(20),
);
}
}
/// Exercises the real AudioPlayer transport/streams against a native-channel
/// fake, including preparation, seek completion and outgoing completion events.
class AudioNative {
AudioNative([this.newClock = Stopwatch.new]);
/// Times mix and effect decks.
final Stopwatch Function() newClock;
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
final calls = <(String, String, Map<Object?, Object?>)>[];
final positions = <String, int>{};
final live = <String>{};
final playing = <String>{};
final sources = <String, String>{};
final sourceContents = <String, String>{};
final preparedContents = <String, String>{};
final sourceGates = <String, Completer<void>>{};
final sourceFailures = <String>{};
final resumeFailures = <String>{};
bool failResumeMethod = false;
final resumedSources = <String>[];
Completer<void>? resumeGate;
final resumeGates = <String, Completer<void>>{};
final stalled = <String>{};
final clocks = <String, Stopwatch>{};
bool emitsDuration = true;
void install() {
for (final name in [
'xyz.luan/audioplayers.global',
'xyz.luan/audioplayers.global/events',
]) {
messenger.setMockMethodCallHandler(
MethodChannel(name),
(_) async => null,
);
}
messenger.setMockMethodCallHandler(
const MethodChannel('xyz.luan/audioplayers'),
(call) async {
final args = call.arguments as Map<Object?, Object?>;
final id = args['playerId']! as String;
calls.add((id, call.method, args));
switch (call.method) {
case 'create':
live.add(id);
positions[id] = 0;
messenger.setMockMethodCallHandler(
MethodChannel('xyz.luan/audioplayers/events/$id'),
(_) async => null,
);
case 'setSourceUrl':
final source = args['url']! as String;
await sourceGates[source]?.future;
if (sourceFailures.contains(source)) {
unawaited(error(id));
return null;
}
// Android caches equal URLs, including recycled SAF descriptors.
if (sources[id] != source) {
sources[id] = source;
preparedContents[id] = sourceContents[source] ?? source;
}
unawaited(event(id, 'audio.onPrepared', true));
if (emitsDuration) unawaited(event(id, 'audio.onDuration', 60000));
case 'seek':
positions[id] = args['position']! as int;
clocks[id]?.reset();
unawaited(event(id, 'audio.onSeekComplete'));
case 'resume':
await resumeGate?.future;
await resumeGates[id]?.future;
if (failResumeMethod) {
throw PlatformException(code: 'AndroidAudioError');
}
if (resumeFailures.contains(id)) {
await error(id);
return null;
}
playing.add(id);
resumedSources.add(sources[id]!);
if (id.startsWith('music-mix-') || id.startsWith('music-effect-')) {
clocks[id] = newClock()..start();
}
case 'pause' || 'stop':
playing.remove(id);
clocks[id]?.stop();
case 'release':
playing.remove(id);
sources.remove(id);
preparedContents.remove(id);
case 'dispose':
playing.remove(id);
live.remove(id);
case 'getDuration':
return 60000;
case 'getCurrentPosition':
return (positions[id] ?? 0) +
(stalled.contains(id)
? 0
: clocks[id]?.elapsedMilliseconds ?? 0);
}
return null;
},
);
}
Future<void> event(String id, String name, [Object? value]) async {
await messenger.handlePlatformMessage(
'xyz.luan/audioplayers/events/$id',
const StandardMethodCodec().encodeSuccessEnvelope({
'event': name,
'value': ?value,
}),
(_) {},
);
}
Future<void> error(String id) async {
await messenger.handlePlatformMessage(
'xyz.luan/audioplayers/events/$id',
const StandardMethodCodec().encodeErrorEnvelope(
code: 'AndroidAudioError',
message: 'Audio output failed',
details: 'MEDIA_ERROR_SYSTEM',
),
(_) {},
);
}
String get prepared => live.singleWhere((id) => id != 'music-player');
double? lastVolume(String id) =>
calls
.where((call) => call.$1 == id && call.$2 == 'setVolume')
.lastOrNull
?.$3['volume']
as double?;
}
const testTracks = [
PlayableMedia(id: 'one', source: '/one.flac', title: 'One', artist: 'Artist'),
PlayableMedia(id: 'two', source: '/two.flac', title: 'Two', artist: 'Artist'),
PlayableMedia(
id: 'three',
source: '/three.flac',
title: 'Three',
artist: 'Artist',
),
];
Future<void> waitUntil(bool Function() ready) async {
for (var i = 0; i < 100; i++) {
if (ready()) return;
await Future<void>.delayed(const Duration(milliseconds: 20));
}
fail('Playback did not reach the expected state');
}
void configurePlaybackNotification({
required PlaybackNotification presentation,
required Future<bool> Function(MediaItem) toggleFavorite,
}) => musicPlayerRuntime.configureNotification(
presentation: presentation,
toggleFavorite: toggleFavorite,
);
void setPlaybackAutomationOptions({
required bool enabled,
required bool pauseOnMute,
required bool playOnHeadphonesConnected,
}) => musicPlayerRuntime.configure(
musicPlayerRuntime.settings.copyWith(
playerMode: enabled ? 'internal' : 'external',
pauseOnMute: pauseOnMute,
playOnHeadphonesConnected: playOnHeadphonesConnected,
),
);
void setAutoMixEnabled(bool enabled) => musicPlayerRuntime.configure(
musicPlayerRuntime.settings.copyWith(autoMix: enabled),
);
void setAutoMixOptions(AutoMixOptions options) => musicPlayerRuntime.configure(
musicPlayerRuntime.settings.copyWith(
autoMixDuration: options.durationSeconds,
autoMixEffect: options.effect,
autoMixSpeed: options.speed,
autoMixPitch: options.pitchSemitones,
autoMixEcho: options.echo,
autoMixLowPass: options.lowPass,
),
);
void setUsbBitPerfectEnabled(bool enabled) => musicPlayerRuntime.configure(
musicPlayerRuntime.settings.copyWith(usbBitPerfect: enabled),
);
void setAutoplayEnabled(bool enabled, {bool includeLocal = true}) =>
musicPlayerRuntime.configure(
musicPlayerRuntime.settings.copyWith(
autoplay: enabled,
localLibraryEnabled: includeLocal,
),
);
// The player under test and its fakes, rebuilt for every test by the setUp
// that [setUpMusicPlayerHarness] registers.
late AudioNative native;
late FakeAutoMixAnalyzer analyzer;
late FakeEffectRenderer effectRenderer;
late MusicPlayerHandler handler;
late AutoplayLibraryLoader autoplayLoader;
late FakeHapticsAnalyzer hapticsAnalyzer;
late List<String?> hapticSongs;
late VirtualTime time;
void _setUpPlayer() {
musicPlayerRuntime.configure(
musicPlayerRuntime.settings.copyWith(musicHaptics: false),
);
setPlaybackAutomationOptions(
enabled: true,
pauseOnMute: false,
playOnHeadphonesConnected: false,
);
setAutoMixEnabled(false);
setAutoMixOptions(const AutoMixOptions());
setUsbBitPerfectEnabled(false);
setAutoplayEnabled(false);
native = AudioNative(time.stopwatch)..install();
analyzer = FakeAutoMixAnalyzer();
effectRenderer = FakeEffectRenderer();
autoplayLoader = (_) async => testTracks;
hapticsAnalyzer = FakeHapticsAnalyzer();
hapticSongs = [];
handler = MusicPlayerHandler(
autoMixAnalyzer: analyzer,
autoMixEffectRenderer: effectRenderer,
autoplayLibraryLoader: (seed) => autoplayLoader(seed),
hapticsAnalyzer: hapticsAnalyzer,
hapticPulse: (_, _) async => hapticSongs.add(handler.mediaItem.value?.id),
);
}
/// Registers the shared player setUp and tearDown in the calling `main`.
void setUpMusicPlayerHarness() {
TestWidgetsFlutterBinding.ensureInitialized();
SharedPreferences.setMockInitialValues({});
setUp(() {
time = VirtualTime();
autoMixStopwatch = time.stopwatch;
addTearDown(() => autoMixStopwatch = Stopwatch.new);
// Timers follow the zone of the listener that creates them, so the player
// is built in the zone that fakeTimeTest can switch to virtual time.
time.zone.run(_setUpPlayer);
});
tearDown(() async {
await handler.dispose();
configurePlaybackNotification(
presentation: const PlaybackNotification(),
toggleFavorite: (_) async => false,
);
analyzer.pending?.complete(null);
setAutoMixEnabled(false);
setUsbBitPerfectEnabled(false);
setAutoplayEnabled(false);
await Future<void>.delayed(const Duration(milliseconds: 30));
expect(native.live, isEmpty);
expect(native.playing, isEmpty);
expect(autoMixStatus.value, AutoMixStatus.idle);
for (final tail in effectRenderer.tails) {
expect(await tail.directory.exists(), isFalse);
}
});
}
/// A test whose waits (fades, readiness checks, waits for nothing to
/// happen) run on virtual time instead of the wall clock.
void fakeTimeTest(String description, Future<void> Function() body) =>
test(description, () => time.run(body));