mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 22:41:38 +02:00
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:
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
Reference in new issue
Block a user