diff --git a/App/AppModel.swift b/App/AppModel.swift index 6d0794c..a64b2f7 100644 --- a/App/AppModel.swift +++ b/App/AppModel.swift @@ -59,6 +59,10 @@ final class AppModel { session.addObserver { [renderer, internet] message in renderer.handle(message) internet.handle(message) + // The cover comes with the song, which can arrive after the item it's for. + if message.type == .currentMetainfo { + nowPlaying.update() + } } internet.onAdded = { [weak self] action, result in self?.showAdded(action, result: result) diff --git a/App/Streaming/NowPlaying.swift b/App/Streaming/NowPlaying.swift index 0c98847..1ed2608 100644 --- a/App/Streaming/NowPlaying.swift +++ b/App/Streaming/NowPlaying.swift @@ -9,6 +9,9 @@ final class NowPlaying { private unowned let model: AppModel /// Commands start enabled once they have targets. private var commandsEnabled = true + private var seekEnabled = true + /// The cover last shown, kept so it isn't decoded again on every update. + private var cover: (data: Data, artwork: MPMediaItemArtwork)? init(model: AppModel) { self.model = model @@ -34,6 +37,12 @@ final class NowPlaying { MainActor.assumeIsolated { session.previous() } return .success } + center.changePlaybackPositionCommand.addTarget { event in + guard let event = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed } + let seconds = Int(event.positionTime) + MainActor.assumeIsolated { session.seek(to: seconds) } + return .success + } setCommandsEnabled(false) } @@ -42,6 +51,7 @@ final class NowPlaying { let renderer = model.renderer guard let item = renderer.item else { MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + cover = nil setCommandsEnabled(false) return } @@ -58,15 +68,31 @@ final class NowPlaying { } else { info[MPNowPlayingInfoPropertyIsLiveStream] = true } - // Clementine sends the cover with the song playing, rather than with the item. - if let data = model.session.song?.artData, let size = UIImage(data: data)?.size { - // iOS asks for the image off the main thread: the closure holds only the data. - info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: size) { @Sendable _ in - UIImage(data: data) ?? UIImage() - } + if let artwork = artwork(for: item) { + info[MPMediaItemPropertyArtwork] = artwork } MPNowPlayingInfoCenter.default().nowPlayingInfo = info setCommandsEnabled(true) + setSeekEnabled(item.lengthMs > 0) + } + + /// The cover of [item]'s song. Clementine sends the cover with the song playing, rather than + /// with the item, and the two can arrive in either order: the cover is only the item's once + /// they're the same song. + private func artwork(for item: RenderItem) -> MPMediaItemArtwork? { + guard let song = model.session.song, song.url == item.song.url, let data = song.artData else { + return nil + } + if let cover, cover.data == data { + return cover.artwork + } + guard let size = UIImage(data: data)?.size else { return nil } + // iOS asks for the image off the main thread: the closure holds only the data. + let made = MPMediaItemArtwork(boundsSize: size) { @Sendable _ in + UIImage(data: data) ?? UIImage() + } + cover = (data, made) + return made } private func setCommandsEnabled(_ enabled: Bool) { @@ -77,5 +103,15 @@ final class NowPlaying { center.nextTrackCommand, center.previousTrackCommand] { command.isEnabled = enabled } + if !enabled { + setSeekEnabled(false) + } + } + + /// Seeking needs the song's length: without it the lock screen shows no position to drag. + private func setSeekEnabled(_ enabled: Bool) { + guard enabled != seekEnabled else { return } + seekEnabled = enabled + MPRemoteCommandCenter.shared().changePlaybackPositionCommand.isEnabled = enabled } } diff --git a/design/DESIGN.md b/design/DESIGN.md index 7b727c4..5ffb0ae 100644 --- a/design/DESIGN.md +++ b/design/DESIGN.md @@ -438,8 +438,9 @@ remote. - **In the background:** the app has the `audio` background mode. While Clementine plays here the connection stays open in the background; once it stops, the app lets it go as usual. iOS pauses playback for calls, and Clementine shows it paused. -- **Lock screen:** while playing here, Now Playing shows the song and the cover, and its buttons - control Clementine. +- **Lock screen:** while playing here, Now Playing shows the song and its cover, and its buttons + and position bar (for songs with a length) control Clementine. The cover comes with Clementine's + current song, so it's only shown once that's the song playing here. - **Security:** the tracks come over plain HTTP from Clementine's computer, allowed by `NSAllowsLocalNetworking` and `NSAllowsArbitraryLoadsForMedia`.