feat(playback): automate mute and headphone playback controls

This commit is contained in:
zarzet committed 2026-10-05 01:55:08 +07:00
1 parent ad3c9c0279
commit 1abc45e2bf
16 files changed
+1028 -36

No files matched your search

+146
View File
@@ -15,6 +15,7 @@ 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';
class _EffectRenderer extends AutoMixEffectRenderer {
final tails = <RenderedMixTail>[];
@@ -67,6 +68,7 @@ class _AudioNative {
final sources = <String, String>{};
final sourceGates = <String, Completer<void>>{};
final resumedSources = <String>[];
Completer<void>? resumeGate;
bool emitsDuration = true;
void install() {
@@ -103,6 +105,7 @@ class _AudioNative {
positions[id] = args['position']! as int;
unawaited(event(id, 'audio.onSeekComplete'));
case 'resume':
await resumeGate?.future;
playing.add(id);
resumedSources.add(sources[id]!);
case 'pause' || 'stop':
@@ -170,6 +173,11 @@ void main() {
late AutoplayLibraryLoader autoplayLoader;
setUp(() {
setPlaybackAutomationOptions(
enabled: true,
pauseOnMute: false,
playOnHeadphonesConnected: false,
);
setAutoMixEnabled(false);
setAutoMixOptions(const AutoMixOptions());
setUsbBitPerfectEnabled(false);
@@ -255,6 +263,144 @@ void main() {
},
);
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);
});
});
group('USB playback transport', () {
const methods = MethodChannel('com.zarz.spotiflac/usb_pcm');
const events = MethodChannel('com.zarz.spotiflac/usb_pcm/events');
+106
View File
@@ -0,0 +1,106 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/theme_settings.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/settings/playback_settings_page.dart';
import 'package:spotiflac_android/screens/settings/settings_search_catalog.dart';
import 'package:spotiflac_android/theme/app_theme.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
void main() {
for (final mornye in [false, true]) {
testWidgets('automatic controls persist in both themes ($mornye)', (
tester,
) async {
SharedPreferences.setMockInitialValues({});
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final container = ProviderContainer(
overrides: [
initialSettingsProvider.overrideWithValue(
const AppSettings(pauseOnMute: false),
),
],
);
addTearDown(container.dispose);
await tester.pumpWidget(
UncontrolledProviderScope(
container: container,
child: MaterialApp(
theme: mornye
? MornyeTheme.build(Brightness.dark, accent: MornyeAccent.blue)
: AppTheme.light(),
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const PlaybackSettingsPage(),
),
),
);
final muted = find.text('Pause when muted');
final connected = find.text('Play when headphones connect');
await tester.scrollUntilVisible(
muted,
200,
scrollable: find.byType(Scrollable).first,
);
await tester.pumpAndSettle();
await tester.tap(muted);
await tester.pumpAndSettle();
await tester.ensureVisible(connected);
await tester.pumpAndSettle();
await tester.tap(connected);
await tester.pumpAndSettle();
expect(container.read(settingsProvider).pauseOnMute, isTrue);
expect(
container.read(settingsProvider).playOnHeadphonesConnected,
isTrue,
);
final prefs = await SharedPreferences.getInstance();
final saved = AppSettings.fromJson(
jsonDecode(prefs.getString('app_settings')!) as Map<String, dynamic>,
);
expect(saved.pauseOnMute, isTrue);
expect(saved.playOnHeadphonesConnected, isTrue);
container.read(settingsProvider.notifier).setPlayerMode('external');
await tester.pump();
final automationControls = tester.widgetList<SettingsSwitchItem>(
find.byWidgetPredicate(
(widget) =>
widget is SettingsSwitchItem &&
const [
'Pause when muted',
'Play when headphones connect',
].contains(widget.title),
),
);
expect(automationControls, hasLength(2));
expect(automationControls.every((widget) => !widget.enabled), isTrue);
expect(tester.takeException(), isNull);
final l10n = AppLocalizations.of(tester.element(connected));
final catalog = SettingsSearchCatalog(l10n);
expect(
catalog.playback.any(
(entry) => entry.matches(SettingsSearchQuery('mute')),
),
isTrue,
);
expect(
catalog.playback.any(
(entry) => entry.matches(SettingsSearchQuery('earphone')),
),
isTrue,
);
});
}
}
+281
View File
@@ -0,0 +1,281 @@
// 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);
});
}
+55
View File
@@ -0,0 +1,55 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/services/system_volume_service.dart';
void main() {
test('closing a slider leaves background volume monitoring alive', () async {
var starts = 0;
var cancels = 0;
final source = StreamController<double>.broadcast(
onListen: () => starts++,
onCancel: () => cancels++,
);
final volume = SystemVolumeService(source: source.stream);
final background = <double>[];
final slider = <double>[];
final backgroundSubscription = volume.changes.listen(background.add);
source.add(0.5);
await Future<void>.delayed(Duration.zero);
final sliderSubscription = volume.changes.listen(slider.add);
await Future<void>.delayed(Duration.zero);
expect(slider, [0.5]);
expect(starts, 1);
await sliderSubscription.cancel();
expect(cancels, 0);
source.add(0);
await Future<void>.delayed(Duration.zero);
expect(background, [0.5, 0]);
await backgroundSubscription.cancel();
expect(cancels, 1);
await source.close();
});
test(
'reattaching waits for fresh volume and releases the last listener',
() async {
final source = StreamController<double>.broadcast();
final volume = SystemVolumeService(source: source.stream);
final first = volume.changes.listen((_) {});
source.add(0);
await Future<void>.delayed(Duration.zero);
await first.cancel();
final values = <double>[];
final second = volume.changes.listen(values.add);
await Future<void>.delayed(Duration.zero);
expect(values, isEmpty);
source.add(0.7);
await Future<void>.delayed(Duration.zero);
expect(values, [0.7]);
await second.cancel();
expect(source.hasListener, isFalse);
await source.close();
},
);
}