From 21f60876f2c65d3bae9eac0e578b837042c05a68 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Oct 2026 14:42:37 +0000 Subject: [PATCH] Rate songs in half stars Each rating star rated the song a whole number of stars, so the half stars Clementine has couldn't be given. A tap on a star's leading half now rates it half a star less, as on Android. VoiceOver still reads one button per star, which rates the whole star, and offers the half star as an action on it. The two new strings are added to the catalog as a build's sync would add them. Release-note: You can rate songs in half stars: tap the left half of a star. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01XFkkp5H5UZTWDyEMm6jnZH --- App/Player/SongDetailsView.swift | 38 ++++++++++++------- App/Resources/Localizable.xcstrings | 6 +++ .../ClementineKit/Session/RemoteSession.swift | 6 +-- 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/App/Player/SongDetailsView.swift b/App/Player/SongDetailsView.swift index b5bd731..bf70d4f 100644 --- a/App/Player/SongDetailsView.swift +++ b/App/Player/SongDetailsView.swift @@ -89,7 +89,11 @@ struct SongDetailsView: View { .frame(width: 96, alignment: .leading) Rating(stars: song.rating * 5) { stars in session.rate(stars: stars) - model.toasts.show("Rated \(stars) stars") + if stars == stars.rounded() { + model.toasts.show("Rated \(Int(stars)) stars") + } else { + model.toasts.show("Rated \(stars.formatted()) stars") + } } } @@ -141,25 +145,31 @@ private struct DetailRow: View { } } -/// Clementine's rating in half stars. Tapping a star rates the song that many stars. +/// Clementine's rating in half stars. Tapping a star rates the song that many stars, or half a +/// star less on its leading half. VoiceOver offers the half star as an action on each star. private struct Rating: View { let stars: Float - let rate: (Int) -> Void + let rate: (Float) -> Void var body: some View { HStack(spacing: 0) { ForEach(1...5, id: \.self) { star in - Button { - rate(star) - } label: { - Image(systemName: symbol(star)) - .font(.title3) - .foregroundStyle(Palette.primary) - .frame(width: 44, height: 44) - .contentShape(.rect) - } - .buttonStyle(.plain) - .accessibilityLabel("Rate \(star) stars") + let whole = Float(star) + let half = whole - 0.5 + Image(systemName: symbol(star)) + .font(.title3) + .foregroundStyle(Palette.primary) + .frame(width: 44, height: 44) + .contentShape(.rect) + // Local coordinates start from the leading edge, in right-to-left languages too. + .onTapGesture(coordinateSpace: .local) { location in + rate(location.x < 22 ? half : whole) + } + .accessibilityElement() + .accessibilityAddTraits(.isButton) + .accessibilityLabel("Rate \(star) stars") + .accessibilityAction { rate(whole) } + .accessibilityAction(named: "Rate \(half.formatted()) stars") { rate(half) } } } .accessibilityElement(children: .contain) diff --git a/App/Resources/Localizable.xcstrings b/App/Resources/Localizable.xcstrings index 2dae1d9..3c617e7 100644 --- a/App/Resources/Localizable.xcstrings +++ b/App/Resources/Localizable.xcstrings @@ -16894,6 +16894,9 @@ } } } + }, + "Rate %@ stars" : { + }, "Rate %lld stars" : { "localizations" : { @@ -17072,6 +17075,9 @@ } } } + }, + "Rated %@ stars" : { + }, "Rated %lld stars" : { "localizations" : { diff --git a/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift b/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift index c452748..99c9de1 100644 --- a/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift +++ b/Packages/ClementineKit/Sources/ClementineKit/Session/RemoteSession.swift @@ -395,10 +395,10 @@ public final class RemoteSession { return repeatMode } - /// Rates the current song from 0 to 5 stars, showing it at once. - public func rate(stars: Int) { + /// Rates the current song from 0 to 5 stars, in half stars, showing it at once. + public func rate(stars: Float) { guard song != nil else { return } - let rating = Float(max(0, min(5, stars))) / 5 + let rating = (max(0, min(5, stars)) * 2).rounded() / 10 song?.rating = rating send(Messages.rate(rating)) }