summaryrefslogtreecommitdiff
path: root/player-android
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-21 18:35:26 +0300
committerPaul Buetow <paul@buetow.org>2026-05-21 18:35:26 +0300
commite58a12ee773da39d639ebb0e26fdb1b782941c56 (patch)
tree60dbf99bc2daf8f9c0b0b0709a7fa0fa106eb8f1 /player-android
parent627f44e105926e5ad4963336cdbfd9f9522bcdb1 (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.dart56
-rw-r--r--player-android/lib/providers/audio_handler_provider.dart43
-rw-r--r--player-android/lib/screens/audio_player_screen.dart179
-rw-r--r--player-android/lib/services/audio_handler.dart198
-rw-r--r--player-android/test/screens/audio_player_screen_test.dart44
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),
),