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