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
|
/// 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user