Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,12 @@ public final class MusicPlaybackController {
guard newState != state else { return }
state = newState
if newState == .paused || newState == .idle || newState == .ended { pauseEngine() }
reportState(newState)
}

/// The current track's state, on the callback queue. Repeated by ``stayPaused()`` when a play fails and the
/// engine was already paused: `setState` would otherwise drop it, and the owner would never hear the refusal.
private func reportState(_ newState: State) {
let uid = current?.track.uid
let callback = callbackLock.withLock { callbacks.state }
if let callback { callbackQueue.async { callback(newState, uid) } }
Expand Down Expand Up @@ -633,11 +639,17 @@ public final class MusicPlaybackController {
}

/// A play the engine couldn't start: paused, as if it had been asked to pause, so the owner and
/// Now Playing show it paused. Nothing plays until the next play.
/// Now Playing show it paused. Nothing plays until the next play. Reported again when already
/// paused — the state didn't change, but the owner still has to hear that this play was refused.
/// Not a decode failure: the track stays put.
private func stayPaused() {
playWhenReady = false
stopTicker()
setState(.paused)
if state == .paused {
reportState(.paused)
} else {
setState(.paused)
}
}

/// Opens `slot`'s source, or waits for the open already under way; false (after reporting it)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,12 +335,24 @@ final class MusicPlaybackControllerTests: XCTestCase {

controller.play()
controller.syncForTesting()
XCTAssertEqual(log.states, [.loading, .paused])
// Already paused, so the state didn't change — the refusal is reported again anyway, for this track.
XCTAssertEqual(log.states, [.loading, .paused, .paused])
XCTAssertEqual(log.failures, [])

controller.startEngine = { try $0.start() }
controller.play()
controller.syncForTesting()
XCTAssertEqual(log.states, [.loading, .paused, .playing])
XCTAssertEqual(log.states, [.loading, .paused, .paused, .playing])
}

func testPausingWhileAlreadyPausedDoesNotRepeatTheReport() throws {
let (controller, log) = try makeController()
controller.load(current: track("A", TestSignal.noise(frames: 12_000, seed: 1)), next: nil, playWhenReady: false)
controller.syncForTesting()

controller.pause()
controller.syncForTesting()
XCTAssertEqual(log.states, [.loading, .paused])
}

