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 /// Clear all media controls
/// ///
/// Should be called when playback stops or screen is disposed. /// Should be called when playback stops or screen is disposed.
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:os_media_controls/os_media_controls.dart'; import 'package:os_media_controls/os_media_controls.dart';
import '../../database/app_database.dart'; import '../../database/app_database.dart';
@@ -10,6 +11,8 @@ import '../../media/media_server_client.dart';
import '../../mpv/models.dart'; import '../../mpv/models.dart';
import '../../mpv/player/player.dart'; import '../../mpv/player/player.dart';
import '../../utils/app_logger.dart'; import '../../utils/app_logger.dart';
import '../../utils/notification_permission.dart';
import '../../utils/platform_detector.dart';
import '../media_controls_manager.dart'; import '../media_controls_manager.dart';
import '../multi_server_manager.dart'; import '../multi_server_manager.dart';
import '../offline_watch_sync_service.dart'; import '../offline_watch_sync_service.dart';
@@ -51,7 +54,7 @@ class _ArmedTrack {
/// Player/resolver failures surface on [errors] (for a snackbar) and /// Player/resolver failures surface on [errors] (for a snackbar) and
/// auto-skip to the next track; three consecutive failures without playback /// auto-skip to the next track; three consecutive failures without playback
/// progress stop the session with [MusicPlaybackStatus.error]. /// progress stop the session with [MusicPlaybackStatus.error].
class MusicPlaybackServiceImpl extends MusicPlaybackService { class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingObserver {
MusicPlaybackServiceImpl({ MusicPlaybackServiceImpl({
required MultiServerManager serverManager, required MultiServerManager serverManager,
AppDatabase? database, AppDatabase? database,
@@ -66,6 +69,13 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService {
_resolver = resolver ?? ServerMusicSourceResolver(serverManager: serverManager, database: database!), _resolver = resolver ?? ServerMusicSourceResolver(serverManager: serverManager, database: database!),
_coordinator = coordinator ?? PlaybackCoordinator.instance { _coordinator = coordinator ?? PlaybackCoordinator.instance {
_coordinator.registerMusicSession(stopAndDispose: _stopForVideoClaim); _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); static const _previousRestartThreshold = Duration(seconds: 3);
@@ -108,6 +118,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService {
int _consecutiveFailures = 0; int _consecutiveFailures = 0;
bool _resumeAfterInterruption = false; bool _resumeAfterInterruption = false;
bool _disposed = false; bool _disposed = false;
bool _observesLifecycle = false;
Timer? _sleepTimer; Timer? _sleepTimer;
DateTime? _sleepTimerEndsAt; DateTime? _sleepTimerEndsAt;
@@ -215,6 +226,10 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService {
bool autoplay = true, bool autoplay = true,
}) async { }) async {
if (tracks.isEmpty || _disposed) return; 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; final generation = ++_generation;
_finalizeCurrentTrack(); _finalizeCurrentTrack();
var startIndex = 0; var startIndex = 0;
@@ -246,6 +261,10 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService {
if (generation != _generation) return; if (generation != _generation) return;
final player = _ensurePlayer(); final player = _ensurePlayer();
_ensureMediaControls(); _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 // Clear any native arm left over from the previous item before the open
// replaces it, so a stray transition can't fire mid-switch. // 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(); 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 @override
Future<void> next() async { Future<void> next() async {
final nextCursor = _queue.nextIndex(manual: true); final nextCursor = _queue.nextIndex(manual: true);
@@ -908,6 +941,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService {
final controls = _mediaControls; final controls = _mediaControls;
_mediaControls = null; _mediaControls = null;
if (controls != null) { if (controls != null) {
unawaited(controls.setBackgroundMode(false));
unawaited(controls.clear()); unawaited(controls.clear());
controls.dispose(); controls.dispose();
} }
@@ -939,6 +973,10 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService {
void dispose() { void dispose() {
if (_disposed) return; if (_disposed) return;
_disposed = true; _disposed = true;
if (_observesLifecycle) {
WidgetsBinding.instance.removeObserver(this);
_observesLifecycle = false;
}
_coordinator.unregisterMusicSession(_stopForVideoClaim); _coordinator.unregisterMusicSession(_stopForVideoClaim);
_completedConfirmTimer?.cancel(); _completedConfirmTimer?.cancel();
_completedConfirmTimer = null; _completedConfirmTimer = null;
@@ -963,6 +1001,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService {
final controls = _mediaControls; final controls = _mediaControls;
_mediaControls = null; _mediaControls = null;
if (controls != null) { if (controls != null) {
unawaited(controls.setBackgroundMode(false));
unawaited(controls.clear()); unawaited(controls.clear());
controls.dispose(); controls.dispose();
} }
+31
View File
@@ -0,0 +1,31 @@
import 'dart:io';
import 'package:background_downloader/background_downloader.dart';
import 'app_logger.dart';
bool _requested = false;
/// Best-effort request for the Android 13+ POST_NOTIFICATIONS runtime
/// permission (routed through background_downloader's permissions API, which
/// the app already ships for download notifications).
///
/// Needed so the background music playback notification is visible; playback
/// and its foreground service run regardless of the outcome, so a denial only
/// costs notification visibility. Asked at most once per app run — Android
/// remembers a real denial, so re-prompting is a no-op anyway.
///
/// No-op off Android: iOS/macOS media controls don't use notifications.
Future<void> ensureNotificationPermission() async {
if (_requested || !Platform.isAndroid) return;
_requested = true;
try {
final permissions = FileDownloader().permissions;
final status = await permissions.status(PermissionType.notifications);
if (status == PermissionStatus.granted) return;
final result = await permissions.request(PermissionType.notifications);
appLogger.d('Notification permission request result: $result');
} catch (e) {
appLogger.w('Notification permission request failed', error: e);
}
}
+3 -3
View File
@@ -768,11 +768,11 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
path: "." path: "."
ref: f51c805ebc15bf7a2f49a74174aeb470d3c4c78e ref: "4f4b28f3e669f6ba2421cef5d240ddd37f244b03"
resolved-ref: f51c805ebc15bf7a2f49a74174aeb470d3c4c78e resolved-ref: "4f4b28f3e669f6ba2421cef5d240ddd37f244b03"
url: "https://github.com/edde746/media_controls" url: "https://github.com/edde746/media_controls"
source: git source: git
version: "0.2.4" version: "0.3.0"
package_config: package_config:
dependency: transitive dependency: transitive
description: description:
+1 -2
View File
@@ -35,7 +35,7 @@ dependencies:
os_media_controls: os_media_controls:
git: git:
url: https://github.com/edde746/media_controls url: https://github.com/edde746/media_controls
ref: f51c805ebc15bf7a2f49a74174aeb470d3c4c78e ref: 4f4b28f3e669f6ba2421cef5d240ddd37f244b03
rate_limiter: ^1.0.0 rate_limiter: ^1.0.0
wakelock_plus: wakelock_plus:
git: git:
@@ -127,7 +127,6 @@ dependency_overrides:
git: git:
url: https://github.com/edde746/material_symbols_icons url: https://github.com/edde746/material_symbols_icons
ref: 1d8cd83 ref: 1d8cd83
sentry: sentry:
org: plezy org: plezy
project: plezy project: plezy