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:
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
|
import 'package:audio_session/audio_session.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
import 'package:path_provider/path_provider.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.
|
// the index and the eviction loop can't reclaim them.
|
||||||
_player.bufferedPositionStream
|
_player.bufferedPositionStream
|
||||||
.listen((_) => unawaited(_maybeRegisterStreamCache()));
|
.listen((_) => unawaited(_maybeRegisterStreamCache()));
|
||||||
|
unawaited(_configureAudioSession());
|
||||||
}
|
}
|
||||||
|
|
||||||
final AudioPlayer _player = AudioPlayer();
|
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.
|
/// cancelled the moment playback resumes or a new queue is set.
|
||||||
Timer? _idleStopTimer;
|
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
|
/// True while _fillRemainingSources is doing backward-fill inserts
|
||||||
/// at index 0..initialIndex-1. Each insert shifts the player's
|
/// at index 0..initialIndex-1. Each insert shifts the player's
|
||||||
/// currentIndex (it tracks the actively-playing source through
|
/// currentIndex (it tracks the actively-playing source through
|
||||||
@@ -729,6 +740,65 @@ class MinstrelAudioHandler extends BaseAudioHandler with QueueHandler, SeekHandl
|
|||||||
}
|
}
|
||||||
unawaited(stop());
|
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
|
/// Adapter that lets the audio handler call into the app's
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ dependencies:
|
|||||||
dio: ^5.7.0
|
dio: ^5.7.0
|
||||||
just_audio: ^0.10.5
|
just_audio: ^0.10.5
|
||||||
audio_service: ^0.18.15
|
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
|
flutter_secure_storage: ^10.1.0
|
||||||
go_router: ^17.2.3
|
go_router: ^17.2.3
|
||||||
flutter_svg: ^2.0.16
|
flutter_svg: ^2.0.16
|
||||||
|
|||||||
Reference in New Issue
Block a user