From db18ee4b34cb1f31244eb51e4eae11ed4f22f4c8 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:52:58 +0200 Subject: [PATCH] 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. --- lib/services/media_controls_manager.dart | 12 ++++++ .../music/music_playback_service_impl.dart | 41 ++++++++++++++++++- lib/utils/notification_permission.dart | 31 ++++++++++++++ pubspec.lock | 6 +-- pubspec.yaml | 3 +- 5 files changed, 87 insertions(+), 6 deletions(-) create mode 100644 lib/utils/notification_permission.dart diff --git a/lib/services/media_controls_manager.dart b/lib/services/media_controls_manager.dart index 5f098b68..434ef7f0 100644 --- a/lib/services/media_controls_manager.dart +++ b/lib/services/media_controls_manager.dart @@ -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 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. diff --git a/lib/services/music/music_playback_service_impl.dart b/lib/services/music/music_playback_service_impl.dart index d30cd00a..e1be0e42 100644 --- a/lib/services/music/music_playback_service_impl.dart +++ b/lib/services/music/music_playback_service_impl.dart @@ -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 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(); } diff --git a/lib/utils/notification_permission.dart b/lib/utils/notification_permission.dart new file mode 100644 index 00000000..a335e194 --- /dev/null +++ b/lib/utils/notification_permission.dart @@ -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 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); + } +} diff --git a/pubspec.lock b/pubspec.lock index 798f02d0..688107f7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 5ff792d1..67e21f1a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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