Skip to content
Merged
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
4 changes: 4 additions & 0 deletions App/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
48 changes: 42 additions & 6 deletions App/Streaming/NowPlaying.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}

Expand All @@ -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
}
Expand All @@ -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) {
Expand All @@ -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
}
}
5 changes: 3 additions & 2 deletions design/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
Loading