From ded9b71c3dfaa56399a4ae5a67504d1d3d9b6c97 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 09:50:30 +0000 Subject: [PATCH] Show a music note for a song without a cover Clementine sends its "no cover" picture, a jewel case that isn't square and was cropped, as the art of a song without a cover. The app now recognises it by the song's empty art_automatic and art_manual (or an art_manual of "(unset)") and drops it, as the Android remote does, so the player, the mini player, the song details and the lock screen show no cover for it. The artwork then shows a music note, as Android's does; the Clementine mark is left for when nothing's playing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015dJQ8hcp9Vd6tD9gJAXxTx --- App/MainView.swift | 2 +- App/Player/PlayerView.swift | 2 +- App/Player/SongDetailsView.swift | 2 +- App/Theme/Components.swift | 24 ++++++++++----- .../Sources/ClementineKit/Model/Model.swift | 19 ++++++++++-- .../ClementineKitTests/ProtocolTests.swift | 30 +++++++++++++++++++ design/DESIGN.md | 13 +++++--- 7 files changed, 76 insertions(+), 16 deletions(-) diff --git a/App/MainView.swift b/App/MainView.swift index 0769bda..4c90e51 100644 --- a/App/MainView.swift +++ b/App/MainView.swift @@ -74,7 +74,7 @@ struct MiniPlayer: View { HStack(spacing: Metrics.space3) { Button(action: open) { HStack(spacing: Metrics.space3) { - Artwork(artData: song?.artData, cornerRadius: Metrics.shapeSmall, markInset: 0.15) + Artwork(song: song, cornerRadius: Metrics.shapeSmall, markInset: 0.15) .frame(width: 32, height: 32) VStack(alignment: .leading, spacing: 0) { Text(song?.title ?? String(localized: "No song playing")) diff --git a/App/Player/PlayerView.swift b/App/Player/PlayerView.swift index 0708cf8..668bc13 100644 --- a/App/Player/PlayerView.swift +++ b/App/Player/PlayerView.swift @@ -103,7 +103,7 @@ struct PlayerView: View { Button { showLyrics() } label: { - Artwork(artData: session.song?.artData) + Artwork(song: session.song) } .buttonStyle(.plain) .accessibilityLabel("Cover art") diff --git a/App/Player/SongDetailsView.swift b/App/Player/SongDetailsView.swift index b7d6298..b5bd731 100644 --- a/App/Player/SongDetailsView.swift +++ b/App/Player/SongDetailsView.swift @@ -48,7 +48,7 @@ struct SongDetailsView: View { isCoverZoomed = true } } label: { - Artwork(artData: session.song?.artData, cornerRadius: Metrics.shapeLarge) + Artwork(song: session.song, cornerRadius: Metrics.shapeLarge) .frame(width: 72, height: 72) } .buttonStyle(.plain) diff --git a/App/Theme/Components.swift b/App/Theme/Components.swift index bad87c1..537ffa6 100644 --- a/App/Theme/Components.swift +++ b/App/Theme/Components.swift @@ -16,24 +16,32 @@ struct IconTile: View { } } -/// A song's cover art, square with rounded corners; the Clementine mark stands in when there's -/// none. Covers crossfade when the song changes. +/// A song's cover art, square with rounded corners. A song without a cover shows a music note, as +/// the Android remote does; the Clementine mark stands in when nothing's playing. Covers crossfade +/// when the song changes. struct Artwork: View { - let artData: Data? + let song: Song? var cornerRadius: CGFloat = Metrics.radiusXL - /// How far the mark is inset, as a fraction of the size, when there's no cover. + /// How far the mark is inset, as a fraction of the size, when nothing's playing. var markInset: CGFloat = 0.1 var body: some View { GeometryReader { geometry in ZStack { Palette.surfaceContainerHighest - if let image = ArtCache.shared.image(for: artData) { + if let image = ArtCache.shared.image(for: song?.artData) { Image(uiImage: image) .resizable() .scaledToFill() .transition(.opacity) - .id(artData) + .id(song?.artData) + } else if song != nil { + Image(systemName: "music.note") + .resizable() + .scaledToFit() + .foregroundStyle(Palette.onSurfaceVariant) + .frame(width: geometry.size.width * 0.4, height: geometry.size.height * 0.4) + .transition(.opacity) } else { Image("ClementineMark") .resizable() @@ -42,7 +50,9 @@ struct Artwork: View { .transition(.opacity) } } - .animation(.easeInOut(duration: 0.75), value: artData) + .frame(width: geometry.size.width, height: geometry.size.height) + .animation(.easeInOut(duration: 0.75), value: song?.artData) + .animation(.easeInOut(duration: 0.75), value: song == nil) } .aspectRatio(1, contentMode: .fit) .clipShape(.rect(cornerRadius: cornerRadius)) diff --git a/Packages/ClementineKit/Sources/ClementineKit/Model/Model.swift b/Packages/ClementineKit/Sources/ClementineKit/Model/Model.swift index 5e8fbe3..b85eb93 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Model/Model.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Model/Model.swift @@ -100,7 +100,8 @@ public struct Song: Sendable, Hashable { /// From 0 to 1. public var rating: Float public var url: String - /// The cover as Clementine sent it (compressed), if any. + /// The cover as Clementine sent it (compressed), if the song has one. Not Clementine's "no + /// cover" picture, which it sends for a song without one. public var artData: Data? /// The whole description, for adding the song to a playlist. public var metadata: SongMetadata @@ -124,12 +125,26 @@ public struct Song: Sendable, Hashable { isLocal = metadata.isLocal rating = metadata.rating url = metadata.url - artData = metadata.hasArt && !metadata.art.isEmpty ? metadata.art : nil + artData = metadata.hasArt && !metadata.art.isEmpty && Self.hasCover(metadata) ? metadata.art : nil var withoutArt = metadata withoutArt.clearArt() self.metadata = withoutArt } + /// Clementine's art_manual for a cover the user removed. + static let manuallyUnsetCover = "(unset)" + + /// Whether the song has a cover of its own, as its art fields say: Clementine sends its "no + /// cover" picture as the art of a song with neither an automatic nor a manual cover. Clementine + /// that doesn't send the fields is taken at its word. + static func hasCover(_ metadata: SongMetadata) -> Bool { + guard metadata.hasArtAutomatic || metadata.hasArtManual else { return true } + if metadata.artManual == manuallyUnsetCover { + return false + } + return !metadata.artManual.isEmpty || !metadata.artAutomatic.isEmpty + } + /// Matches a filter in the title, artist or album, ignoring case. public func matches(_ filter: String) -> Bool { title.localizedCaseInsensitiveContains(filter) diff --git a/Packages/ClementineKit/Tests/ClementineKitTests/ProtocolTests.swift b/Packages/ClementineKit/Tests/ClementineKitTests/ProtocolTests.swift index 18ee485..4bda55d 100644 --- a/Packages/ClementineKit/Tests/ClementineKitTests/ProtocolTests.swift +++ b/Packages/ClementineKit/Tests/ClementineKitTests/ProtocolTests.swift @@ -88,3 +88,33 @@ struct MessagesTests { #expect(Lyrics.best(of: []) == nil) } } + +struct SongArtTests { + static func metadata(automatic: String? = nil, manual: String? = nil) -> SongMetadata { + var metadata = SongMetadata() + metadata.title = "Clair de lune" + metadata.art = Data([0x89, 0x50, 0x4E, 0x47]) + if let automatic { + metadata.artAutomatic = automatic + } + if let manual { + metadata.artManual = manual + } + return metadata + } + + @Test func keepsACover() { + #expect(Song(Self.metadata(automatic: "/music/cover.jpg", manual: "")).artData != nil) + #expect(Song(Self.metadata(automatic: "", manual: "/covers/mine.png")).artData != nil) + } + + @Test func dropsTheNoCoverPicture() { + // Clementine's "no cover" picture comes with neither an automatic nor a manual cover. + #expect(Song(Self.metadata(automatic: "", manual: "")).artData == nil) + #expect(Song(Self.metadata(automatic: "/music/cover.jpg", manual: "(unset)")).artData == nil) + } + + @Test func trustsClementineThatDoesNotSay() { + #expect(Song(Self.metadata()).artData != nil) + } +} diff --git a/design/DESIGN.md b/design/DESIGN.md index d2a9fe9..eb05c99 100644 --- a/design/DESIGN.md +++ b/design/DESIGN.md @@ -113,8 +113,9 @@ From the Now playing board. Opened from the mini player; swipe down or the chevr - Top row: chevron-down (close), "Playing from" / the playlist's name, centred, and a menu (Stop, Download…). - Artwork: square, full width with 24 pt gutters, 28 pt corners, on `surface-container-highest`. - With no cover, the Clementine mark inset 32 pt. Tapping it shows the lyrics. Covers crossfade over - 0.75 s. + A song without a cover shows a `music.note` in `on-surface-variant`, 40% of the size, as the + Android remote does; with nothing playing, the Clementine mark inset 32 pt. Tapping it shows the + lyrics. Covers crossfade over 0.75 s. - Song info, start-aligned: title (28 pt), artist in `primary` (16 pt medium), album in `on-surface-variant`, then "genre · year". One line each. A Love button (heart) beside it when Last.fm buttons are on. @@ -248,7 +249,8 @@ From the Connection sheet board. Opened from the ConnectionChip. ### Mini player `.tabViewBottomAccessory`, shown while connected. Title and artist, a small cover (8 pt corners), the -output button when Clementine can play elsewhere, a small play/pause and next. A thin `primary` progress line when the accessory is expanded. Tapping it +output button when Clementine can play elsewhere, a small play/pause and next. The small cover +shows a music note for a song without one, as the player does. A thin `primary` progress line when the accessory is expanded. Tapping it opens the player. With nothing playing it reads "No song playing". ### Settings @@ -347,7 +349,10 @@ SF Symbols, filled, in the colour of their control: | Stars | `star.fill` / `star.leadinghalf.filled` / `star` | The Clementine mark (from the desktop repo's `data/icon.svg`) is the app icon, the Connect hero and -the missing-cover image. +the cover when nothing's playing. A song without a cover has a music note instead: Clementine sends +its "no cover" picture (a jewel case, not square) for such a song, which the app recognises by its +empty `art_automatic` and `art_manual` (or an `art_manual` of `(unset)`) and doesn't show, as the +Android remote does. ### Copy