feat(player): audio focus, interruptions & becoming-noisy

The Flutter client had no audio_session integration, so it didn't pause
for phone calls / other media, didn't duck for navigation prompts, and
kept blasting the phone speaker when earbuds were unplugged.

Add audio_session ^0.2.3 and configure AudioSessionConfiguration.music()
in MinstrelAudioHandler (best-effort, fully try-caught):
- becomingNoisy -> pause (no speaker blast on unplug/BT drop)
- interruption begin: duck -> lower volume; pause/unknown -> pause,
  remembering whether we were actively playing
- interruption end: duck -> restore volume; pause -> resume only if we
  paused it and the session isn't torn down (guards the idle-teardown
  -during-long-call edge; re-init is resume-last-session territory);
  unknown -> no auto-resume

pubspec.lock regenerated by build/CI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 15:43:52 -04:00
parent 905f05c120
commit 57ce3d2d0a
2 changed files with 75 additions and 0 deletions
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:flutter/foundation.dart';
import 'package:just_audio/just_audio.dart';
import 'package:path_provider/path_provider.dart';
@@ -38,6 +39,7 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
// the index and the eviction loop can't reclaim them.
_player.bufferedPositionStream
.listen((_) => unawaited(_maybeRegisterStreamCache()));
unawaited(_configureAudioSession());
}
final AudioPlayer _player = AudioPlayer();
@@ -68,6 +70,15 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
/// cancelled the moment playback resumes or a new queue is set.
Timer? _idleStopTimer;
/// Player volume captured when an OS duck interruption begins,
/// restored when it ends. Null when not currently ducked.
double? _volumeBeforeDuck;
/// True when an interruption (call / other media) paused playback that
/// was actively going, so a transient (pause-type) interruption end
/// auto-resumes. Cleared on resume or a non-resumable end.
bool _interruptedWhilePlaying = false;
/// True while _fillRemainingSources is doing backward-fill inserts
/// at index 0..initialIndex-1. Each insert shifts the player's
/// currentIndex (it tracks the actively-playing source through
@@ -729,6 +740,65 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
}
unawaited(stop());
}
/// Configures the OS audio session for music playback and wires
/// interruption + becoming-noisy handling. just_audio auto-activates /
/// deactivates the session around playback once it's configured, but
/// does NOT handle interruptions or the headphones-unplugged event
/// itself — that's done here. Best effort: a failure must not break
/// playback.
Future<void> _configureAudioSession() async {
try {
final session = await AudioSession.instance;
await session.configure(AudioSessionConfiguration.music());
session.interruptionEventStream.listen(_onInterruption);
session.becomingNoisyEventStream.listen((_) {
// Headphones unplugged / Bluetooth disconnected — never blast the
// phone speaker; pause like every other media app.
unawaited(_player.pause());
});
} catch (e, st) {
debugPrint('audio_handler: audio session configure failed: $e\n$st');
}
}
void _onInterruption(AudioInterruptionEvent event) {
if (event.begin) {
switch (event.type) {
case AudioInterruptionType.duck:
// Transient: another app wants the foreground briefly. Lower
// our volume rather than stopping (OS may also auto-duck).
_volumeBeforeDuck = _player.volume;
unawaited(_player.setVolume(0.3));
case AudioInterruptionType.pause:
case AudioInterruptionType.unknown:
// Call / other media took focus. Remember whether we were
// actively playing so a transient end can resume us.
_interruptedWhilePlaying = _player.playing &&
_player.processingState != ProcessingState.completed;
unawaited(_player.pause());
}
} else {
switch (event.type) {
case AudioInterruptionType.duck:
unawaited(_player.setVolume(_volumeBeforeDuck ?? 1.0));
_volumeBeforeDuck = null;
case AudioInterruptionType.pause:
// Transient interruption ended — resume only if WE paused it
// and the session is still alive (a long call may have let the
// #52 idle timer tear it down; re-init is resume-last-session
// territory, out of scope here).
if (_interruptedWhilePlaying &&
_player.processingState != ProcessingState.idle) {
unawaited(_player.play());
}
_interruptedWhilePlaying = false;
case AudioInterruptionType.unknown:
// Unknown end — do not auto-resume (could surprise the user).
_interruptedWhilePlaying = false;
}
}
}
}
/// Adapter that lets the audio handler call into the app's
+5
View File
@@ -14,6 +14,11 @@ dependencies:
dio: ^5.7.0
just_audio: ^0.10.5
audio_service: ^0.18.15
# Audio focus + interruptions + becoming-noisy. just_audio auto-manages
# session activation once configured; the app must still handle
# interruption/becoming-noisy itself (just_audio does not). Same author
# as just_audio/audio_service so versions track together.
audio_session: ^0.2.3
flutter_secure_storage: ^10.1.0
go_router: ^17.2.3
flutter_svg: ^2.0.16