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
2 changes: 1 addition & 1 deletion App/MainView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
2 changes: 1 addition & 1 deletion App/Player/PlayerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ struct PlayerView: View {
Button {
showLyrics()
} label: {
Artwork(artData: session.song?.artData)
Artwork(song: session.song)
}
.buttonStyle(.plain)
.accessibilityLabel("Cover art")
Expand Down
2 changes: 1 addition & 1 deletion App/Player/SongDetailsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 17 additions & 7 deletions App/Theme/Components.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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))
Expand Down
19 changes: 17 additions & 2 deletions Packages/ClementineKit/Sources/ClementineKit/Model/Model.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
13 changes: 9 additions & 4 deletions design/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading