diff --git a/test/music_player_automix_test.dart b/test/music_player_automix_test.dart index 84bd89ca..530c9b54 100644 --- a/test/music_player_automix_test.dart +++ b/test/music_player_automix_test.dart @@ -1,357 +1,21 @@ import 'dart:async'; -import 'dart:io'; -import 'dart:typed_data'; import 'package:audio_service/audio_service.dart'; -import 'package:audioplayers/audioplayers.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_playback_deck.dart'; -import 'package:spotiflac_android/services/music_haptics.dart'; import 'package:spotiflac_android/models/settings.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 'package:spotiflac_android/services/system_volume_service.dart'; -import 'support/virtual_time.dart'; +import 'support/music_player_automix_harness.dart'; -class _EffectRenderer extends AutoMixEffectRenderer { - final tails = []; - bool fail = false; - - @override - Future 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 _Analyzer extends AutoMixAnalyzer { - final calls = []; - Completer? pending; - bool matchBeats = false; - - @override - Future 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 _HapticsAnalyzer extends MusicHapticsAnalyzer { - final paths = []; - - @override - Future 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)>[]; - final positions = {}; - final live = {}; - final playing = {}; - final sources = {}; - final sourceContents = {}; - final preparedContents = {}; - final sourceGates = >{}; - final sourceFailures = {}; - final resumeFailures = {}; - bool failResumeMethod = false; - final resumedSources = []; - Completer? resumeGate; - final resumeGates = >{}; - final stalled = {}; - final clocks = {}; - 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; - 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 event(String id, String name, [Object? value]) async { - await messenger.handlePlatformMessage( - 'xyz.luan/audioplayers/events/$id', - const StandardMethodCodec().encodeSuccessEnvelope({ - 'event': name, - 'value': ?value, - }), - (_) {}, - ); - } - - Future 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 _tracks = [ - 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 _until(bool Function() ready) async { - for (var i = 0; i < 100; i++) { - if (ready()) return; - await Future.delayed(const Duration(milliseconds: 20)); - } - fail('Playback did not reach the expected state'); -} - -void configurePlaybackNotification({ - required PlaybackNotification presentation, - required Future 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, - ), - ); +const _tracks = testTracks; +const _until = waitUntil; void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - SharedPreferences.setMockInitialValues({}); - late _AudioNative native; - late _Analyzer analyzer; - late _EffectRenderer effectRenderer; - late MusicPlayerHandler handler; - late AutoplayLibraryLoader autoplayLoader; - late _HapticsAnalyzer hapticsAnalyzer; - late List 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 = _Analyzer(); - effectRenderer = _EffectRenderer(); - autoplayLoader = (_) async => _tracks; - hapticsAnalyzer = _HapticsAnalyzer(); - hapticSongs = []; - handler = MusicPlayerHandler( - autoMixAnalyzer: analyzer, - autoMixEffectRenderer: effectRenderer, - autoplayLibraryLoader: (seed) => autoplayLoader(seed), - hapticsAnalyzer: hapticsAnalyzer, - hapticPulse: (_, _) async => hapticSongs.add(handler.mediaItem.value?.id), - ); - } - - 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.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 Function() body) => - test(description, () => time.run(body)); + setUpMusicPlayerHarness(); test( 'USB preference is opt-in and persists with existing DSP preferences', @@ -404,724 +68,6 @@ void main() { }, ); - 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.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')); - }, - ); - }); - - 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.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); - }); - }); - - group('automatic playback on system volume', () { - const events = MethodChannel( - 'com.kurenai7968.volume_controller.volume_listener_event', - ); - late List volumeCalls; - - Future 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.delayed(Duration.zero); - native.messenger.setMockMethodCallHandler(events, null); - }); - - Future 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.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(); - 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(); - 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.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.delayed(const Duration(milliseconds: 50)); - expect(handler.playbackState.value.playing, isTrue); - }); - }); - - 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 usbCalls; - late int token; - late String? fallbackReason; - late bool failResume; - late bool fatal; - late bool volumeBlocked; - Map? hardwareVolume; - late String transport; - late Map prepareArguments; - Completer? pauseReply; - Completer>? prepareReply; - var position = 0; - - Future 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())); - 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>(); - 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())); - 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())); - 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()), - ); - 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(); - 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())); - 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())); - 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.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(); - }, - ); - }); - test( 'Autoplay fills a single-track queue and follows its visible order', () async { diff --git a/test/music_player_haptics_transport_test.dart b/test/music_player_haptics_transport_test.dart new file mode 100644 index 00000000..bb79afa1 --- /dev/null +++ b/test/music_player_haptics_transport_test.dart @@ -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.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')); + }, + ); + }); +} diff --git a/test/music_player_playback_failures_test.dart b/test/music_player_playback_failures_test.dart new file mode 100644 index 00000000..5afb5449 --- /dev/null +++ b/test/music_player_playback_failures_test.dart @@ -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.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); + }); + }); +} diff --git a/test/music_player_system_volume_test.dart b/test/music_player_system_volume_test.dart new file mode 100644 index 00000000..750804d9 --- /dev/null +++ b/test/music_player_system_volume_test.dart @@ -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 volumeCalls; + + Future 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.delayed(Duration.zero); + native.messenger.setMockMethodCallHandler(events, null); + }); + + Future 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.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(); + 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(); + 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.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.delayed(const Duration(milliseconds: 50)); + expect(handler.playbackState.value.playing, isTrue); + }); + }); +} diff --git a/test/music_player_usb_transport_test.dart b/test/music_player_usb_transport_test.dart new file mode 100644 index 00000000..924e6e9a --- /dev/null +++ b/test/music_player_usb_transport_test.dart @@ -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 usbCalls; + late int token; + late String? fallbackReason; + late bool failResume; + late bool fatal; + late bool volumeBlocked; + Map? hardwareVolume; + late String transport; + late Map prepareArguments; + Completer? pauseReply; + Completer>? prepareReply; + var position = 0; + + Future 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())); + 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>(); + 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())); + 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())); + 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()), + ); + 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(); + 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())); + 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())); + 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.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(); + }, + ); + }); +} diff --git a/test/support/music_player_automix_harness.dart b/test/support/music_player_automix_harness.dart new file mode 100644 index 00000000..5c912e9a --- /dev/null +++ b/test/support/music_player_automix_harness.dart @@ -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 = []; + bool fail = false; + + @override + Future 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 = []; + Completer? pending; + bool matchBeats = false; + + @override + Future 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 = []; + + @override + Future 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)>[]; + final positions = {}; + final live = {}; + final playing = {}; + final sources = {}; + final sourceContents = {}; + final preparedContents = {}; + final sourceGates = >{}; + final sourceFailures = {}; + final resumeFailures = {}; + bool failResumeMethod = false; + final resumedSources = []; + Completer? resumeGate; + final resumeGates = >{}; + final stalled = {}; + final clocks = {}; + 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; + 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 event(String id, String name, [Object? value]) async { + await messenger.handlePlatformMessage( + 'xyz.luan/audioplayers/events/$id', + const StandardMethodCodec().encodeSuccessEnvelope({ + 'event': name, + 'value': ?value, + }), + (_) {}, + ); + } + + Future 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 waitUntil(bool Function() ready) async { + for (var i = 0; i < 100; i++) { + if (ready()) return; + await Future.delayed(const Duration(milliseconds: 20)); + } + fail('Playback did not reach the expected state'); +} + +void configurePlaybackNotification({ + required PlaybackNotification presentation, + required Future 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 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.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 Function() body) => + test(description, () => time.run(body));