func testALoadThatPlaysWhenTheEngineCantStartLoadsPausedAndStaysPaused() throws {
Expand All @@ -351,6 +363,7 @@ final class MusicPlaybackControllerTests: XCTestCase {
controller.load(current: track("A", TestSignal.noise(frames: 12_000, seed: 1)), next: nil, playWhenReady: true)
controller.syncForTesting()
XCTAssertEqual(log.states, [.loading, .paused])
XCTAssertEqual(log.failures, [], "a start failure is not a song that failed to decode")

// The failed play is forgotten: a seek, which restarts the stream, doesn't try again.
controller.startEngine = { try $0.start() }
Expand Down
17 changes: 15 additions & 2 deletions ios/S2/Platform/Audio/EngineAudioPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import Shared
/// **Audio session.** `onWillPlay` runs before anything plays (`play()`, or a load that plays) and `onPaused` on
/// every pause; `PlaybackSystemCoordinator` points them at `AudioSessionController.activate()` and
/// `playbackPaused()`. A session that won't activate (a call, another app holding the hardware) cancels the play:
/// `play()` does nothing and a load loads paused, so the engine reports paused, as when the engine can't start.
/// `play()` does nothing and a load loads paused. Both report paused for that track, on the calling thread, so Kotlin
/// can drop the intent. A refused load's report is not the track becoming ready — the engine's own loading and paused
/// reports, afterwards and in order, are. A refused play of a track that's already ready has no engine transition, so
/// the paused report is the only one.
final class EngineAudioPlayer: NSObject, IosAudioPlayer {
/// Called on the main thread before playback starts; false cancels it.
var onWillPlay: () -> Bool = { true }
Expand Down Expand Up @@ -80,17 +83,27 @@ final class EngineAudioPlayer: NSObject, IosAudioPlayer {
}
let plays = playWhenReady && onWillPlay()
engine.load(current: track, next: next.flatMap(engineTrack), startMs: startMs, playWhenReady: plays)
if playWhenReady && !plays { reportPaused(current.id) }
}

func setNext(next: IosAudioTrack?) {
engine.setNext(next.flatMap(engineTrack))
}

func play() {
guard onWillPlay() else { return }
guard onWillPlay() else {
if let currentId { reportPaused(currentId) }
return
}
engine.play()
}

/// The session refused playback of `trackId`. Synchronous, on the call's thread (the main thread): Kotlin is still
/// inside the load or play and can tell this from the engine's later ready-paused.
private func reportPaused(_ trackId: String) {
listener?.onStateChanged(trackId: trackId, state: .paused)
}

func pause() {
engine.pause()
onPaused()
Expand Down
32 changes: 24 additions & 8 deletions ios/S2/Platform/Audio/PlaybackSystemCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,14 @@ final class PlaybackSystemCoordinator: NowPlayingCommandHandler {
player.onPaused = {}
}

/// Read from the flow's value, not `isPlaying`, which lags it by a hop through the observer task.
/// Read from the flows' values, not `isPlaying`, which lags them by a hop through the observer task.
/// Playing, or loading with the intent to play. An idle, ended or failed track is paused and must not
/// advertise playback — including to an interruption, which resumes only when this was true.
private var playerIsPlaying: Bool {
playback.playbackStateFlow.value is PlaybackState.Playing
let state = playback.playbackStateFlow.value
if state is PlaybackState.Playing { return true }
if state is PlaybackState.Loading { return playback.playWhenReadyFlow.value.boolValue }
return false
}

private func observe() {
Expand All @@ -87,6 +92,11 @@ final class PlaybackSystemCoordinator: NowPlayingCommandHandler {
self?.publish()
}
},
Task { [weak self] in
for await _ in playback.playWhenReadyFlow {
self?.publish()
}
},
Task { [weak self] in
for await _ in playback.progressFlow {
self?.publish()
Expand All @@ -108,7 +118,8 @@ final class PlaybackSystemCoordinator: NowPlayingCommandHandler {
// MARK: - Following the player

/// Publishes the player as it is now: the current song, its position and whether it plays.
private func publish() {
/// `force` writes the item again, artwork included, instead of `updatePlayback`'s throttled position update.
private func publish(force: Bool = false) {
guard let current = playback.queueOperations.queueStateFlow.value.currentItem else {
guard nowPlaying.item != nil else { return }
// The queue emptied: nothing is playing, so give the session back to other apps.
Expand All @@ -125,7 +136,7 @@ final class PlaybackSystemCoordinator: NowPlayingCommandHandler {
current.song.type == .audio
? .tracks : .interval(forward: Self.skipForwardSeconds, backward: Self.skipBackwardSeconds)
)
if item == nowPlaying.item {
if !force, item == nowPlaying.item {
nowPlaying.updatePlayback(position: position, isPlaying: isPlaying, speed: speed)
} else {
nowPlaying.setItem(item, position: position, isPlaying: isPlaying, speed: speed)
Expand All @@ -148,14 +159,19 @@ final class PlaybackSystemCoordinator: NowPlayingCommandHandler {
}

/// Every audio object died with the media server: a new engine, and the current item loaded into it
/// where it was, playing on if it was.
/// where it was. Playing — or still loading with the intent to play — resumes; paused stays paused.
/// Now Playing is written in full even when the item and state didn't change: the system copy was wiped
/// with the server, and `updatePlayback` would throttle an unchanged paused or loading item.
private func rebuildEngine() {
guard let engine = makeEngine() else { return }
let wasPlaying = playerIsPlaying
let resume = playerIsPlaying
let position = playback.getProgress()
player.replaceEngine(engine)
playback.load(seekPosition: position, skipUnloadable: false) { _ in }
if wasPlaying { playback.play() }
playback.load(seekPosition: position, skipUnloadable: false) { [weak self] _ in
MainActor.assumeIsolated { self?.publish(force: true) }
}
if resume { playback.play() }
publish(force: true)
}

// MARK: - NowPlayingCommandHandler
Expand Down
103 changes: 102 additions & 1 deletion ios/S2Tests/EngineAudioPlayerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,11 @@ struct EngineAudioPlayerTests {
player.load(current: track("a"), next: nil, startMs: 0, playWhenReady: true)
player.play()

// The load still happens, paused; the play never reaches the engine.
// The load still happens, paused; the play never reaches the engine. Each refusal is a paused
// report for that track, delivered on the call so it stays ahead of the engine's own events.
#expect(engine.loads.map(\.playWhenReady) == [false])
#expect(engine.commands.isEmpty)
#expect(listener.calls == ["state a paused", "state a paused"])
}

@Test func positionAndDurationOnlyCountForTheTrackKotlinThinksIsCurrent() {
Expand Down Expand Up @@ -261,4 +263,103 @@ struct EngineAudioPlayerTests {
#expect(engine.loads.last?.current.url.absoluteString == "demo://2")
#expect(graph.playerController.queueOperations.queueStateFlow.value.currentItem?.song.name == "Hyperballad")
}

private func intendsToPlay(_ controller: IosPlayerController) -> Bool {
controller.playWhenReadyFlow.value.boolValue
}

@Test func aSessionRefusalClearsIntentWithoutFinishingTheLoadEarly() async throws {
player.onWillPlay = { false }
let graph = makeTestGraph(audioPlayer: player)
let controller = graph.playerController
_ = try await controller.queueOperations.setQueue(songs: TestSongs.demo, shuffleSongs: nil, position: 0, context: PlayContextNone.shared)
var completed = false
controller.skipToNext(ignoreRepeat: true) { _ in completed = true }

#expect(await waitUntil { engine.loads.count == 1 })
#expect(engine.loads.last?.playWhenReady == false)
#expect(!engine.commands.contains("play"))
#expect(!intendsToPlay(controller))
#expect(!completed)
#expect(controller.playbackStateFlow.value is PlaybackState.Loading)

let id = try #require(engine.loads.last?.current.id)
engine.emit(.state(.loading, trackId: id))
#expect(controller.playbackStateFlow.value is PlaybackState.Loading)
engine.emit(.state(.paused, trackId: id))
#expect(await waitUntil { controller.playbackStateFlow.value is PlaybackState.Paused })
#expect(completed)
#expect(!intendsToPlay(controller))
#expect(controller.queueOperations.queueStateFlow.value.currentItem?.song.name == "Hyperballad")

// The cancelled intent is what a later queue change preserves: the new song loads paused.
let next = try #require(controller.queueOperations.getNext(ignoreRepeat: true))
controller.queueOperations.setCurrentItem(currentItem: next)
#expect(await waitUntil { engine.loads.count == 2 })
#expect(engine.loads.last?.playWhenReady == false)
#expect(!intendsToPlay(controller))

player.onWillPlay = { true }
controller.play()
#expect(await waitUntil { engine.commands.contains("play") })
#expect(intendsToPlay(controller))
}

@Test func aRefusedPlayOfAReadySongClearsIntentWithNoEngineTransition() async throws {
let graph = makeTestGraph(audioPlayer: player)
let controller = graph.playerController
try await queueDemoSongs(on: graph, skipUnloadable: false)
#expect(await waitUntil { !engine.loads.isEmpty })
let id = try #require(engine.loads.first?.current.id)
engine.emit(.state(.paused, trackId: id))
#expect(await waitUntil { controller.playbackStateFlow.value is PlaybackState.Paused })

player.onWillPlay = { false }
controller.play()

#expect(!engine.commands.contains("play"))
#expect(!intendsToPlay(controller))
#expect(controller.playbackStateFlow.value is PlaybackState.Paused)
}

@Test func aPausedReportForTheSongASkipReplacedIsIgnored() async throws {
let graph = makeTestGraph(audioPlayer: player)
let controller = graph.playerController
try await queueDemoSongs(on: graph, skipUnloadable: false)
#expect(await waitUntil { !engine.loads.isEmpty })
let first = try #require(engine.loads.first?.current.id)
controller.play()
engine.emit(.state(.playing, trackId: first))
#expect(await waitUntil { controller.playbackStateFlow.value is PlaybackState.Playing })
#expect(intendsToPlay(controller))
#expect(await waitUntil { engine.nexts.last??.id != nil })

controller.skipToNext(ignoreRepeat: true, completion: nil)
#expect(await waitUntil { engine.loads.count == 2 })
engine.emit(.state(.paused, trackId: first))

#expect(intendsToPlay(controller))
#expect(controller.queueOperations.queueStateFlow.value.currentItem?.song.name == "Hyperballad")
#expect(controller.playbackStateFlow.value is PlaybackState.Loading)
}

@Test func aLoadCompletionThatPlaysKeepsTheNewIntent() async throws {
player.onWillPlay = { false }
let graph = makeTestGraph(audioPlayer: player)
let controller = graph.playerController
_ = try await controller.queueOperations.setQueue(songs: TestSongs.demo, shuffleSongs: nil, position: 0, context: PlayContextNone.shared)
controller.skipToNext(ignoreRepeat: true) { _ in
player.onWillPlay = { true }
controller.play()
}

#expect(await waitUntil { engine.loads.count == 1 })
#expect(!intendsToPlay(controller))
let id = try #require(engine.loads.last?.current.id)
engine.emit(.state(.loading, trackId: id))
engine.emit(.state(.paused, trackId: id))

#expect(await waitUntil { intendsToPlay(controller) && engine.commands.contains("play") })
#expect(controller.queueOperations.queueStateFlow.value.currentItem?.song.name == "Hyperballad")
}
}
Loading
Loading