diff --git a/flutter_client/lib/player/audio_handler.dart b/flutter_client/lib/player/audio_handler.dart index 6626ba40..1e962011 100644 --- a/flutter_client/lib/player/audio_handler.dart +++ b/flutter_client/lib/player/audio_handler.dart @@ -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 _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 diff --git a/flutter_client/pubspec.yaml b/flutter_client/pubspec.yaml index 0d9aab7a..6bda1980 100644 --- a/flutter_client/pubspec.yaml +++ b/flutter_client/pubspec.yaml @@ -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