mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-18 13:41:34 +02:00
fix(ios): restore lock screen controls across app transitions
This commit is contained in:
1 parent
02901a0c91
commit
00cff9b3c3
8 files changed
+284
-24
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
+20
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user