From 00cff9b3c3d6d57388ee8795d256679edf257667 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Fri, 9 Oct 2026 18:08:12 +0700 Subject: [PATCH] fix(ios): restore lock screen controls across app transitions --- ios/RunnerTests/RunnerTests.swift | 70 +++++++++ .../player_artwork_video_provider.dart | 3 +- lib/services/music_player_service.dart | 59 ++++++-- lib/widgets/motion_header_banner.dart | 3 +- test/music_player_runtime_test.dart | 140 +++++++++++++++++- .../audio_service/SPOTIFLAC_PATCHES.md | 4 + .../audio_service/AudioServicePlugin.m | 22 ++- .../audio_service/lib/audio_service.dart | 7 + 8 files changed, 284 insertions(+), 24 deletions(-) diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift index 4a1482e3..c7073ea5 100644 --- a/ios/RunnerTests/RunnerTests.swift +++ b/ios/RunnerTests/RunnerTests.swift @@ -1,9 +1,79 @@ import Foundation import Flutter +import MediaPlayer import XCTest +import audio_service @testable import Runner class RunnerTests: XCTestCase { + @MainActor + func testNowPlayingRecoveryPreservesTrackArtworkAndControlsWithoutSkipping() throws { + let plugin = AudioServicePlugin() + func call(_ method: String, _ arguments: [String: Any] = [:]) { + var replied = false + plugin.handle(FlutterMethodCall(methodName: method, arguments: arguments)) { result in + XCTAssertFalse(result is FlutterError) + replied = true + } + XCTAssertTrue(replied) + } + call("stopService") + defer { call("stopService") } + + let artworkURL = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).png") + let image = UIGraphicsImageRenderer(size: CGSize(width: 16, height: 16)).image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 16, height: 16)) + } + try XCTUnwrap(image.pngData()).write(to: artworkURL) + defer { try? FileManager.default.removeItem(at: artworkURL) } + call("setMediaItem", ["mediaItem": [ + "id": "fixture", "title": "Same track", "artist": "Test artist", "album": "Test album", + "duration": 180_000, "isLive": false, "artUri": artworkURL.absoluteString, + "extras": ["artCacheFile": artworkURL.path], + ]]) + func state(playing: Bool, position: Int, updateAge: Double) -> [String: Any] { + ["state": [ + "processingState": 3, "playing": playing, + "updatePosition": position, "bufferedPosition": 180_000, + "updateTime": (Date().timeIntervalSince1970 - updateAge) * 1000, + "speed": 1.5, "repeatMode": 0, "shuffleMode": 0, + "controls": [["action": playing ? 1 : 2], ["action": 4], ["action": 5]], + "systemActions": [8], + ]] + } + call("setState", state(playing: true, position: 17_000, updateAge: 10)) + let center = MPNowPlayingInfoCenter.default() + let artwork = try XCTUnwrap(center.nowPlayingInfo?[MPMediaItemPropertyArtwork] as? MPMediaItemArtwork) + + // Simulate repeated loss of the OS card while the plugin retains the + // same item. Recovery must not depend on a new title or artwork event. + for _ in 0..<3 { + center.nowPlayingInfo = nil + call("refreshNowPlayingInfo") + let info = try XCTUnwrap(center.nowPlayingInfo) + XCTAssertEqual(info[MPMediaItemPropertyTitle] as? String, "Same track") + XCTAssertEqual(info[MPMediaItemPropertyArtist] as? String, "Test artist") + XCTAssertTrue(info[MPMediaItemPropertyArtwork] as? MPMediaItemArtwork === artwork) + let elapsed = try XCTUnwrap(info[MPNowPlayingInfoPropertyElapsedPlaybackTime] as? Double) + XCTAssertEqual(elapsed, 32, accuracy: 1) + XCTAssertEqual(info[MPNowPlayingInfoPropertyPlaybackRate] as? Double, 1.5) + XCTAssertTrue(MPRemoteCommandCenter.shared().pauseCommand.isEnabled) + XCTAssertTrue(MPRemoteCommandCenter.shared().nextTrackCommand.isEnabled) + XCTAssertTrue(MPRemoteCommandCenter.shared().changePlaybackPositionCommand.isEnabled) + } + + call("setState", state(playing: false, position: 42_000, updateAge: 60)) + center.nowPlayingInfo = nil + call("refreshNowPlayingInfo") + XCTAssertEqual(center.nowPlayingInfo?[MPNowPlayingInfoPropertyElapsedPlaybackTime] as? Double, 42) + XCTAssertEqual(center.nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] as? Double, 0) + XCTAssertTrue(MPRemoteCommandCenter.shared().playCommand.isEnabled) + call("stopService") + call("refreshNowPlayingInfo") + XCTAssertNil(center.nowPlayingInfo) + } + @MainActor func testPlayerWidgetSharesSceneEngineAndCompletesCommands() async throws { let delegate = try XCTUnwrap(UIApplication.shared.delegate as? AppDelegate) diff --git a/lib/providers/player_artwork_video_provider.dart b/lib/providers/player_artwork_video_provider.dart index d5040bb3..53368ced 100644 --- a/lib/providers/player_artwork_video_provider.dart +++ b/lib/providers/player_artwork_video_provider.dart @@ -26,10 +26,11 @@ final playerArtworkVideoProvider = FutureProvider.autoDispose await controller.setVolume(0); if (!ref.mounted) return controller; await controller.setLooping(true); - await restoreMusicAudioSessionAfterVideo(); return controller; } catch (_) { unawaited(disposeController()); rethrow; + } finally { + await restoreMusicAudioSessionAfterVideo(); } }); diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index f5bd47ae..803167ae 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -4,6 +4,8 @@ import 'dart:math'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart' + show AppLifecycleState, WidgetsBinding, WidgetsBindingObserver; import 'package:spotiflac_android/services/playback_notification.dart'; import 'package:audio_session/audio_session.dart' show @@ -54,20 +56,10 @@ void configurePlaybackNotification({ toggleFavorite: toggleFavorite, ); -/// Decorative video plugins share AVAudioSession with the music player and -/// may enable mixing while creating a muted cover. Restore music ownership -/// without activating a session when this app is not playing music. -Future restoreMusicAudioSessionAfterVideo() async { - if (!musicPlayerRuntime.dependencies.isIOS) return; - final handler = musicPlayerRuntime.handler; - if (handler == null || handler.mediaItem.value == null) return; - try { - final session = await musicPlayerRuntime.dependencies.audioSession(); - await session.configure(const AudioSessionConfiguration.music()); - } catch (error) { - _log.w('Could not restore music audio session after video: $error'); - } -} +/// Muted artwork shares the music session. Recover its Now Playing ownership +/// after video initialization, including failed or abandoned initialization. +Future restoreMusicAudioSessionAfterVideo() async => + musicPlayerRuntime.handler?.restoreIosNowPlaying(); /// Enables/disables ReplayGain volume normalization and re-applies it to the /// track currently playing. @@ -304,7 +296,7 @@ Duration normalizedPlaybackResumePosition( } class MusicPlayerHandler extends BaseAudioHandler - with QueueHandler, SeekHandler { + with QueueHandler, SeekHandler, WidgetsBindingObserver { final MusicPlayerRuntime _runtime; PlaybackDependencies get _dependencies => _runtime.dependencies; AppSettings get _settings => _runtime.settings; @@ -514,6 +506,42 @@ class MusicPlayerHandler extends BaseAudioHandler _refreshPlaybackAutomation(); _listenToPlayer(_player); + if (_dependencies.isIOS) WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed || + state == AppLifecycleState.inactive || + state == AppLifecycleState.paused) { + unawaited(restoreIosNowPlaying()); + } + } + + /// Recover system controls without reloading, seeking or restarting audio. + /// Never reclaim audio focus for a paused/interrupted/stopped player. + Future restoreIosNowPlaying() async { + final generation = _playRequestGeneration; + bool canRestore() => + _dependencies.isIOS && + !_disposed && + generation == _playRequestGeneration && + !_interruptionActive && + !_userPaused && + playbackState.value.playing && + playbackState.value.processingState == AudioProcessingState.ready && + mediaItem.value != null; + if (!canRestore()) return; + try { + final session = _audioSession ?? await _dependencies.audioSession(); + if (!canRestore()) return; + await session.configure(const AudioSessionConfiguration.music()); + if (!canRestore()) return; + if (!await session.setActive(true) || !canRestore()) return; + await AudioService.refreshNowPlayingInfo(); + } catch (error) { + _log.w('Could not restore iOS playback controls: $error'); + } } void _listenToPlayer(MusicPlaybackDeck player) { @@ -2067,6 +2095,7 @@ class MusicPlayerHandler extends BaseAudioHandler Future _dispose() async { _autoplay.reset(); _disposed = true; + if (_dependencies.isIOS) WidgetsBinding.instance.removeObserver(this); if (identical(musicPlayerRuntime.handler, this)) { autoMixStatus.value = AutoMixStatus.idle; } diff --git a/lib/widgets/motion_header_banner.dart b/lib/widgets/motion_header_banner.dart index 3e370af7..b96b39b1 100644 --- a/lib/widgets/motion_header_banner.dart +++ b/lib/widgets/motion_header_banner.dart @@ -153,7 +153,6 @@ class _MotionHeaderBannerState extends ConsumerState if (!mounted || !identical(controller, _controller)) return; await controller.setVolume(0); await controller.setLooping(true); - await restoreMusicAudioSessionAfterVideo(); if (!mounted || !identical(controller, _controller)) return; setState(() => _ready = true); widget.onAspectRatioChanged?.call(controller.value.aspectRatio); @@ -164,6 +163,8 @@ class _MotionHeaderBannerState extends ConsumerState _disposeController(); setState(() => _failed = true); widget.onError?.call(); + } finally { + await restoreMusicAudioSessionAfterVideo(); } } diff --git a/test/music_player_runtime_test.dart b/test/music_player_runtime_test.dart index 874d954a..4469ef3b 100644 --- a/test/music_player_runtime_test.dart +++ b/test/music_player_runtime_test.dart @@ -5,6 +5,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:audio_session/audio_session.dart'; import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart' show AppLifecycleState; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:spotiflac_android/models/settings.dart'; @@ -91,8 +92,19 @@ class _Deck implements MusicPlaybackDeck { } class _Session implements AudioSession { + int configurations = 0; + final activations = []; + Completer? configureBarrier; + final interruptions = StreamController.broadcast(); + + Future dispose() => interruptions.close(); + @override - Future configure(AudioSessionConfiguration configuration) async {} + Future configure(AudioSessionConfiguration configuration) async { + configurations++; + await configureBarrier?.future; + } + @override Future setActive( bool active, { @@ -102,10 +114,14 @@ class _Session implements AudioSession { bool? androidWillPauseWhenDucked, AudioSessionConfiguration fallbackConfiguration = const AudioSessionConfiguration.music(), - }) async => true; + }) async { + activations.add(active); + return true; + } + @override Stream get interruptionEventStream => - const Stream.empty(); + interruptions.stream; @override Stream get becomingNoisyEventStream => const Stream.empty(); @@ -165,7 +181,11 @@ MusicPlayerRuntime _runtime({ Stream volumeChanges = const Stream.empty(), SourceDeletionEvents? sourceDeletionEvents, ListeningRecorder? recorder, + bool isIOS = false, + _Session? session, }) { + final audioSession = session ?? _Session(); + addTearDown(audioSession.dispose); final runtime = MusicPlayerRuntime( settings: settings, dependencies: PlaybackDependencies( @@ -176,7 +196,7 @@ MusicPlayerRuntime _runtime({ decks?.add(deck); return deck; }, - audioSession: () async => _Session(), + audioSession: () async => audioSession, initializeHandler: initialize ?? (builder) async => builder(), readMetadata: (_, {displayName}) async => { 'replaygain_track_gain': '-6 dB', @@ -192,7 +212,7 @@ MusicPlayerRuntime _runtime({ volumeChanges: volumeChanges, sourceDeletionEvents: sourceDeletionEvents ?? SourceDeletionEvents(), isAndroid: false, - isIOS: false, + isIOS: isIOS, ), ); addTearDown(runtime.dispose); @@ -213,6 +233,116 @@ const _track = PlayableMedia( void main() { TestWidgetsFlutterBinding.ensureInitialized(); + test( + 'iOS restores controls across unlock/background without changing track', + () async { + const channel = MethodChannel( + 'com.ryanheise.audio_service.client.methods', + ); + final calls = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async => calls.add(call)); + addTearDown( + () => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + final session = _Session(); + final runtime = _runtime(isIOS: true, session: session); + final handler = await runtime.initialize(); + await _settle(); + handler.mediaItem.add(_item); + final state = PlaybackState( + playing: true, + processingState: AudioProcessingState.ready, + updatePosition: const Duration(seconds: 17), + ); + handler.playbackState.add(state); + final configurations = session.configurations; + for (final lifecycle in [ + AppLifecycleState.inactive, + AppLifecycleState.paused, + AppLifecycleState.resumed, + AppLifecycleState.inactive, + AppLifecycleState.paused, + ]) { + handler.didChangeAppLifecycleState(lifecycle); + await _settle(); + } + expect(session.configurations, configurations + 5); + expect(session.activations, List.filled(5, true)); + expect( + calls.map((call) => call.method), + List.filled(5, 'refreshNowPlayingInfo'), + ); + expect(handler.mediaItem.value, same(_item)); + expect(handler.playbackState.value, same(state)); + }, + ); + + test( + 'iOS does not reclaim controls when paused, stopped or interrupted', + () async { + final session = _Session(); + final runtime = _runtime(isIOS: true, session: session); + final handler = await runtime.initialize(); + await _settle(); + final configurations = session.configurations; + await handler.restoreIosNowPlaying(); + handler.mediaItem.add(_item); + handler.playbackState.add( + PlaybackState(processingState: AudioProcessingState.ready), + ); + await handler.restoreIosNowPlaying(); + handler.playbackState.add(PlaybackState(playing: true)); + await handler.restoreIosNowPlaying(); + expect(session.configurations, configurations); + expect(session.activations, isEmpty); + + handler.playbackState.add( + PlaybackState( + playing: true, + processingState: AudioProcessingState.ready, + ), + ); + session.interruptions.add( + AudioInterruptionEvent(true, AudioInterruptionType.pause), + ); + await _settle(); + await handler.restoreIosNowPlaying(); + expect(session.activations, isEmpty); + await handler.dispose(); + await handler.restoreIosNowPlaying(); + expect(session.activations, isEmpty); + }, + ); + + test( + 'iOS recovery cannot activate audio after a pause during configuration', + () async { + final session = _Session(); + final handler = await _runtime( + isIOS: true, + session: session, + ).initialize(); + await _settle(); + handler.mediaItem.add(_item); + handler.playbackState.add( + PlaybackState( + playing: true, + processingState: AudioProcessingState.ready, + ), + ); + session.configureBarrier = Completer(); + final restore = handler.restoreIosNowPlaying(); + await _settle(); + await handler.pause(); + session.configureBarrier!.complete(); + await restore; + expect(session.activations, isEmpty); + expect(handler.playbackState.value.playing, isFalse); + }, + ); + test( 'muted start pauses and warns; explicit resume survives source reload', () async { diff --git a/third_party/audio_service/SPOTIFLAC_PATCHES.md b/third_party/audio_service/SPOTIFLAC_PATCHES.md index d5dab3f7..9f28fabf 100644 --- a/third_party/audio_service/SPOTIFLAC_PATCHES.md +++ b/third_party/audio_service/SPOTIFLAC_PATCHES.md @@ -17,6 +17,10 @@ adding another playback engine or native SDK. `PinnedMetadataCache`. The active queue and current item stay pinned so notification, Android Auto and browser lookups survive recent-item eviction. Replacing the queue and clearing the service release their pinned metadata. +- iOS: allow the active music handler to re-publish retained Now Playing + metadata after restoring its audio session on app lifecycle/video changes. + Refresh keeps artwork and extrapolates elapsed time without restarting audio; + it never activates the audio session or revives a stopped service. Android 13+ still chooses the positions and built-in transport icons in System UI. We preserve real play/pause/previous/next semantics for headsets, lock screen diff --git a/third_party/audio_service/darwin/audio_service/Sources/audio_service/AudioServicePlugin.m b/third_party/audio_service/darwin/audio_service/Sources/audio_service/AudioServicePlugin.m index 4e39524c..a53e4e09 100644 --- a/third_party/audio_service/darwin/audio_service/Sources/audio_service/AudioServicePlugin.m +++ b/third_party/audio_service/darwin/audio_service/Sources/audio_service/AudioServicePlugin.m @@ -227,6 +227,15 @@ static NSMutableDictionary *nowPlayingInfo = nil; result(@{}); } else if ([@"androidForceEnableMediaButtons" isEqualToString:call.method]) { result(@{}); + } else if ([@"refreshNowPlayingInfo" isEqualToString:call.method]) { + // A foreground video/session change can remove the system card while + // our metadata cache stays unchanged. Re-publish the current snapshot, + // but never resurrect a stopped service or activate audio ourselves. + if (commandCenter && mediaItem && processingState != ApsIdle) { + [self updateControls]; + [self updateNowPlayingInfoForce:YES]; + } + result(@{}); } else if ([@"stopService" isEqualToString:call.method]) { [commandCenter.changePlaybackRateCommand setEnabled:NO]; [commandCenter.togglePlayPauseCommand setEnabled:NO]; @@ -269,6 +278,10 @@ static NSMutableDictionary *nowPlayingInfo = nil; } - (void) updateNowPlayingInfo { + [self updateNowPlayingInfoForce:NO]; +} + +- (void) updateNowPlayingInfoForce:(BOOL)force { BOOL updated = NO; if (mediaItem) { updated |= [self updateNowPlayingField:MPMediaItemPropertyTitle value:mediaItem[@"title"]]; @@ -287,7 +300,12 @@ static NSMutableDictionary *nowPlayingInfo = nil; } updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyPlaybackRate value:(playing ? speed : [NSNumber numberWithDouble: 0.0])]; updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyDefaultPlaybackRate value:(playing ? speed : [NSNumber numberWithDouble: 0.0])]; - updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyElapsedPlaybackTime value:[NSNumber numberWithDouble:([position doubleValue] / 1000)]]; + double elapsed = [position doubleValue] / 1000; + if (force && playing && processingState == ApsReady) { + double now = [[NSDate date] timeIntervalSince1970]; + elapsed += MAX(0, now - [updateTime doubleValue] / 1000) * [speed doubleValue]; + } + updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyElapsedPlaybackTime value:@(elapsed)]; MPNowPlayingInfoCenter *center = [MPNowPlayingInfoCenter defaultCenter]; #if TARGET_OS_OSX if (@available(iOS 13.0, macOS 10.12.2, *)) { @@ -297,7 +315,7 @@ static NSMutableDictionary *nowPlayingInfo = nil; if (@available(iOS 10.0, macOS 10.12.2, *)) { updated |= [self updateNowPlayingField:MPNowPlayingInfoPropertyIsLiveStream value:mediaItem[@"isLive"]]; } - if (updated) { + if (updated || force) { //NSLog(@"### updating nowPlayingInfo"); center.nowPlayingInfo = nowPlayingInfo; } diff --git a/third_party/audio_service/lib/audio_service.dart b/third_party/audio_service/lib/audio_service.dart index 3104d7be..74e7a84b 100644 --- a/third_party/audio_service/lib/audio_service.dart +++ b/third_party/audio_service/lib/audio_service.dart @@ -1228,6 +1228,13 @@ class AudioService { ); } + /// Re-publishes the retained iOS metadata after the audio session is restored. + /// Does not reload artwork or change playback. Call only on iOS. + static Future refreshNowPlayingInfo() async { + await const MethodChannel('com.ryanheise.audio_service.client.methods') + .invokeMethod('refreshNowPlayingInfo'); + } + /// Stops the service. static Future _stop() async { await _platform.stopService(const StopServiceRequest());