mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 23:11:45 +02:00
282 lines
9.8 KiB
Dart
282 lines
9.8 KiB
Dart
// Exercises the typed device API used by PlaybackAutomation.
|
|
// ignore_for_file: experimental_member_use
|
|
|
|
import 'dart:async';
|
|
|
|
import 'package:audio_session/audio_session.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:spotiflac_android/models/settings.dart';
|
|
import 'package:spotiflac_android/services/playback_automation.dart';
|
|
|
|
AudioDevice device(String id, AudioDeviceType type, {bool output = true}) =>
|
|
AudioDevice(
|
|
id: id,
|
|
name: id,
|
|
isInput: !output,
|
|
isOutput: output,
|
|
type: type,
|
|
);
|
|
|
|
class _Transport {
|
|
bool playing = true;
|
|
bool available = true;
|
|
int pauses = 0;
|
|
int plays = 0;
|
|
Completer<void>? pauseGate;
|
|
bool failPause = false;
|
|
final List<Object> errors = [];
|
|
|
|
late final PlaybackAutomation automation = PlaybackAutomation(
|
|
isPlaying: () => playing,
|
|
canResume: () => available,
|
|
pause: () async {
|
|
pauses++;
|
|
if (failPause) throw StateError('pause failed');
|
|
await pauseGate?.future;
|
|
playing = false;
|
|
},
|
|
play: () async {
|
|
plays++;
|
|
playing = true;
|
|
},
|
|
onError: errors.add,
|
|
);
|
|
|
|
Future<void> enable({bool mute = true, bool headphones = false}) => automation
|
|
.configure(pauseOnMute: mute, playOnHeadphonesConnected: headphones);
|
|
}
|
|
|
|
void main() {
|
|
test('mute automation defaults on and saved preferences survive restore', () {
|
|
final defaults = AppSettings.fromJson({});
|
|
expect(const AppSettings().pauseOnMute, isTrue);
|
|
expect(defaults.pauseOnMute, isTrue);
|
|
expect(defaults.playOnHeadphonesConnected, isFalse);
|
|
final settings = defaults.copyWith(
|
|
pauseOnMute: false,
|
|
playOnHeadphonesConnected: true,
|
|
);
|
|
final restored = AppSettings.fromJson(settings.toJson());
|
|
expect(restored.pauseOnMute, isFalse);
|
|
expect(restored.playOnHeadphonesConnected, isTrue);
|
|
expect(restored.copyWith(autoMix: true).pauseOnMute, isFalse);
|
|
});
|
|
|
|
test('mute pauses once; unmute resumes only a pause owned by mute', () async {
|
|
final transport = _Transport();
|
|
await transport.enable();
|
|
await transport.automation.volumeChanged(0.4);
|
|
expect(transport.pauses, 0);
|
|
await transport.automation.volumeChanged(0);
|
|
await transport.automation.volumeChanged(0);
|
|
expect(transport.pauses, 1);
|
|
await transport.automation.volumeChanged(0.01);
|
|
await transport.automation.volumeChanged(0.4);
|
|
expect(transport.plays, 1);
|
|
expect(transport.playing, isTrue);
|
|
await transport.automation.dispose();
|
|
});
|
|
|
|
test('an already paused song cannot start on unmute', () async {
|
|
final transport = _Transport()..playing = false;
|
|
await transport.enable();
|
|
await transport.automation.volumeChanged(0);
|
|
await transport.automation.volumeChanged(0.5);
|
|
expect(transport.pauses, 0);
|
|
expect(transport.plays, 0);
|
|
await transport.automation.dispose();
|
|
});
|
|
|
|
for (final reason in ['manual pause', 'sleep timer', 'focus loss', 'stop']) {
|
|
test('$reason cancels an unmute resume', () async {
|
|
final transport = _Transport();
|
|
await transport.enable();
|
|
await transport.automation.volumeChanged(0);
|
|
transport.automation.cancelPendingActions();
|
|
await transport.automation.volumeChanged(0.5);
|
|
expect(transport.plays, 0);
|
|
await transport.automation.dispose();
|
|
});
|
|
}
|
|
|
|
test('rapid mute and unmute do not leave a paused song behind', () async {
|
|
final transport = _Transport()..pauseGate = Completer<void>();
|
|
await transport.enable();
|
|
final muted = transport.automation.volumeChanged(0);
|
|
await Future<void>.delayed(Duration.zero);
|
|
expect(transport.pauses, 1);
|
|
final unmuted = transport.automation.volumeChanged(0.5);
|
|
transport.pauseGate!.complete();
|
|
await Future.wait([muted, unmuted]);
|
|
expect(transport.plays, 1);
|
|
expect(transport.playing, isTrue);
|
|
await transport.automation.dispose();
|
|
});
|
|
|
|
test('explicit pause during an automatic pause cancels resume', () async {
|
|
final transport = _Transport()..pauseGate = Completer<void>();
|
|
await transport.enable();
|
|
final muted = transport.automation.volumeChanged(0);
|
|
await Future<void>.delayed(Duration.zero);
|
|
transport.automation.cancelPendingActions();
|
|
final unmuted = transport.automation.volumeChanged(0.5);
|
|
transport.pauseGate!.complete();
|
|
await Future.wait([muted, unmuted]);
|
|
expect(transport.plays, 0);
|
|
await transport.automation.dispose();
|
|
});
|
|
|
|
test('disabled preferences and unavailable sessions do nothing', () async {
|
|
final transport = _Transport();
|
|
await transport.automation.volumeChanged(0);
|
|
expect(transport.pauses, 0);
|
|
transport.available = false;
|
|
await transport.enable();
|
|
expect(transport.pauses, 0);
|
|
await transport.automation.dispose();
|
|
});
|
|
|
|
test('turning mute automation off cancels its pending resume', () async {
|
|
final transport = _Transport();
|
|
await transport.enable();
|
|
await transport.automation.volumeChanged(0);
|
|
await transport.enable(mute: false);
|
|
await transport.automation.volumeChanged(0.5);
|
|
expect(transport.plays, 0);
|
|
await transport.automation.dispose();
|
|
});
|
|
|
|
test('starting a new song at zero volume pauses it', () async {
|
|
final transport = _Transport()..playing = false;
|
|
await transport.enable();
|
|
await transport.automation.volumeChanged(0);
|
|
transport.playing = true;
|
|
await transport.automation.playbackStarted();
|
|
expect(transport.pauses, 1);
|
|
await transport.automation.volumeChanged(0.5);
|
|
expect(transport.plays, 1);
|
|
await transport.automation.dispose();
|
|
});
|
|
|
|
test('failed pause is reported and cannot trigger unmute autoplay', () async {
|
|
final transport = _Transport()..failPause = true;
|
|
await transport.enable();
|
|
await transport.automation.volumeChanged(0);
|
|
await transport.automation.volumeChanged(0.5);
|
|
expect(transport.errors, hasLength(1));
|
|
expect(transport.plays, 0);
|
|
await transport.automation.dispose();
|
|
});
|
|
|
|
test(
|
|
'initial headphone inventory does not start a restored session',
|
|
() async {
|
|
final transport = _Transport()..playing = false;
|
|
await transport.enable(headphones: true);
|
|
await transport.automation.devicesChanged({
|
|
device('wired', AudioDeviceType.wiredHeadphones),
|
|
});
|
|
expect(transport.plays, 0);
|
|
await transport.automation.dispose();
|
|
},
|
|
);
|
|
|
|
for (final type in [
|
|
AudioDeviceType.wiredHeadset,
|
|
AudioDeviceType.wiredHeadphones,
|
|
AudioDeviceType.usbAudio,
|
|
AudioDeviceType.bluetoothA2dp,
|
|
AudioDeviceType.bluetoothLe,
|
|
]) {
|
|
test('$type connects once and disconnecting pauses', () async {
|
|
final transport = _Transport()..playing = false;
|
|
await transport.enable(headphones: true);
|
|
await transport.automation.volumeChanged(0.5);
|
|
await transport.automation.devicesChanged({});
|
|
final headphones = {device('headphones', type)};
|
|
await transport.automation.devicesChanged(headphones);
|
|
await transport.automation.devicesChanged(headphones);
|
|
expect(transport.plays, 1);
|
|
await transport.automation.devicesChanged({});
|
|
expect(transport.pauses, 1);
|
|
expect(transport.playing, isFalse);
|
|
await transport.automation.dispose();
|
|
});
|
|
}
|
|
|
|
test(
|
|
'speakers, microphones and Bluetooth call profiles do not autoplay',
|
|
() async {
|
|
final transport = _Transport()..playing = false;
|
|
await transport.enable(headphones: true);
|
|
await transport.automation.devicesChanged({});
|
|
await transport.automation.devicesChanged({
|
|
device('speaker', AudioDeviceType.builtInSpeaker),
|
|
device('call', AudioDeviceType.bluetoothSco),
|
|
device('mic', AudioDeviceType.wiredHeadset, output: false),
|
|
});
|
|
expect(transport.plays, 0);
|
|
await transport.automation.dispose();
|
|
},
|
|
);
|
|
|
|
test('headphone connections respect mute and audio interruptions', () async {
|
|
final transport = _Transport()..playing = false;
|
|
await transport.enable(headphones: true);
|
|
await transport.automation.devicesChanged({});
|
|
await transport.automation.volumeChanged(0);
|
|
await transport.automation.devicesChanged({
|
|
device('wired', AudioDeviceType.wiredHeadphones),
|
|
});
|
|
expect(transport.plays, 0);
|
|
await transport.automation.volumeChanged(0.5);
|
|
expect(transport.plays, 0);
|
|
transport.available = false;
|
|
await transport.automation.devicesChanged({
|
|
device('bluetooth', AudioDeviceType.bluetoothA2dp),
|
|
});
|
|
expect(transport.plays, 0);
|
|
await transport.automation.dispose();
|
|
});
|
|
|
|
test('unplugging cancels an unmute resume', () async {
|
|
final transport = _Transport();
|
|
await transport.enable(headphones: true);
|
|
await transport.automation.devicesChanged({
|
|
device('wired', AudioDeviceType.wiredHeadphones),
|
|
});
|
|
await transport.automation.volumeChanged(0);
|
|
await transport.automation.devicesChanged({});
|
|
await transport.automation.volumeChanged(0.5);
|
|
expect(transport.plays, 0);
|
|
await transport.automation.dispose();
|
|
});
|
|
|
|
test(
|
|
'pending connection playback is cancelled by an explicit pause',
|
|
() async {
|
|
final transport = _Transport()..playing = false;
|
|
await transport.enable(headphones: true);
|
|
await transport.automation.devicesChanged({});
|
|
final connected = transport.automation.devicesChanged({
|
|
device('wired', AudioDeviceType.wiredHeadphones),
|
|
});
|
|
transport.automation.cancelPendingActions();
|
|
await connected;
|
|
expect(transport.plays, 0);
|
|
await transport.automation.dispose();
|
|
},
|
|
);
|
|
|
|
test('disposal cancels queued work and stale notifications', () async {
|
|
final transport = _Transport();
|
|
await transport.enable(headphones: true);
|
|
final muted = transport.automation.volumeChanged(0);
|
|
await transport.automation.dispose();
|
|
await muted;
|
|
await transport.automation.volumeChanged(0.5);
|
|
expect(transport.pauses, 0);
|
|
expect(transport.plays, 0);
|
|
});
|
|
}
|