From 0f593f561940763dbb50e84b42f01e490e1e18e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 18:08:15 +0000 Subject: [PATCH] Seek from the lock screen, and show the cover of the song playing here While Clementine plays on the phone, Now Playing had no position bar to drag, though Android's notification seeks. And it took the cover from Clementine's current song whatever was playing here, so after a change of song it could show the previous song's cover, until the next status update or, when paused, until something else changed. The cover is now only shown once Clementine's song is the one playing here, refreshed when the song arrives, and decoded once rather than on every update. Release-note: While Clementine plays on your iPhone, you can seek from the lock screen, and it no longer shows the previous song's cover after a change of song. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01DihgxevQ7a59sF9RFc4Wby --- App/AppModel.swift | 4 +++ App/Streaming/NowPlaying.swift | 48 +++++++++++++++++++++++++++++----- design/DESIGN.md | 5 ++-- 3 files changed, 49 insertions(+), 8 deletions(-) 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`.