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/lib/screens | |
| 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/lib/screens')
| -rw-r--r-- | player-android/lib/screens/audio_player_screen.dart | 179 |
1 files changed, 107 insertions, 72 deletions
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'; } } - |
