mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 06:52:18 +02:00
feat(playback): automate mute and headphone playback controls
This commit is contained in:
1 parent
ad3c9c0279
commit
1abc45e2bf
16 files changed
+1028
-36
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user