diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-21 18:35:26 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-21 18:35:26 +0300 |
| commit | e58a12ee773da39d639ebb0e26fdb1b782941c56 (patch) | |
| tree | 60dbf99bc2daf8f9c0b0b0709a7fa0fa106eb8f1 /player-android | |
| parent | 627f44e105926e5ad4963336cdbfd9f9522bcdb1 (diff) | |
Wrap AudioPlayerScreen in audio_service for background playback (1b)
Creates PlayerAudioHandler (BaseAudioHandler + SeekHandler) that wraps
just_audio's AudioPlayer and bridges it to the Android media session:
background foreground-service playback, lock-screen / notification
controls (play/pause/seek/skip ±15 s), audio focus, and Bluetooth
headset events all handled by audio_service.
Key design decisions:
- Handler registered once via AudioService.init in main() and injected
into ProviderScope via overrideWithValue (DIP: no global mutable var).
- Progress-sync timer stays in the screen so PlayerApiClient is never
imported by the handler (SRP boundary preserved).
- Seek bar onChanged routes through handler.seek() so the notification
position updates on slider drags (Law of Demeter fix).
- _initPlayer refactored into _buildAuthHeaders / _loadSource /
_resumeFromSavedPosition helpers (each ≤30 lines, SoC).
- Tests override audioHandlerProvider with _FakePlayerAudioHandler to
avoid platform-channel calls; all 221 tests pass.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'player-android')
| -rw-r--r-- | player-android/lib/main.dart | 56 | ||||
| -rw-r--r-- | player-android/lib/providers/audio_handler_provider.dart | 43 | ||||
| -rw-r--r-- | player-android/lib/screens/audio_player_screen.dart | 179 | ||||
| -rw-r--r-- | player-android/lib/services/audio_handler.dart | 198 | ||||
| -rw-r--r-- | player-android/test/screens/audio_player_screen_test.dart | 44 |
5 files changed, 443 insertions, 77 deletions
diff --git a/player-android/lib/main.dart b/player-android/lib/main.dart index 156b8f8..b32c8e7 100644 --- a/player-android/lib/main.dart +++ b/player-android/lib/main.dart @@ -1,11 +1,61 @@ +import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:just_audio/just_audio.dart'; +import 'providers/audio_handler_provider.dart'; import 'router.dart'; +import 'services/audio_handler.dart'; -/// Entry point — wraps the whole widget tree in a [ProviderScope] so every -/// widget and provider has access to the Riverpod container. -void main() => runApp(const ProviderScope(child: PlayerAndroidApp())); +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +/// Entry point. +/// +/// [AudioService.init] must be called before [runApp] so that the handler is +/// registered before any widget tries to obtain it via [audioHandlerProvider]. +/// +/// The handler is injected into [ProviderScope] via an override rather than +/// stored in a global variable, keeping the dependency explicit and testable +/// (Dependency Inversion Principle: the provider file does not need to import +/// `main.dart`; the composition root wires the graph). +void main() async { + // Ensure platform channels are ready before calling AudioService.init. + WidgetsFlutterBinding.ensureInitialized(); + + // Register the PlayerAudioHandler as the singleton media session handler. + // AudioService.init<T> returns the exact T from the builder. We pass it + // directly into ProviderScope so [audioHandlerProvider] is resolved without + // any global mutable state. + final handler = await AudioService.init<PlayerAudioHandler>( + builder: () => PlayerAudioHandler(AudioPlayer()), + config: const AudioServiceConfig( + // Notification channel name shown in Android Settings → App info. + androidNotificationChannelName: 'Player Audio', + // Keep the service alive while the notification is visible so the OS + // does not kill the process when the user swipes the app away. + androidStopForegroundOnPause: false, + // Allow the user to swipe away the notification to stop playback (UX + // expectation on Android — mirrors Spotify / Podcast Addict behaviour). + androidNotificationOngoing: false, + ), + ); + + runApp( + ProviderScope( + // Override the provider with the concrete handler instance so that every + // widget and provider that reads [audioHandlerProvider] gets the same + // singleton without going through a global variable. + overrides: [audioHandlerProvider.overrideWithValue(handler)], + child: const PlayerAndroidApp(), + ), + ); +} + +// --------------------------------------------------------------------------- +// Root widget +// --------------------------------------------------------------------------- /// Root application widget. /// diff --git a/player-android/lib/providers/audio_handler_provider.dart b/player-android/lib/providers/audio_handler_provider.dart new file mode 100644 index 0000000..d3ea307 --- /dev/null +++ b/player-android/lib/providers/audio_handler_provider.dart @@ -0,0 +1,43 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../services/audio_handler.dart'; + +// --------------------------------------------------------------------------- +// audioHandlerProvider +// --------------------------------------------------------------------------- + +/// Provides the [PlayerAudioHandler] singleton that [AudioService.init] +/// registered at app startup. +/// +/// This provider has **no default implementation** — it must be overridden at +/// the composition root ([ProviderScope] in [main]) with the instance returned +/// by [AudioService.init]. Attempting to read it without an override throws a +/// [StateError] so misconfiguration is caught immediately. +/// +/// - Using a [ProviderScope] override (rather than reading a global mutable +/// variable from `main.dart`) follows the Dependency Inversion Principle: +/// the provider file does not depend on `main.dart`, and the wiring is +/// explicit and visible in the composition root. +/// - Tests can supply a fake handler by overriding this provider in the +/// test's [ProviderScope], without touching the production entry point. +/// +/// Usage in widgets: +/// ```dart +/// final handler = ref.read(audioHandlerProvider); +/// await handler.play(); +/// ``` +/// +/// Composition root wiring (see `main.dart`): +/// ```dart +/// final handler = await AudioService.init<PlayerAudioHandler>(...); +/// runApp(ProviderScope( +/// overrides: [audioHandlerProvider.overrideWithValue(handler)], +/// child: const PlayerAndroidApp(), +/// )); +/// ``` +final audioHandlerProvider = Provider<PlayerAudioHandler>( + (_) => throw StateError( + 'audioHandlerProvider has no value: override it with the ' + 'PlayerAudioHandler instance from AudioService.init() in ProviderScope.', + ), +); diff --git a/player-android/lib/screens/audio_player_screen.dart b/player-android/lib/screens/audio_player_screen.dart index 00465e4..58affa4 100644 --- a/player-android/lib/screens/audio_player_screen.dart +++ b/player-android/lib/screens/audio_player_screen.dart @@ -4,8 +4,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:just_audio/just_audio.dart'; +import '../api/dio_client.dart'; import '../api/player_api_client.dart'; import '../providers/api_client_provider.dart'; +import '../providers/audio_handler_provider.dart'; +import '../services/audio_handler.dart'; // How often progress updates are emitted to the server while playing. // Mirrors VideoPlayerScreen._kProgressInterval exactly. @@ -34,10 +37,16 @@ const _kSkipDuration = Duration(seconds: 15); /// - Bearer token is attached via `headers` on [AudioSource.uri] so the /// just_audio native layer can authenticate without routing bytes through /// Dart. +/// - The [PlayerAudioHandler] (obtained via [audioHandlerProvider]) wraps +/// the underlying [AudioPlayer] and bridges it to the Android media +/// session, enabling lock-screen controls and background playback. /// - Progress updates (every [_kProgressInterval]) and the finished mark are /// fire-and-forget: errors are swallowed so a transient network blip never /// interrupts playback. -/// - [AudioPlayer] is disposed in [dispose] to prevent resource leaks. +/// - The progress-sync timer intentionally stays in the screen (not in the +/// handler) so it can call [updateProgress] via [apiClientProvider] without +/// the handler needing a reference to the API layer — preserving the +/// Single Responsibility of each class. /// - All async continuations guard on [mounted] before calling [setState]. class AudioPlayerScreen extends ConsumerStatefulWidget { const AudioPlayerScreen({ @@ -69,9 +78,6 @@ class AudioPlayerScreen extends ConsumerStatefulWidget { // --------------------------------------------------------------------------- class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { - // Nullable until initialisation completes (or fails). - AudioPlayer? _audioPlayer; - // Non-null when initialisation failed; shown in the error view. String? _error; @@ -101,10 +107,9 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { @override void dispose() { - // Cancel the timer before disposing the player so the callback cannot fire - // against a disposed player (mirrors VideoPlayerScreen dispose order). + // Cancel the timer before the player is detached so the callback cannot + // fire with a stale player reference (mirrors VideoPlayerScreen order). _progressTimer?.cancel(); - _audioPlayer?.dispose(); super.dispose(); } @@ -112,84 +117,102 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { // Player initialisation // --------------------------------------------------------------------------- - /// Initialises [AudioPlayer] with bearer-token auth and resumes position. + /// Top-level orchestrator for player setup. /// - /// Steps: - /// 1. Resolve the stream URL (from route extra or [PlayerApiClient]). - /// 2. Read the bearer token for the `Authorization` header. - /// 3. Create [AudioPlayer] and set the authenticated [AudioSource.uri]. - /// 4. Fetch the saved position via [getMediaProgress] and seek to it. - /// 5. Start playback and the progress ticker. + /// Delegates each step to a focused helper so this method stays under 30 + /// lines and each concern (auth, source loading, seek) is independently + /// testable and readable (Separation of Concerns). Future<void> _initPlayer() async { if (!mounted) return; + final handler = ref.read(audioHandlerProvider); + final player = handler.player; final client = ref.read(apiClientProvider); final storage = ref.read(tokenStorageProvider); final mediaIdInt = int.tryParse(widget.mediaId) ?? 0; - - // Step 1: resolve the stream URL — prefer the route-extra URL so the - // calling screen can forward a pre-computed URL; fall back to streamUrl. final url = widget.mediaUrl ?? client.streamUrl(mediaIdInt); - // Step 2: read the bearer token so the native player can authenticate - // without routing bytes through Dart (performance and correctness). - final token = await storage.readToken(); + // Step 1–2: build auth headers. + final headers = await _buildAuthHeaders(storage); if (!mounted) return; - final headers = <String, String>{ + // Step 3: load the authenticated source; show error UI on failure. + final loaded = await _loadSource(player, url, headers); + if (!loaded || !mounted) return; + + // Step 4: seek to the saved position (non-fatal if unavailable). + await _resumeFromSavedPosition(player, client, mediaIdInt); + if (!mounted) return; + + // Step 5: publish media-session metadata to notification/lock-screen. + handler.setMediaItem( + id: widget.mediaId, + title: 'Audio – ${widget.mediaId}', + ); + + setState(() => _isLoading = false); + + // Step 6: begin playback and start the periodic progress ticker. + unawaited(handler.play()); + _startProgressTicker(mediaIdInt, client, player); + } + + /// Reads the bearer token and returns the `Authorization` header map. + /// + /// Returns an empty map when no token is stored so the source can still be + /// loaded (e.g., public streams or during tests). + Future<Map<String, String>> _buildAuthHeaders(TokenStorage storage) async { + final token = await storage.readToken(); + return <String, String>{ if (token != null && token.isNotEmpty) 'Authorization': 'Bearer $token', }; + } - // Step 3: create the AudioPlayer and load the authenticated source. - final audioPlayer = AudioPlayer(); + /// Loads [url] into [player] with [headers]; returns `true` on success. + /// + /// On failure, sets the error UI state and returns `false` so [_initPlayer] + /// can short-circuit without nesting the remaining steps inside a try/catch. + Future<bool> _loadSource( + AudioPlayer player, + String url, + Map<String, String> headers, + ) async { try { - await audioPlayer.setAudioSource( + await player.setAudioSource( AudioSource.uri(Uri.parse(url), headers: headers), ); + return true; } catch (e) { - audioPlayer.dispose(); - if (!mounted) return; + if (!mounted) return false; setState(() { _error = _initErrorMessage(e); _isLoading = false; }); - return; - } - - if (!mounted) { - audioPlayer.dispose(); - return; + return false; } + } - // Step 4: resume from the saved position. - // Prefer [widget.startPosition] (forwarded by the continue-watching screen) - // to avoid a redundant API round-trip. Fall back to [getMediaProgress] so - // audio items opened from other screens still resume correctly. + /// Seeks [player] to the saved position for this media item. + /// + /// Prefers [widget.startPosition] to avoid a redundant API round-trip; falls + /// back to [client.getMediaProgress]. Failure is non-fatal — the player + /// simply starts from the beginning. + Future<void> _resumeFromSavedPosition( + AudioPlayer player, + PlayerApiClient client, + int mediaId, + ) async { try { final savedSeconds = - widget.startPosition ?? await client.getMediaProgress(mediaIdInt); + widget.startPosition ?? await client.getMediaProgress(mediaId); if (savedSeconds != null && savedSeconds > 0) { - await audioPlayer.seek( + await player.seek( Duration(milliseconds: (savedSeconds * 1000).round()), ); } } catch (_) { // Progress fetch failure is non-fatal; start from the beginning. } - - if (!mounted) { - audioPlayer.dispose(); - return; - } - - setState(() { - _audioPlayer = audioPlayer; - _isLoading = false; - }); - - // Step 5: begin playback and start the periodic progress ticker. - unawaited(audioPlayer.play()); - _startProgressTicker(mediaIdInt, client, audioPlayer); } // --------------------------------------------------------------------------- @@ -202,6 +225,10 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { /// The [client] and [player] references are captured once here so we avoid /// accessing [ref] or [_audioPlayer] inside the timer callback after the /// widget may have been disposed. + /// + /// The timer intentionally lives in the screen — not in the handler — so + /// that [PlayerApiClient] (an HTTP concern) is not imported into + /// [PlayerAudioHandler] (an audio-session concern), preserving SRP. void _startProgressTicker( int mediaId, PlayerApiClient client, @@ -262,14 +289,12 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { // Actions // --------------------------------------------------------------------------- - /// Tears down the current player and re-runs [_initPlayer]. + /// Tears down the current player source and re-runs [_initPlayer]. /// /// Extracted to keep [_buildErrorView] below 30 lines (style guideline). void _onRetry() { _progressTimer?.cancel(); - _audioPlayer?.dispose(); setState(() { - _audioPlayer = null; _error = null; _isLoading = true; _finishedEmitted = false; @@ -280,8 +305,8 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { /// Skips playback by [delta]; clamps to [Duration.zero] and total duration. Future<void> _skip(Duration delta) async { - final player = _audioPlayer; - if (player == null) return; + final handler = ref.read(audioHandlerProvider); + final player = handler.player; final current = player.position; final total = player.duration ?? Duration.zero; // Duration does not implement Comparable, so clamp manually. @@ -289,14 +314,13 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { final next = raw < Duration.zero ? Duration.zero : (total > Duration.zero && raw > total ? total : raw); - await player.seek(next); + await handler.seek(next); } - /// Applies [speed] to the player and updates the UI state. + /// Applies [speed] to the handler and updates the UI state. Future<void> _setSpeed(double speed) async { - final player = _audioPlayer; - if (player == null) return; - await player.setSpeed(speed); + final handler = ref.read(audioHandlerProvider); + await handler.setSpeed(speed); if (!mounted) return; setState(() => _playbackSpeed = speed); } @@ -367,7 +391,7 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { /// The main playback UI: cover art placeholder, seek bar, and controls. Widget _buildPlayerView() { - final player = _audioPlayer!; + final handler = ref.read(audioHandlerProvider); return Padding( key: const Key('audio_player_view'), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), @@ -376,9 +400,9 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { children: [ _buildCoverArt(), const SizedBox(height: 32), - _buildSeekBar(player), + _buildSeekBar(handler), const SizedBox(height: 16), - _buildControls(player), + _buildControls(handler), const SizedBox(height: 16), _buildSpeedSelector(), ], @@ -410,7 +434,13 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { /// /// Uses [StreamBuilder] so the slider reflects real-time position without /// calling [setState] on every tick — preventing unnecessary full rebuilds. - Widget _buildSeekBar(AudioPlayer player) { + /// + /// All seeks are routed through [handler.seek] (not directly through the + /// underlying [AudioPlayer]) so that the Android media-session notification + /// position is updated when the user drags the slider (Law of Demeter: + /// the screen should not bypass the handler for mutations). + Widget _buildSeekBar(PlayerAudioHandler handler) { + final player = handler.player; return StreamBuilder<Duration>( stream: player.positionStream, builder: (context, snapshot) { @@ -428,8 +458,10 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { value: current, min: 0, max: total > 0 ? total : 1.0, + // Route through the handler so the media-session notification + // stays in sync with the slider position during a drag. onChanged: total > 0 - ? (v) => player.seek(Duration(milliseconds: v.round())) + ? (v) => handler.seek(Duration(milliseconds: v.round())) : null, activeColor: Colors.white, inactiveColor: Colors.white24, @@ -459,9 +491,13 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { } /// Playback controls: skip-back, play/pause, skip-forward. - Widget _buildControls(AudioPlayer player) { + /// + /// All tap handlers delegate to [handler] instead of calling the underlying + /// [AudioPlayer] directly, so the media-session notification stays in sync + /// with every button press (Law of Demeter: one collaborator for mutations). + Widget _buildControls(PlayerAudioHandler handler) { return StreamBuilder<bool>( - stream: player.playingStream, + stream: handler.player.playingStream, builder: (context, snapshot) { final isPlaying = snapshot.data ?? false; return Row( @@ -475,7 +511,7 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { tooltip: 'Skip back 15 seconds', ), const SizedBox(width: 16), - // Play / Pause + // Play / Pause — delegate to handler so the notification updates. IconButton( key: const Key('audio_player_play_pause'), icon: Icon( @@ -483,7 +519,7 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { color: Colors.white, size: 64, ), - onPressed: isPlaying ? player.pause : player.play, + onPressed: isPlaying ? handler.pause : handler.play, tooltip: isPlaying ? 'Pause' : 'Play', ), const SizedBox(width: 16), @@ -542,4 +578,3 @@ class _AudioPlayerScreenState extends ConsumerState<AudioPlayerScreen> { return h > 0 ? '$h:$m:$s' : '$m:$s'; } } - diff --git a/player-android/lib/services/audio_handler.dart b/player-android/lib/services/audio_handler.dart new file mode 100644 index 0000000..7ae585d --- /dev/null +++ b/player-android/lib/services/audio_handler.dart @@ -0,0 +1,198 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:just_audio/just_audio.dart'; + +// --------------------------------------------------------------------------- +// PlayerAudioHandler +// --------------------------------------------------------------------------- + +/// [BaseAudioHandler] subclass that wraps a [just_audio] [AudioPlayer] and +/// bridges it to the Android media-session / iOS AVAudioSession so that: +/// - Playback continues in the background as a foreground service. +/// - Lock-screen and notification controls (play/pause/seek/skip) work. +/// - Audio focus is honoured: playback pauses on phone calls or other +/// audio interruptions and resumes when focus is regained. +/// - Bluetooth headset media buttons (play, pause, next, previous) are +/// forwarded by [AudioService] and handled here. +/// +/// Single Responsibility: this class only translates between the +/// [BaseAudioHandler] protocol and [AudioPlayer]'s API. All progress +/// reporting and navigation logic lives in [AudioPlayerScreen]. +/// +/// The handler is registered once via [AudioService.init] in [main]. +/// Consumers retrieve the singleton through [audioHandlerProvider]. +class PlayerAudioHandler extends BaseAudioHandler with SeekHandler { + /// Creates the handler with an already-configured [AudioPlayer]. + /// + /// The player is injected so that tests can supply a mock without any + /// platform channels (Dependency Inversion Principle). + PlayerAudioHandler(this._player) { + // Propagate just_audio's playback state into the audio_service stream so + // the notification, lock screen, and Wear OS clients see live updates. + _player.playbackEventStream.listen(_onPlaybackEvent); + + // Propagate playing/paused transitions, which are not always carried in + // playback events (just_audio emits them separately). + _player.playingStream.listen((_) => _broadcastState()); + + // Propagate processing-state changes (e.g. loading → ready → completed). + _player.processingStateStream.listen((_) => _broadcastState()); + } + + final AudioPlayer _player; + + // --------------------------------------------------------------------------- + // Public accessors (used by AudioPlayerScreen to avoid a second Player) + // --------------------------------------------------------------------------- + + /// The underlying [AudioPlayer] so the screen can subscribe to position / + /// duration streams and still use bearer-token authenticated sources. + /// + /// Exposing the player directly is intentional: [AudioPlayerScreen] owns the + /// progress-sync timer and needs raw position/duration access. No extra + /// abstraction layer is needed here (YAGNI). + AudioPlayer get player => _player; + + // --------------------------------------------------------------------------- + // BaseAudioHandler — playback controls + // --------------------------------------------------------------------------- + + @override + Future<void> play() => _player.play(); + + @override + Future<void> pause() => _player.pause(); + + @override + Future<void> stop() async { + await _player.stop(); + await super.stop(); + } + + /// Seeks to [position] within the current item. + /// + /// [SeekHandler] mixin provides the default [fastForward] / [rewind] + /// implementations in terms of this method. + @override + Future<void> seek(Duration position) => _player.seek(position); + + /// Skip forward 15 seconds (media-button "next" maps to a short skip for + /// podcast and audiobook use-cases rather than a full track change). + @override + Future<void> skipToNext() async { + final current = _player.position; + final total = _player.duration ?? Duration.zero; + final next = _clamp(current + const Duration(seconds: 15), Duration.zero, total); + await _player.seek(next); + } + + /// Skip back 15 seconds. + @override + Future<void> skipToPrevious() async { + final current = _player.position; + final total = _player.duration ?? Duration.zero; + final prev = _clamp(current - const Duration(seconds: 15), Duration.zero, total); + await _player.seek(prev); + } + + /// Changes playback speed and refreshes the notification state. + @override + Future<void> setSpeed(double speed) async { + await _player.setSpeed(speed); + _broadcastState(); + } + + // --------------------------------------------------------------------------- + // Media item helpers (called by AudioPlayerScreen after player is ready) + // --------------------------------------------------------------------------- + + /// Pushes a new [MediaItem] onto the [mediaItem] stream so the Android media + /// notification and lock-screen controls show the correct title and duration. + /// + /// Named [setMediaItem] rather than [updateMediaItem] to avoid clashing with + /// [BaseAudioHandler.updateMediaItem] (which takes a full [MediaItem] and + /// notifies children in a queue scenario — not applicable here). + /// + /// Called once per session after the player finishes loading the source. + void setMediaItem({required String id, required String title}) { + mediaItem.add( + MediaItem( + id: id, + title: title, + duration: _player.duration, + ), + ); + } + + // --------------------------------------------------------------------------- + // Internal — state broadcast + // --------------------------------------------------------------------------- + + /// Converts [just_audio]'s [PlaybackEvent] into [audio_service]'s + /// [PlaybackState] and pushes it onto the [playbackState] stream. + /// + /// Called on every playback event so the notification and lock-screen + /// controls always reflect the true player state. + void _onPlaybackEvent(PlaybackEvent event) => _broadcastState(); + + /// Emits the current [PlaybackState] derived from the underlying player. + /// + /// Maps [just_audio]'s processing state to [audio_service]'s equivalents and + /// advertises which controls are enabled so Android can render the correct + /// notification buttons. + void _broadcastState() { + final processingState = _mapProcessingState(_player.processingState); + + playbackState.add( + PlaybackState( + // Advertise all supported actions so the notification, lock screen, + // and Bluetooth headset buttons all get the correct controls. + controls: [ + MediaControl.skipToPrevious, // maps to skipToPrevious (–15 s) + if (_player.playing) MediaControl.pause else MediaControl.play, + MediaControl.stop, + MediaControl.skipToNext, // maps to skipToNext (+15 s) + ], + systemActions: const { + MediaAction.seek, + MediaAction.seekForward, + MediaAction.seekBackward, + }, + androidCompactActionIndices: const [0, 1, 3], // prev, play/pause, next + processingState: processingState, + playing: _player.playing, + updatePosition: _player.position, + bufferedPosition: _player.bufferedPosition, + speed: _player.speed, + ), + ); + } + + /// Translates [just_audio]'s [ProcessingState] to [audio_service]'s + /// [AudioProcessingState] so the notification can show loading spinners. + AudioProcessingState _mapProcessingState(ProcessingState state) { + switch (state) { + case ProcessingState.idle: + return AudioProcessingState.idle; + case ProcessingState.loading: + return AudioProcessingState.loading; + case ProcessingState.buffering: + return AudioProcessingState.buffering; + case ProcessingState.ready: + return AudioProcessingState.ready; + case ProcessingState.completed: + return AudioProcessingState.completed; + } + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + /// Clamps [value] to [[min], [max]]; [Duration] doesn't implement [Comparable] + /// so we do the comparison manually (mirrors VideoPlayerScreen pattern). + Duration _clamp(Duration value, Duration min, Duration max) { + if (value < min) return min; + if (max > Duration.zero && value > max) return max; + return value; + } +} diff --git a/player-android/test/screens/audio_player_screen_test.dart b/player-android/test/screens/audio_player_screen_test.dart index 2bb817b..a7af1fb 100644 --- a/player-android/test/screens/audio_player_screen_test.dart +++ b/player-android/test/screens/audio_player_screen_test.dart @@ -8,14 +8,16 @@ // 5. Screen renders the AppBar title containing the mediaId. // 6. Stream URL resolution (route-extra URL and client.streamUrl fallback). // -// just_audio behaviour in the test harness: +// just_audio / audio_service behaviour in the test harness: // AudioPlayer initialises lazily; the native just_audio platform channel is // not available in the Flutter unit-test environment, so setAudioSource() // hangs indefinitely if we let it wait for a platform response. We work // around this by: // a) Registering a no-op mock handler for the `com.ryanheise.audio_session` // method channel so that AudioSession.instance resolves immediately. -// b) Using pump(Duration(seconds: N)) instead of pumpAndSettle() to advance +// b) Providing a [_FakePlayerAudioHandler] via [audioHandlerProvider] so +// that no real AudioPlayer or AudioService platform channel is invoked. +// c) Using pump(Duration(seconds: N)) instead of pumpAndSettle() to advance // the test clock a fixed amount — enough for the async init path to // attempt and fail, without waiting forever. // @@ -32,10 +34,13 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; +import 'package:just_audio/just_audio.dart'; import 'package:player_android/api/dio_client.dart'; import 'package:player_android/api/player_api_client.dart'; import 'package:player_android/providers/api_client_provider.dart'; +import 'package:player_android/providers/audio_handler_provider.dart'; import 'package:player_android/screens/audio_player_screen.dart'; +import 'package:player_android/services/audio_handler.dart'; // --------------------------------------------------------------------------- // Fakes @@ -94,6 +99,35 @@ class _FakeApiClient extends PlayerApiClient { 'http://localhost:8080/api/v1/media/$mediaId/stream'; } +/// A [PlayerAudioHandler] subclass that wraps a real [AudioPlayer] but +/// overrides [setMediaItem] and playback methods to be no-ops so that no +/// platform channels are invoked during widget tests. +/// +/// The [player] getter returns the real AudioPlayer so that stream subscriptions +/// in [AudioPlayerScreen] (positionStream, playingStream) work without crashing, +/// while [setAudioSource] is the call that will hang (pending platform channel) — +/// matching the existing test behaviour where the screen stays in the loading +/// state. +class _FakePlayerAudioHandler extends PlayerAudioHandler { + _FakePlayerAudioHandler() : super(AudioPlayer()); + + /// Prevents any media-session metadata broadcast from being sent, + /// since there is no registered AudioService in the test harness. + @override + void setMediaItem({required String id, required String title}) { + // no-op in tests — audio_service is not initialised + } + + @override + Future<void> play() async {} // no-op + + @override + Future<void> pause() async {} // no-op + + @override + Future<void> stop() async {} // no-op +} + // --------------------------------------------------------------------------- // Test setup helpers // --------------------------------------------------------------------------- @@ -126,7 +160,10 @@ Future<void> _pumpScreen( _FakeApiClient fakeClient, { String mediaId = '42', String? mediaUrl, + _FakePlayerAudioHandler? fakeHandler, }) async { + final handler = fakeHandler ?? _FakePlayerAudioHandler(); + final router = GoRouter( initialLocation: '/audio/$mediaId', routes: [ @@ -145,6 +182,9 @@ Future<void> _pumpScreen( overrides: [ tokenStorageProvider.overrideWithValue(const _FakeTokenStorage()), apiClientProvider.overrideWithValue(fakeClient), + // Override audioHandlerProvider so no real AudioService or AudioPlayer + // platform channels are invoked during widget tests. + audioHandlerProvider.overrideWithValue(handler), ], child: MaterialApp.router(routerConfig: router), ), |
