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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
@@ -768,11 +768,11 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: f51c805ebc15bf7a2f49a74174aeb470d3c4c78e
|
||||
resolved-ref: f51c805ebc15bf7a2f49a74174aeb470d3c4c78e
|
||||
ref: "4f4b28f3e669f6ba2421cef5d240ddd37f244b03"
|
||||
resolved-ref: "4f4b28f3e669f6ba2421cef5d240ddd37f244b03"
|
||||
url: "https://github.com/edde746/media_controls"
|
||||
source: git
|
||||
version: "0.2.4"
|
||||
version: "0.3.0"
|
||||
package_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
+1
-2
@@ -35,7 +35,7 @@ dependencies:
|
||||
os_media_controls:
|
||||
git:
|
||||
url: https://github.com/edde746/media_controls
|
||||
ref: f51c805ebc15bf7a2f49a74174aeb470d3c4c78e
|
||||
ref: 4f4b28f3e669f6ba2421cef5d240ddd37f244b03
|
||||
rate_limiter: ^1.0.0
|
||||
wakelock_plus:
|
||||
git:
|
||||
@@ -127,7 +127,6 @@ dependency_overrides:
|
||||
git:
|
||||
url: https://github.com/edde746/material_symbols_icons
|
||||
ref: 1d8cd83
|
||||
|
||||
sentry:
|
||||
org: plezy
|
||||
project: plezy
|
||||
|
||||
Reference in New Issue
Block a user