feat(music): android background playback via media_controls foreground service

Bumps os_media_controls to 4f4b28f3: MediaStyle foreground service with
a JUnit-tested promote/demote/stop policy, artwork URL download (also
fixes video lock-screen art), and task-removal teardown that can't leak
orphan notifications. The music service opts into background mode per
session and requests POST_NOTIFICATIONS before first playback.
This commit is contained in:
edde746
2026-07-05 21:52:58 +02:00
parent 764345f021
commit db18ee4b34
5 changed files with 87 additions and 6 deletions
+12
View File
@@ -152,6 +152,18 @@ class MediaControlsManager {
}
}
/// Enable/disable Android background playback: while enabled, the plugin
/// keeps audio alive with a `mediaPlayback` foreground service and shows a
/// MediaStyle notification for the session. No-op on other platforms.
Future<void> setBackgroundMode(bool enabled) async {
try {
await OsMediaControls.setBackgroundMode(enabled);
appLogger.d('Media controls background mode: $enabled');
} catch (e) {
appLogger.w('Failed to set media controls background mode', error: e);
}
}
/// Clear all media controls
///
/// Should be called when playback stops or screen is disposed.
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:os_media_controls/os_media_controls.dart';
import '../../database/app_database.dart';
@@ -10,6 +11,8 @@ import '../../media/media_server_client.dart';
import '../../mpv/models.dart';
import '../../mpv/player/player.dart';
import '../../utils/app_logger.dart';
import '../../utils/notification_permission.dart';
import '../../utils/platform_detector.dart';
import '../media_controls_manager.dart';
import '../multi_server_manager.dart';
import '../offline_watch_sync_service.dart';
@@ -51,7 +54,7 @@ class _ArmedTrack {
/// Player/resolver failures surface on [errors] (for a snackbar) and
/// auto-skip to the next track; three consecutive failures without playback
/// progress stop the session with [MusicPlaybackStatus.error].
class MusicPlaybackServiceImpl extends MusicPlaybackService {
class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingObserver {
MusicPlaybackServiceImpl({
required MultiServerManager serverManager,
AppDatabase? database,
@@ -66,6 +69,13 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService {
_resolver = resolver ?? ServerMusicSourceResolver(serverManager: serverManager, database: database!),
_coordinator = coordinator ?? PlaybackCoordinator.instance {
_coordinator.registerMusicSession(stopAndDispose: _stopForVideoClaim);
// tvOS has no background-audio session in v1 — pause on backgrounding so
// audio doesn't play over other apps / the home screen. Other platforms
// keep playing under their OS media session.
if (PlatformDetector.isAppleTV()) {
_observesLifecycle = true;
WidgetsBinding.instance.addObserver(this);
}
}
static const _previousRestartThreshold = Duration(seconds: 3);
@@ -108,6 +118,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService {
int _consecutiveFailures = 0;
bool _resumeAfterInterruption = false;
bool _disposed = false;
bool _observesLifecycle = false;
Timer? _sleepTimer;
DateTime? _sleepTimerEndsAt;
@@ -215,6 +226,10 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService {
bool autoplay = true,
}) async {
if (tracks.isEmpty || _disposed) return;
// Android 13+: the background playback notification needs
// POST_NOTIFICATIONS. Fire-and-forget — playback and the foreground
// service run regardless; a denial only hides the notification.
unawaited(ensureNotificationPermission());
final generation = ++_generation;
_finalizeCurrentTrack();
var startIndex = 0;
@@ -246,6 +261,10 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService {
if (generation != _generation) return;
final player = _ensurePlayer();
_ensureMediaControls();
// Re-asserted per open (cheap, idempotent): the native side drops the
// background-mode opt-in when the user swipes the task away, so a
// session that survives task removal heals itself here.
unawaited(_mediaControls?.setBackgroundMode(true));
// Clear any native arm left over from the previous item before the open
// replaces it, so a stray transition can't fire mid-switch.
@@ -691,6 +710,20 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService {
return player.state.isActive ? pause() : play();
}
/// Apple TV only (observer registered in the constructor): pause when the
/// app leaves the foreground — tvOS background audio is not attempted in
/// v1, so playback must not continue under the home screen.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (_disposed) return;
if (state == AppLifecycleState.paused || state == AppLifecycleState.hidden) {
if (isPlaying) {
appLogger.d('App backgrounded on Apple TV — pausing music playback');
unawaited(pause());
}
}
}
@override
Future<void> next() async {
final nextCursor = _queue.nextIndex(manual: true);
@@ -908,6 +941,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService {
final controls = _mediaControls;
_mediaControls = null;
if (controls != null) {
unawaited(controls.setBackgroundMode(false));
unawaited(controls.clear());
controls.dispose();
}
@@ -939,6 +973,10 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService {
void dispose() {
if (_disposed) return;
_disposed = true;
if (_observesLifecycle) {
WidgetsBinding.instance.removeObserver(this);
_observesLifecycle = false;
}
_coordinator.unregisterMusicSession(_stopForVideoClaim);
_completedConfirmTimer?.cancel();
_completedConfirmTimer = null;
@@ -963,6 +1001,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService {
final controls = _mediaControls;
_mediaControls = null;
if (controls != null) {
unawaited(controls.setBackgroundMode(false));
unawaited(controls.clear());
controls.dispose();
}