fix(ios): restore lock screen controls across app transitions

This commit is contained in:
zarzet committed 2026-10-09 18:08:12 +07:00
1 parent 02901a0c91
commit 00cff9b3c3
8 files changed
+284 -24

No files matched your search

+70
View File
@@ -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)
@@ -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();
}
});
+44 -15
View File
@@ -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<void> 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<void> 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<void> 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<void> _dispose() async {
_autoplay.reset();
_disposed = true;
if (_dependencies.isIOS) WidgetsBinding.instance.removeObserver(this);
if (identical(musicPlayerRuntime.handler, this)) {
autoMixStatus.value = AutoMixStatus.idle;
}
+2 -1
View File
@@ -153,7 +153,6 @@ class _MotionHeaderBannerState extends ConsumerState<MotionHeaderBanner>
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<MotionHeaderBanner>
_disposeController();
setState(() => _failed = true);
widget.onError?.call();
} finally {
await restoreMusicAudioSessionAfterVideo();
}
}
+135 -5
View File
@@ -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 = <bool>[];
Completer<void>? configureBarrier;
final interruptions = StreamController<AudioInterruptionEvent>.broadcast();
Future<void> dispose() => interruptions.close();
@override
Future<void> configure(AudioSessionConfiguration configuration) async {}
Future<void> configure(AudioSessionConfiguration configuration) async {
configurations++;
await configureBarrier?.future;
}
@override
Future<bool> 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<AudioInterruptionEvent> get interruptionEventStream =>
const Stream.empty();
interruptions.stream;
@override
Stream<void> get becomingNoisyEventStream => const Stream.empty();
@@ -165,7 +181,11 @@ MusicPlayerRuntime _runtime({
Stream<double> 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 = <MethodCall>[];
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<void>();
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 {
+4
View File
@@ -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
@@ -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;
}
+7
View File
@@ -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<void> refreshNowPlayingInfo() async {
await const MethodChannel('com.ryanheise.audio_service.client.methods')
.invokeMethod<void>('refreshNowPlayingInfo');
}
/// Stops the service.
static Future<void> _stop() async {
await _platform.stopService(const StopServiceRequest());