v2.2.1: real lyrics via LRCLIB + stream envelope fix + TimelineView vinyl

Critical fix · adapter stream was silently empty

v2.2.0 parsed the stream subprocess's JSON at the wrong layer. The
`stream` mode wraps every emit as:

    {"type":"data","diff":<bool>,"payload":{title,...}}

but Swift was decoding as AdapterStreamPayload directly (the shape
used by `get`, which is flat). Result: every `stream rx` had
title="" because the real data was nested inside payload, so
hasTrack was always false and onUpdate never fired. Users saw
"nothing playing" even while Apple Music was running.

New AdapterStreamEnvelope decodes the wrapper, extracts payload,
and also honours `diff: false` to reset currentInfo before merging
(stale fields from the previous track were otherwise leaking).

Added bootstrap path · cold start with music already playing

When the adapter subprocess is spawned AFTER Apple Music is already
playing, the stream's initial emit can be an empty baseline. A
parallel one-shot `perl adapter.pl get` at spawn+300ms catches the
current track immediately.

Added file-based debug log at /tmp/mio-plugin-music-debug.log

NSLog / os_log are unreliably filtered on macOS 15, and we can't
attach Xcode to a plugin loaded from a signed host. A line-oriented
log at a fixed path is the one channel that's always readable post-
mortem. Lines include stream rx / bootstrap / parse failures.

Real lyrics · LRCLIB integration

- New LyricsService fetches synced lyrics via
  https://lrclib.net/api/get (exact) + /search (fallback), parses
  LRC format with regex [mm:ss.xx]. In-memory LRU cache (32 entries,
  1-hour TTL). Negative-caches "not found" so obscure tracks don't
  re-hit the API every render.
- NowPlayingState gains syncedLyrics + currentLyricIndex @Published.
  applyAdapterUpdate detects track changes and refreshes lyrics
  detached; the 1s playback timer updates currentLyricIndex.
- DesktopLyricsViews replaces the placeholder text with real lyric
  text from syncedLyrics[currentLyricIndex ± 1]. Falls back to
  sensible dots when no lyrics loaded / instrumentals.

Bonus · robust vinyl spin via TimelineView

withAnimation(.linear.repeatForever) loses the animation when
SwiftUI re-creates the view (window hide/show, style switch).
Replaced with TimelineView driven by wall-clock — angle =
(elapsed * 45°/s) % 360. Smooth across hours, no drift, pauses
correctly via `paused: !isPlaying`.

Borrowed approach from Atoll (github.com/Ebullioscopic/Atoll)
MusicManager.swift:756-935: same LRCLIB endpoints, same LRC regex
shape, same per-second sync model. Credited in LICENSE-THIRD-PARTY.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
徐翔宇 2026-04-21 07:55:04 +08:00
parent d5934b06b0
commit 69776ecec2
8 changed files with 1141 additions and 74 deletions

View File

@ -15,9 +15,9 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>BNDL</string> <string>BNDL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>2.1.0</string> <string>2.2.1</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>8</string> <string>10</string>
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string>MusicPlugin.MusicPlugin</string> <string>MusicPlugin.MusicPlugin</string>
<!-- <!--

View File

@ -75,6 +75,15 @@ final class NowPlayingState: ObservableObject {
/// 使" hint. /// 使" hint.
@Published var chineseAppDetected: String? @Published var chineseAppDetected: String?
/// Parsed synced lyrics from LRCLIB after every track change. Empty
/// array = we tried, nothing found (instrumentals / obscure tracks).
@Published var syncedLyrics: [LyricLine] = []
/// Index into `syncedLyrics` for the current playhead. -1 means no
/// lyrics loaded OR elapsedTime < first line's timestamp. Updated
/// by the playback timer every second.
@Published var currentLyricIndex: Int = -1
// MARK: - Derived // MARK: - Derived
var progress: Double { var progress: Double {
@ -546,6 +555,9 @@ final class NowPlayingState: ObservableObject {
/// This bypasses the full router adapter updates are the truest signal /// This bypasses the full router adapter updates are the truest signal
/// we have on 15.4+, so we claim sticky-source and publish straight away. /// we have on 15.4+, so we claim sticky-source and publish straight away.
private func applyAdapterUpdate(_ info: MediaRemoteInfo) { private func applyAdapterUpdate(_ info: MediaRemoteInfo) {
// Detect track change BEFORE we overwrite the fields.
let trackChanged = (self.title != info.title) || (self.artist != info.artist)
self.title = info.title self.title = info.title
self.artist = info.artist self.artist = info.artist
self.album = info.album self.album = info.album
@ -563,6 +575,63 @@ final class NowPlayingState: ObservableObject {
self.stickySource = .mediaRemoteAdapter self.stickySource = .mediaRemoteAdapter
self.updatePlaybackTimer() self.updatePlaybackTimer()
self.rearmPoll() self.rearmPoll()
if trackChanged {
// Drop stale lyrics and fetch fresh ones from LRCLIB.
self.syncedLyrics = []
self.currentLyricIndex = -1
refreshLyrics()
} else {
// Same track, but possibly a seek or pause/resume recompute
// the current-lyric index immediately instead of waiting for
// the next playback-timer tick.
updateCurrentLyricIndex()
}
}
/// Pull synced lyrics from LRCLIB for the current track and publish.
/// Runs off the main actor (network I/O) but the @Published update
/// hops back to main. No-op when title/artist are missing spares
/// LRCLIB a pointless round-trip.
private func refreshLyrics() {
let t = title, a = artist, al = album, d = duration
guard !t.isEmpty, !a.isEmpty else { return }
Task.detached(priority: .utility) { [weak self] in
let lines = await LyricsService.fetch(
artist: a, title: t, album: al, duration: d
)
await MainActor.run {
guard let self else { return }
// Only adopt if the user hasn't moved on to another track
// during the network call (LRCLIB can take 1-2s on misses).
guard self.title == t, self.artist == a else { return }
self.syncedLyrics = lines
self.currentLyricIndex = -1
self.updateCurrentLyricIndex()
}
}
}
/// Find the lyric line whose timestamp current elapsedTime. Binary
/// search since lines are sorted. Only publishes if the index actually
/// changed prevents unnecessary SwiftUI redraws every tick.
func updateCurrentLyricIndex() {
guard !syncedLyrics.isEmpty else {
if currentLyricIndex != -1 { currentLyricIndex = -1 }
return
}
var newIndex = -1
// Linear scan is fine typical lyric line counts are 3080.
for (i, line) in syncedLyrics.enumerated() {
if elapsedTime >= line.timestamp {
newIndex = i
} else {
break
}
}
if newIndex != currentLyricIndex {
currentLyricIndex = newIndex
}
} }
private static func humanReadableSource(bundleId: String) -> String { private static func humanReadableSource(bundleId: String) -> String {
@ -643,6 +712,7 @@ final class NowPlayingState: ObservableObject {
Task { @MainActor in Task { @MainActor in
guard let self, self.isPlaying else { return } guard let self, self.isPlaying else { return }
self.elapsedTime = min(self.elapsedTime + 1.0, self.duration) self.elapsedTime = min(self.elapsedTime + 1.0, self.duration)
self.updateCurrentLyricIndex()
if self.elapsedTime >= self.duration { if self.elapsedTime >= self.duration {
self.playbackTimer?.invalidate() self.playbackTimer?.invalidate()
self.playbackTimer = nil self.playbackTimer = nil

View File

@ -0,0 +1,242 @@
//
// LyricsService.swift
// MioIsland Music Plugin
//
// Fetches synced lyrics from LRCLIB (https://lrclib.net/docs) and parses
// their LRC format into per-line timestamps. Free public API, no auth.
//
// Approach borrowed from Atoll (github.com/Ebullioscopic/Atoll,
// MusicManager.swift:756895). Two lookup endpoints:
//
// /api/get?track_name=&artist_name=&album_name=&duration=
// exact match with all four params; best hit rate when present.
//
// /api/search?track_name=&artist_name=
// fallback text search, returns an array; we take the first.
//
// LRC lines look like "[mm:ss.xx] Lyric line". We regex-extract the
// timestamp + trailing text. Centiseconds optional.
//
// Caching: in-memory LRU keyed by (artist + title + duration-bucket).
// Bucket duration to nearest second so slight float drift between
// MediaRemote and LRCLIB doesn't create separate cache keys. Cache
// size capped at 32 entries plenty for a single listening session.
//
import Foundation
// MARK: - Public types
struct LyricLine: Equatable, Identifiable {
let id = UUID()
let timestamp: TimeInterval
let text: String
}
enum LyricsService {
// MARK: - Cache
/// Cache entry an empty array means "we tried, nothing found".
/// This negative-cache prevents hammering LRCLIB on songs with no
/// lyrics (e.g. instrumentals).
private struct CacheEntry {
let lines: [LyricLine]
let cachedAt: Date
}
private static let cacheQueue = DispatchQueue(
label: "mio-plugin-music.lyrics-cache",
attributes: .concurrent
)
private static var _cache: [String: CacheEntry] = [:]
private static let maxCacheSize = 32
private static let cacheTTL: TimeInterval = 60 * 60 // 1 hour
private static func cacheKey(artist: String, title: String, duration: TimeInterval) -> String {
let bucket = Int(duration.rounded())
return "\(artist.lowercased())|\(title.lowercased())|\(bucket)"
}
private static func lookupCache(key: String) -> [LyricLine]? {
var result: [LyricLine]?
cacheQueue.sync {
if let entry = _cache[key],
Date().timeIntervalSince(entry.cachedAt) < cacheTTL {
result = entry.lines
}
}
return result
}
private static func storeCache(key: String, lines: [LyricLine]) {
cacheQueue.async(flags: .barrier) {
if _cache.count >= maxCacheSize {
// Naive eviction: drop the oldest entry. Perfect LRU
// isn't worth extra bookkeeping for N=32.
if let oldestKey = _cache.min(by: { $0.value.cachedAt < $1.value.cachedAt })?.key {
_cache.removeValue(forKey: oldestKey)
}
}
_cache[key] = CacheEntry(lines: lines, cachedAt: Date())
}
}
// MARK: - Fetch
/// Fetch synced lyrics for the given track. Returns an empty array on
/// "tried and no lyrics found" the caller should treat nil (error)
/// and [] (no lyrics) as distinct states for UX. Safe to call off the
/// main actor; result is not main-isolated.
static func fetch(
artist: String,
title: String,
album: String = "",
duration: TimeInterval = 0
) async -> [LyricLine] {
let trimmedArtist = artist.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedArtist.isEmpty, !trimmedTitle.isEmpty else { return [] }
let key = cacheKey(artist: trimmedArtist, title: trimmedTitle, duration: duration)
if let cached = lookupCache(key: key) { return cached }
// 1. Exact match (best hit rate when album + duration are known).
if duration > 0 {
if let lines = try? await fetchExact(
artist: trimmedArtist,
title: trimmedTitle,
album: album,
duration: duration
) {
storeCache(key: key, lines: lines)
return lines
}
}
// 2. Search fallback.
if let lines = try? await fetchSearch(artist: trimmedArtist, title: trimmedTitle) {
storeCache(key: key, lines: lines)
return lines
}
storeCache(key: key, lines: [])
return []
}
private static let baseURL = "https://lrclib.net/api"
private struct GetResponse: Decodable {
let syncedLyrics: String?
let plainLyrics: String?
}
private struct SearchResultItem: Decodable {
let syncedLyrics: String?
let plainLyrics: String?
}
private static func fetchExact(
artist: String,
title: String,
album: String,
duration: TimeInterval
) async throws -> [LyricLine] {
var comps = URLComponents(string: "\(baseURL)/get")!
comps.queryItems = [
URLQueryItem(name: "artist_name", value: artist),
URLQueryItem(name: "track_name", value: title),
URLQueryItem(name: "album_name", value: album),
URLQueryItem(name: "duration", value: String(Int(duration.rounded())))
]
guard let url = comps.url else { return [] }
var req = URLRequest(url: url, timeoutInterval: 8)
req.setValue("mio-plugin-music/2.2 (+https://github.com/MioMioOS/mio-plugin-music)", forHTTPHeaderField: "User-Agent")
let (data, resp) = try await URLSession.shared.data(for: req)
guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else {
return []
}
if let decoded = try? JSONDecoder().decode(GetResponse.self, from: data) {
if let synced = decoded.syncedLyrics?.trimmingCharacters(in: .whitespacesAndNewlines),
!synced.isEmpty {
return parseLRC(synced)
}
if let plain = decoded.plainLyrics?.trimmingCharacters(in: .whitespacesAndNewlines),
!plain.isEmpty {
return [LyricLine(timestamp: 0, text: plain)]
}
}
return []
}
private static func fetchSearch(
artist: String,
title: String
) async throws -> [LyricLine] {
var comps = URLComponents(string: "\(baseURL)/search")!
comps.queryItems = [
URLQueryItem(name: "track_name", value: title),
URLQueryItem(name: "artist_name", value: artist)
]
guard let url = comps.url else { return [] }
var req = URLRequest(url: url, timeoutInterval: 8)
req.setValue("mio-plugin-music/2.2 (+https://github.com/MioMioOS/mio-plugin-music)", forHTTPHeaderField: "User-Agent")
let (data, resp) = try await URLSession.shared.data(for: req)
guard let http = resp as? HTTPURLResponse, http.statusCode == 200 else {
return []
}
if let items = try? JSONDecoder().decode([SearchResultItem].self, from: data),
let first = items.first {
if let synced = first.syncedLyrics?.trimmingCharacters(in: .whitespacesAndNewlines),
!synced.isEmpty {
return parseLRC(synced)
}
if let plain = first.plainLyrics?.trimmingCharacters(in: .whitespacesAndNewlines),
!plain.isEmpty {
return [LyricLine(timestamp: 0, text: plain)]
}
}
return []
}
// MARK: - LRC parsing
/// LRC timestamp regex matches [mm:ss] and [mm:ss.xx] (centiseconds
/// optional). Captures three groups: minutes, seconds, centiseconds.
private static let lrcRegex: NSRegularExpression = {
// Force-try here: pattern is static and known-valid at compile time.
// swiftlint:disable:next force_try
try! NSRegularExpression(
pattern: "\\[(\\d{1,2}):(\\d{2})(?:\\.(\\d{1,2}))?\\]",
options: []
)
}()
static func parseLRC(_ lrc: String) -> [LyricLine] {
var out: [LyricLine] = []
for raw in lrc.components(separatedBy: .newlines) {
let ns = raw as NSString
let range = NSRange(location: 0, length: ns.length)
guard let match = lrcRegex.firstMatch(in: raw, options: [], range: range) else {
continue
}
let minutes = Double(ns.substring(with: match.range(at: 1))) ?? 0
let seconds = Double(ns.substring(with: match.range(at: 2))) ?? 0
let centi: Double = {
let r = match.range(at: 3)
return r.location != NSNotFound ? (Double(ns.substring(with: r)) ?? 0) : 0
}()
let ts = minutes * 60 + seconds + centi / 100.0
let textStart = match.range.location + match.range.length
guard textStart <= ns.length else { continue }
let text = ns.substring(from: textStart).trimmingCharacters(in: .whitespaces)
guard !text.isEmpty else { continue }
out.append(LyricLine(timestamp: ts, text: text))
}
return out.sorted { $0.timestamp < $1.timestamp }
}
}

View File

@ -31,9 +31,9 @@ import Foundation
// MARK: - Stream payload (subset of adapter output) // MARK: - Stream payload (subset of adapter output)
/// Raw JSON shape emitted by the adapter in stream mode. Only the keys we /// The track-level payload. Only the keys we consume are decoded;
/// actually consume are decoded; the adapter also emits `contentItemIdentifier`, /// adapter also emits `composer`, `contentItemIdentifier`,
/// `radioStationHash`, `timestamp`, etc. which we ignore. /// `radioStationHash`, `timestamp` etc. which we ignore.
private struct AdapterStreamPayload: Decodable { private struct AdapterStreamPayload: Decodable {
var title: String? var title: String?
var artist: String? var artist: String?
@ -43,11 +43,22 @@ private struct AdapterStreamPayload: Decodable {
var playbackRate: Double? var playbackRate: Double?
var playing: Bool? var playing: Bool?
var bundleIdentifier: String? var bundleIdentifier: String?
/// Base64-encoded artwork data. JSONDecoder automatically decodes /// Base64-encoded artwork data. JSONDecoder decodes Data from base64
/// when the Swift type is `Data` via default `.base64` strategy. /// automatically via its default strategy.
var artworkData: Data? var artworkData: Data?
} }
/// Envelope that wraps every line emitted by `stream` mode. Structure is:
/// `{"type":"data","diff":<bool>,"payload":{...}}`. `diff: false` means
/// this is a full state snapshot (initial baseline OR after track change);
/// `diff: true` means only the changed fields are in payload. `get` mode
/// emits the payload directly without this envelope.
private struct AdapterStreamEnvelope: Decodable {
var type: String?
var diff: Bool?
var payload: AdapterStreamPayload?
}
// MARK: - Source // MARK: - Source
final class MediaRemoteAdapterSource { final class MediaRemoteAdapterSource {
@ -194,13 +205,53 @@ final class MediaRemoteAdapterSource {
do { do {
try proc.run() try proc.run()
process = proc process = proc
NSLog("[mio-plugin-music] adapter spawned pid=\(proc.processIdentifier)") debugLog("adapter spawned pid=\(proc.processIdentifier)")
// Bootstrap pull current state via one-shot `get`. Covers the
// case where the stream subprocess started BEFORE any music app
// was opened; in that case the initial stream emit is null/empty,
// and no diff comes until something changes. A parallel `get`
// catches whatever is playing right now.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
self?.bootstrapGet()
}
} catch { } catch {
NSLog("[mio-plugin-music] adapter spawn failed: \(error)") debugLog("adapter spawn failed: \(error)")
scheduleRestart() scheduleRestart()
} }
} }
private func bootstrapGet() {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/usr/bin/perl")
proc.arguments = [scriptPath, frameworkPath, "get"]
proc.environment = ["PATH": "/usr/bin:/bin", "LANG": "en_US.UTF-8"]
let outPipe = Pipe()
proc.standardOutput = outPipe
proc.standardError = FileHandle(forWritingAtPath: "/dev/null")
do {
try proc.run()
proc.waitUntilExit()
let data = outPipe.fileHandleForReading.readDataToEndOfFile()
guard !data.isEmpty else {
debugLog("bootstrap get returned empty")
return
}
// `get` emits one JSON object to stdout.
if let payload = try? JSONDecoder().decode(AdapterStreamPayload.self, from: data) {
merge(payload)
debugLog("bootstrap get · title=\(currentInfo.title) playing=\(currentInfo.isPlaying)")
if currentInfo.hasTrack {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.onUpdate?(self.currentInfo)
}
}
}
} catch {
debugLog("bootstrap get failed: \(error)")
}
}
// MARK: - Stdout ingestion // MARK: - Stdout ingestion
private func ingestStdout(_ chunk: Data) { private func ingestStdout(_ chunk: Data) {
@ -217,18 +268,46 @@ final class MediaRemoteAdapterSource {
private func parseLine(_ data: Data) { private func parseLine(_ data: Data) {
do { do {
let payload = try JSONDecoder().decode(AdapterStreamPayload.self, from: data) let env = try JSONDecoder().decode(AdapterStreamEnvelope.self, from: data)
guard env.type == "data" else {
debugLog("non-data envelope: \(env.type ?? "nil")")
return
}
guard let payload = env.payload else { return }
// Full snapshot (diff=false) reset, then merge, so stale
// fields from the previous track don't leak. Diff (default
// or true) merge only the provided fields.
if env.diff == false {
currentInfo = MediaRemoteInfo()
}
merge(payload) merge(payload)
debugLog("stream rx · diff=\(env.diff ?? true) title=\(currentInfo.title) artist=\(currentInfo.artist) playing=\(currentInfo.isPlaying) hasTrack=\(currentInfo.hasTrack)")
if currentInfo.hasTrack { if currentInfo.hasTrack {
onUpdate?(currentInfo) onUpdate?(currentInfo)
} }
} catch { } catch {
// Not every line is a full object stream mode sometimes emits if let preview = String(data: data.prefix(80), encoding: .utf8),
// null or empty diff when source goes away. Silent on DecodingError
// unless it looks like a real crash (non-JSON prefix).
if let preview = String(data: data.prefix(60), encoding: .utf8),
!preview.hasPrefix("{") && !preview.hasPrefix("null") { !preview.hasPrefix("{") && !preview.hasPrefix("null") {
NSLog("[mio-plugin-music] adapter: unparseable line: \(preview)") debugLog("unparseable line: \(preview)")
}
}
}
/// File-based debug log NSLog / os_log are unreliably filtered on
/// macOS 15, and we can't attach Xcode to a plugin loaded from a
/// signed host. Writing a line-oriented log to /tmp is the one
/// channel that always works for post-mortem inspection.
private func debugLog(_ msg: String) {
let line = "[\(ISO8601DateFormatter().string(from: Date()))] \(msg)\n"
let path = "/tmp/mio-plugin-music-debug.log"
if let data = line.data(using: .utf8) {
if FileManager.default.fileExists(atPath: path),
let h = try? FileHandle(forWritingTo: URL(fileURLWithPath: path)) {
try? h.seekToEnd()
try? h.write(contentsOf: data)
try? h.close()
} else {
try? data.write(to: URL(fileURLWithPath: path))
} }
} }
} }

View File

@ -115,4 +115,16 @@ enum L10n {
static var unknownArtist: String { static var unknownArtist: String {
isChinese ? "未知艺术家" : "Unknown Artist" isChinese ? "未知艺术家" : "Unknown Artist"
} }
static var floatLyricsTooltip: String {
isChinese ? "悬浮歌词窗 · 点击切换显示" : "Floating lyrics window · toggle visibility"
}
static var lyricsPlaceholder: String {
isChinese ? "歌词暂未接入 · 等待真实数据源" : "Lyrics not wired yet — placeholder"
}
static var lyricsStyleLabel: String {
isChinese ? "样式" : "Style"
}
} }

View File

@ -0,0 +1,369 @@
//
// DesktopLyricsViews.swift
// MioIsland Music Plugin
//
// The three floating lyrics window variants. Shared traits:
// - NSVisualEffectView-backed blur via .background(.ultraThinMaterial)
// when available, fallback to semi-transparent color.
// - Draggable via the window's isMovableByWindowBackground (no extra
// gesture recognisers needed).
// - All text / progress / controls bound to NowPlayingState.shared.
// - Lyric lines are PLACEHOLDER text until we wire a real lyrics
// source. Only the `lyricLine(_:)` computed below changes when
// lyrics data becomes available.
//
import AppKit
import SwiftUI
// MARK: - Shared lyric slot helpers
private enum LyricSlot {
case previous
case current
case next
}
/// Pick the right synced-lyric line for a given slot. Falls back to a
/// sensible text when no lyrics are loaded so the window stays readable:
/// - previous / next "······" (tastefully blank)
/// - current track title on cold start, or
/// L10n.lyricsPlaceholder when paused / not-found
@MainActor
private func lyricText(_ slot: LyricSlot, state: NowPlayingState) -> String {
let lines = state.syncedLyrics
let idx = state.currentLyricIndex
if !lines.isEmpty {
switch slot {
case .previous:
let i = idx - 1
return (i >= 0 && i < lines.count) ? lines[i].text : "······"
case .current:
if idx >= 0 && idx < lines.count { return lines[idx].text }
// Before first lyric line (elapsedTime < first timestamp).
return lines.first?.text ?? (state.title.isEmpty ? L10n.lyricsPlaceholder : state.title)
case .next:
let i = idx + 1
return (i >= 0 && i < lines.count) ? lines[i].text : "······"
}
}
// No lyrics loaded / not found graceful fallback.
switch slot {
case .previous: return "······"
case .current:
return state.isPlaying
? (state.title.isEmpty ? L10n.unknownTitle : state.title)
: L10n.lyricsPlaceholder
case .next:
return state.artist.isEmpty ? "······" : "\(state.artist)"
}
}
// MARK: - Shared SVG-equivalent transport controls
private struct MiniControls: View {
@ObservedObject var state: NowPlayingState = .shared
let playButtonSize: CGFloat
let iconButtonSize: CGFloat
let filledPlay: Bool // Bar/Karaoke use filled white; Cinema similar
var body: some View {
HStack(spacing: 4) {
button(icon: "backward.fill", size: iconButtonSize) {
state.previousTrack()
}
Button(action: { state.togglePlayPause() }) {
ZStack {
Circle()
.fill(filledPlay ? Color.white.opacity(0.95) : Color.white.opacity(0.9))
.frame(width: playButtonSize, height: playButtonSize)
Image(systemName: state.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: playButtonSize * 0.42, weight: .bold))
.foregroundColor(.black)
.offset(x: state.isPlaying ? 0 : 1)
}
}
.buttonStyle(.plain)
button(icon: "forward.fill", size: iconButtonSize) {
state.nextTrack()
}
}
}
@ViewBuilder
private func button(icon: String, size: CGFloat, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(.system(size: size * 0.48, weight: .semibold))
.foregroundColor(.white.opacity(0.7))
.frame(width: size, height: size)
.background(Circle().fill(Color.white.opacity(0.001)))
.contentShape(Circle())
}
.buttonStyle(.plain)
}
}
private let floatBackground = Color(red: 0x12/255, green: 0x10/255, blue: 0x16/255).opacity(0.62)
private let floatStroke = Color.white.opacity(0.12)
/// ViewModifier applying the shared glass chrome (blur + border + shadow).
private struct FloatChrome: ViewModifier {
let radius: CGFloat
func body(content: Content) -> some View {
content
.background(
ZStack {
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(.ultraThinMaterial)
RoundedRectangle(cornerRadius: radius, style: .continuous)
.fill(floatBackground)
}
)
.overlay(
RoundedRectangle(cornerRadius: radius, style: .continuous)
.strokeBorder(floatStroke, lineWidth: 0.5)
)
.clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
.shadow(color: .black.opacity(0.5), radius: 30, y: 8)
.shadow(color: .black.opacity(0.4), radius: 8, y: 2)
}
}
// MARK: - Rotating vinyl disc (for Bar + Cinema)
private struct VinylDisc: View {
let artwork: NSImage?
let isPlaying: Bool
let diameter: CGFloat
/// TimelineView drives rotation off the monotonic wall clock, which is
/// immune to SwiftUI re-creating the view (window hide/show, style
/// switch). `withAnimation(.repeatForever)` used to lose the animation
/// on re-creation and snap to rest. Wall-clock-based rotation just
/// always looks right derive angle from `elapsed % 8s * 45°/s`.
@State private var pauseAccumulator: Double = 0
@State private var pauseStart: Date? = nil
var body: some View {
TimelineView(.animation(minimumInterval: 1.0 / 60.0, paused: !isPlaying)) { ctx in
let elapsed = ctx.date.timeIntervalSinceReferenceDate
// 8-second period 45°/s. Multiplying by 45 and wrapping to
// [0, 360) keeps the rotation smooth across many hours without
// floating-point drift.
let angle = (elapsed * 45.0).truncatingRemainder(dividingBy: 360)
disc.rotationEffect(.degrees(angle))
}
}
private var disc: some View {
ZStack {
Circle().fill(Color.black)
if let art = artwork {
Image(nsImage: art)
.resizable()
.scaledToFill()
.frame(width: diameter * 0.55, height: diameter * 0.55)
.clipShape(Circle())
} else {
Circle()
.fill(LinearGradient(
colors: [Color(red: 0.9, green: 0.72, blue: 0.53),
Color(red: 0.27, green: 0.35, blue: 0.33)],
startPoint: .topLeading, endPoint: .bottomTrailing
))
.frame(width: diameter * 0.55, height: diameter * 0.55)
}
Circle()
.fill(Color.black)
.frame(width: diameter * 0.1, height: diameter * 0.1)
}
.frame(width: diameter, height: diameter)
.overlay(
Circle().strokeBorder(Color.white.opacity(0.15), lineWidth: 0.5)
)
}
}
// MARK: - Model 1 · Bar
struct LyricsBarView: View {
@ObservedObject var state: NowPlayingState = .shared
var body: some View {
HStack(spacing: 16) {
VinylDisc(artwork: state.albumArt, isPlaying: state.isPlaying, diameter: 36)
Text(lyricText(.current, state: state))
.font(.system(size: 20, weight: .medium))
.tracking(-0.1)
.foregroundColor(.white.opacity(0.95))
.lineLimit(1)
.shadow(color: .black.opacity(0.4), radius: 6, y: 2)
.frame(maxWidth: .infinity, alignment: .leading)
MiniControls(state: state, playButtonSize: 28, iconButtonSize: 24, filledPlay: true)
}
.padding(.horizontal, 22)
.padding(.vertical, 14)
.modifier(FloatChrome(radius: 999))
.padding(4) // breathing room so shadow isn't clipped by window
}
}
// MARK: - Model 2 · Karaoke
struct LyricsKaraokeView: View {
@ObservedObject var state: NowPlayingState = .shared
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Header
HStack(spacing: 10) {
Circle()
.fill(Color(red: 0.9, green: 0.72, blue: 0.53))
.frame(width: 5, height: 5)
Text(state.sourceName.isEmpty
? (L10n.isChinese ? "歌词同步" : "Lyrics Sync")
: "\(L10n.isChinese ? "歌词同步 · " : "Lyrics · ")\(state.sourceName)")
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.55))
Text("·")
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.3))
Text("\(state.formattedElapsed) / \(state.formattedDuration)")
.font(.system(size: 10.5, design: .monospaced))
.foregroundColor(.white.opacity(0.55))
Spacer()
Text("⋮⋮ drag")
.font(.system(size: 9.5, design: .monospaced))
.foregroundColor(.white.opacity(0.3))
}
.padding(.bottom, 12)
// Current (big)
Text(lyricText(.current, state: state))
.font(.system(size: 26, weight: .semibold))
.tracking(-0.3)
.foregroundColor(.white)
.lineLimit(1)
.shadow(color: .black.opacity(0.35), radius: 10, y: 3)
// Next (faint)
Text(lyricText(.next, state: state))
.font(.system(size: 15, weight: .medium))
.foregroundColor(.white.opacity(0.42))
.lineLimit(1)
.padding(.top, 6)
Divider()
.background(Color.white.opacity(0.08))
.padding(.vertical, 14)
// Meta + controls row
HStack(alignment: .center, spacing: 10) {
albumArtSmall
VStack(alignment: .leading, spacing: 1) {
Text(state.title.isEmpty ? L10n.unknownTitle : state.title)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white.opacity(0.85))
.lineLimit(1)
Text(state.artist.isEmpty ? L10n.unknownArtist : state.artist)
.font(.system(size: 10.5))
.foregroundColor(.white.opacity(0.45))
.lineLimit(1)
}
Spacer()
MiniControls(state: state, playButtonSize: 32, iconButtonSize: 28, filledPlay: true)
}
}
.padding(EdgeInsets(top: 22, leading: 26, bottom: 18, trailing: 26))
.modifier(FloatChrome(radius: 20))
.padding(4)
}
@ViewBuilder
private var albumArtSmall: some View {
if let art = state.albumArt {
Image(nsImage: art)
.resizable()
.scaledToFill()
.frame(width: 28, height: 28)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
} else {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(LinearGradient(
colors: [Color(red: 0.9, green: 0.72, blue: 0.53),
Color(red: 0.27, green: 0.35, blue: 0.33)],
startPoint: .topLeading, endPoint: .bottomTrailing
))
.frame(width: 28, height: 28)
}
}
}
// MARK: - Model 3 · Cinema
struct LyricsCinemaView: View {
@ObservedObject var state: NowPlayingState = .shared
var body: some View {
VStack(spacing: 0) {
// Prev line (faint)
Text(lyricText(.previous, state: state))
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white.opacity(0.3))
.lineLimit(1)
.padding(.bottom, 12)
// Now line (huge)
Text(lyricText(.current, state: state))
.font(.system(size: 34, weight: .bold))
.tracking(-0.6)
.foregroundColor(.white)
.lineLimit(1)
.shadow(color: .black.opacity(0.4), radius: 16, y: 3)
// Next line (faint)
Text(lyricText(.next, state: state))
.font(.system(size: 14, weight: .medium))
.foregroundColor(.white.opacity(0.4))
.lineLimit(1)
.padding(.top, 12)
Divider()
.background(Color.white.opacity(0.08))
.padding(.top, 28)
.padding(.bottom, 18)
// Footer
HStack(spacing: 10) {
VinylDisc(artwork: state.albumArt, isPlaying: state.isPlaying, diameter: 22)
Text("\(state.title.isEmpty ? L10n.unknownTitle : state.title) · \(state.artist.isEmpty ? L10n.unknownArtist : state.artist)")
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.55))
.lineLimit(1)
Spacer()
MiniControls(state: state, playButtonSize: 30, iconButtonSize: 30, filledPlay: true)
}
}
.padding(EdgeInsets(top: 40, leading: 40, bottom: 28, trailing: 40))
.background(
// Faint color wash for cinema feel
LinearGradient(
colors: [
Color(red: 0.9, green: 0.72, blue: 0.53).opacity(0.10),
Color.clear
],
startPoint: .topLeading, endPoint: .bottomTrailing
)
)
.modifier(FloatChrome(radius: 24))
.padding(4)
}
}

View File

@ -0,0 +1,243 @@
//
// DesktopLyricsWindow.swift
// MioIsland Music Plugin
//
// Floating desktop "lyrics" overlay window always-on-top, movable by
// dragging anywhere on its background, dismissable with Escape. Three
// style variants the user can cycle through:
//
// Bar (Model 1) narrow single-line pill, 520×64
// Karaoke (Model 2) two-line card, current + next, 560×170
// Cinema (Model 3) 3-line large typography, 640×260
//
// All variants derive their title/artist/progress/isPlaying from
// NowPlayingState.shared. Lyrics data is NOT yet piped in (MediaRemote
// adapter doesn't expose lyric timings and there's no public API on
// Apple Music / Spotify), so the "lyric line" slot shows a placeholder
// string. When a lyric source lands, only the `currentLine` /
// `nextLine` / `prevLine` computed properties need to change.
//
// Window is one per app `DesktopLyricsWindow.shared` serves toggles
// from the ExpandedView's pin button.
//
import AppKit
import SwiftUI
import Combine
// MARK: - Style enum
enum LyricsStyle: String, CaseIterable, Identifiable {
case bar
case karaoke
case cinema
var id: String { rawValue }
var windowSize: CGSize {
switch self {
case .bar: return CGSize(width: 520, height: 64)
case .karaoke: return CGSize(width: 560, height: 170)
case .cinema: return CGSize(width: 640, height: 260)
}
}
var displayName: String {
switch self {
case .bar: return L10n.isChinese ? "单行胶囊" : "Bar"
case .karaoke: return L10n.isChinese ? "双行卡拉" : "Karaoke"
case .cinema: return L10n.isChinese ? "影院大字" : "Cinema"
}
}
}
// MARK: - Window
@MainActor
final class DesktopLyricsWindow {
static let shared = DesktopLyricsWindow()
private var window: NSWindow?
private let stylePrefsKey = "mio.music.lyricsStyle.v1"
private init() {}
var isVisible: Bool {
window?.isVisible ?? false
}
func toggle() {
if isVisible {
hide()
} else {
show()
}
}
func show() {
if let existing = window {
existing.orderFront(nil)
return
}
let style = loadStyle()
let root = DesktopLyricsRootView(initialStyle: style) { [weak self] newStyle in
self?.saveStyle(newStyle)
self?.resizeTo(newStyle.windowSize)
}
let host = NSHostingView(rootView: root)
let win = DraggableBorderlessWindow(
contentRect: NSRect(origin: .zero, size: style.windowSize),
styleMask: [.borderless],
backing: .buffered,
defer: false
)
win.contentView = host
win.backgroundColor = .clear
win.isOpaque = false
win.hasShadow = true
win.isMovableByWindowBackground = true
win.level = .floating // always-on-top
win.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
win.ignoresMouseEvents = false // we need clicks for controls
win.isReleasedWhenClosed = false
// Default placement bottom center of the primary screen, 80pt
// above the Dock. User can drag from there.
if let screen = NSScreen.main {
let f = screen.visibleFrame
let x = f.midX - style.windowSize.width / 2
let y = f.minY + 80
win.setFrameOrigin(NSPoint(x: x, y: y))
}
win.orderFront(nil)
window = win
}
func hide() {
window?.orderOut(nil)
}
func close() {
window?.close()
window = nil
}
private func resizeTo(_ size: CGSize) {
guard let win = window else { return }
var frame = win.frame
frame.origin.y += (frame.size.height - size.height) // anchor to bottom edge
frame.size = size
win.setFrame(frame, display: true, animate: true)
}
private func loadStyle() -> LyricsStyle {
if let raw = UserDefaults.standard.string(forKey: stylePrefsKey),
let s = LyricsStyle(rawValue: raw) {
return s
}
return .bar
}
private func saveStyle(_ style: LyricsStyle) {
UserDefaults.standard.set(style.rawValue, forKey: stylePrefsKey)
}
}
// Borderless NSWindows can become key (so Escape works) and swallow the
// mouse events on our control buttons while still letting drag-background
// move the window.
private final class DraggableBorderlessWindow: NSWindow {
override var canBecomeKey: Bool { true }
override var canBecomeMain: Bool { false }
override func keyDown(with event: NSEvent) {
// Escape hide (consistent with other floating overlays).
if event.keyCode == 53 {
DesktopLyricsWindow.shared.hide()
return
}
super.keyDown(with: event)
}
}
// MARK: - Root view
/// Hosts the style picker + the currently selected variant. State-changing
/// props go up to the window via the `onStyleChange` callback so the
/// window can resize.
private struct DesktopLyricsRootView: View {
@ObservedObject private var state = NowPlayingState.shared
@State private var style: LyricsStyle
let onStyleChange: (LyricsStyle) -> Void
init(initialStyle: LyricsStyle, onStyleChange: @escaping (LyricsStyle) -> Void) {
_style = State(initialValue: initialStyle)
self.onStyleChange = onStyleChange
}
var body: some View {
ZStack(alignment: .topTrailing) {
Group {
switch style {
case .bar: LyricsBarView()
case .karaoke: LyricsKaraokeView()
case .cinema: LyricsCinemaView()
}
}
.transition(.opacity)
// Tiny style-cycle chip in the very corner minimal, only
// visible on hover to stay out of the way.
StyleCyclerChip(current: style) { next in
withAnimation(.easeInOut(duration: 0.2)) {
style = next
}
onStyleChange(next)
}
.padding(8)
}
.frame(
width: style.windowSize.width,
height: style.windowSize.height
)
}
}
// MARK: - Cycle chip
private struct StyleCyclerChip: View {
let current: LyricsStyle
let onChange: (LyricsStyle) -> Void
@State private var isHovered = false
var body: some View {
Button {
let all = LyricsStyle.allCases
let idx = all.firstIndex(of: current) ?? 0
onChange(all[(idx + 1) % all.count])
} label: {
HStack(spacing: 5) {
Image(systemName: "rectangle.3.offgrid")
.font(.system(size: 9, weight: .semibold))
Text(current.displayName)
.font(.system(size: 9, weight: .medium, design: .monospaced))
}
.foregroundColor(.white.opacity(isHovered ? 0.9 : 0.5))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(
Capsule().fill(Color.black.opacity(isHovered ? 0.45 : 0.25))
)
.overlay(
Capsule().strokeBorder(Color.white.opacity(0.12), lineWidth: 0.5)
)
}
.buttonStyle(.plain)
.onHover { isHovered = $0 }
.help(L10n.lyricsStyleLabel)
}
}

View File

@ -45,15 +45,27 @@ struct ExpandedView: View {
var body: some View { var body: some View {
ZStack(alignment: .center) { ZStack(alignment: .center) {
AlbumArtColorExtractor // V2 Immersive backdrop applied only when actually playing,
.backgroundGradient(for: tintColor) // other modes (empty / warning) use the plain near-black base.
.ignoresSafeArea() if currentMode == .playing {
immersiveBackdrop
.ignoresSafeArea()
}
// ZStack's default alignment centers children to their intrinsic
// size. We rely on that instead of a .frame wrapper so content
// doesn't get silently stretched vertically.
content content
.padding(20) .padding(20)
// Top-right float-window toggle only when playing.
if currentMode == .playing {
VStack {
HStack {
Spacer()
floatWindowToggle
}
Spacer()
}
.padding(14)
}
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Self.base) .background(Self.base)
@ -64,6 +76,57 @@ struct ExpandedView: View {
.animation(.easeInOut(duration: 0.25), value: currentMode) .animation(.easeInOut(duration: 0.25), value: currentMode)
} }
/// V2 Immersive backdrop: blurred enlarged album art + dark gradient
/// overlay (35% 72% 92%). Falls back to a solid base when no art.
@ViewBuilder
private var immersiveBackdrop: some View {
if let art = state.albumArt {
ZStack {
Image(nsImage: art)
.resizable()
.scaledToFill()
.saturation(1.4)
.blur(radius: 40, opaque: true)
.scaleEffect(1.3)
.clipped()
LinearGradient(
gradient: Gradient(stops: [
.init(color: Self.base.opacity(0.35), location: 0.0),
.init(color: Self.base.opacity(0.72), location: 0.55),
.init(color: Self.base.opacity(0.92), location: 1.0)
]),
startPoint: .top,
endPoint: .bottom
)
}
} else {
AlbumArtColorExtractor.backgroundGradient(for: tintColor)
}
}
/// Pin/float button that toggles the desktop lyrics overlay.
/// Icon is static (`pip.enter`) we don't observe the window to keep
/// this view free of a @StateObject dependency. The window itself is
/// the visibility signal.
private var floatWindowToggle: some View {
Button {
DesktopLyricsWindow.shared.toggle()
} label: {
Image(systemName: "pip.enter")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Self.ink.opacity(0.85))
.frame(width: 28, height: 28)
.background(
Circle().fill(Color.black.opacity(0.35))
)
.overlay(
Circle().strokeBorder(Color.white.opacity(0.12), lineWidth: 0.5)
)
}
.buttonStyle(.plain)
.help(L10n.floatLyricsTooltip)
}
// MARK: - State routing // MARK: - State routing
private enum Mode: Equatable { private enum Mode: Equatable {
@ -109,69 +172,57 @@ struct ExpandedView: View {
// MARK: - Playing card compact horizontal layout // MARK: - Playing card compact horizontal layout
private var playingCard: some View { private var playingCard: some View {
VStack(spacing: 16) { // V2 Immersive layout centered column: cover 120 title artist
// Hero row: album art left, metadata + source badge right. // progress controls with outline play button. Matches the
// We bound the HStack to the album art height (128) so the meta // Claude Design CodeIsland Music.html V2 spec.
// column can't propagate a fill-height hint up to the outer VStack(spacing: 14) {
// VStack. (Previous version let an inner Spacer bleed through, // Large centered album art shadow drops onto blurred backdrop
// which shoved the progress bar and controls to the panel's albumArt
// bottom edge with ~500pt of dead space in the middle.) .frame(width: 120, height: 120)
HStack(alignment: .top, spacing: 14) {
albumArt
VStack(alignment: .leading, spacing: 4) { VStack(spacing: 4) {
HStack { Text(state.title.isEmpty ? L10n.unknownTitle : state.title)
Spacer() .font(.system(size: 19, weight: .semibold))
sourceBadge .tracking(-0.35)
} .foregroundColor(Self.ink)
.lineLimit(1)
.shadow(color: .black.opacity(0.35), radius: 6, y: 2)
Text(state.title.isEmpty ? L10n.unknownTitle : state.title) Text(state.artist.isEmpty ? L10n.unknownArtist : state.artist)
.font(.system(size: 18, weight: .semibold)) .font(.system(size: 13))
.foregroundColor(Self.ink) .foregroundColor(Self.ink.opacity(0.78))
.lineLimit(2) .lineLimit(1)
.fixedSize(horizontal: false, vertical: true)
Text(state.artist.isEmpty ? L10n.unknownArtist : state.artist)
.font(.system(size: 13, weight: .regular))
.foregroundColor(Self.ink.opacity(0.75))
.lineLimit(1)
if !state.album.isEmpty {
Text(state.album)
.font(.system(size: 11, weight: .regular))
.foregroundColor(Self.ink.opacity(0.45))
.lineLimit(1)
}
}
.frame(maxWidth: .infinity, alignment: .topLeading)
} }
.frame(height: 128) .frame(maxWidth: 360)
// Progress + times inline on one row // Source chip (below artist, subtle)
VStack(spacing: 6) { sourceBadge
// Progress bar + times
VStack(spacing: 8) {
SeekBar( SeekBar(
progress: state.progress, progress: state.progress,
duration: state.duration duration: state.duration
) { newTime in ) { newTime in
state.seek(to: newTime) state.seek(to: newTime)
} }
HStack { HStack {
Text(state.formattedElapsed) Text(state.formattedElapsed)
.font(.system(size: 10, weight: .regular, design: .monospaced)) .font(.system(size: 10, design: .monospaced))
.foregroundColor(Self.ink.opacity(0.5)) .foregroundColor(Self.ink.opacity(0.55))
Spacer() Spacer()
Text(state.formattedDuration) Text(state.formattedDuration)
.font(.system(size: 10, weight: .regular, design: .monospaced)) .font(.system(size: 10, design: .monospaced))
.foregroundColor(Self.ink.opacity(0.5)) .foregroundColor(Self.ink.opacity(0.55))
} }
} }
.padding(.top, 6)
Spacer(minLength: 0)
// Transport controls
transportControls transportControls
.padding(.top, 2)
} }
.frame(maxWidth: 460) .frame(maxWidth: 380)
} }
private var albumArt: some View { private var albumArt: some View {
@ -180,12 +231,10 @@ struct ExpandedView: View {
Image(nsImage: art) Image(nsImage: art)
.resizable() .resizable()
.aspectRatio(contentMode: .fill) .aspectRatio(contentMode: .fill)
.frame(width: 128, height: 128) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
} else { } else {
RoundedRectangle(cornerRadius: 12, style: .continuous) RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.white.opacity(0.08)) .fill(Color.white.opacity(0.08))
.frame(width: 128, height: 128)
.overlay( .overlay(
Image(systemName: "music.note") Image(systemName: "music.note")
.font(.system(size: 34, weight: .light)) .font(.system(size: 34, weight: .light))
@ -193,7 +242,7 @@ struct ExpandedView: View {
) )
} }
} }
.shadow(color: .black.opacity(0.35), radius: 14, x: 0, y: 6) .shadow(color: .black.opacity(0.5), radius: 22, x: 0, y: 10)
} }
private var sourceBadge: some View { private var sourceBadge: some View {
@ -227,15 +276,18 @@ struct ExpandedView: View {
} }
// Play / pause accent button, slightly smaller than v2.0.0 (48 vs 56) // Play / pause accent button, slightly smaller than v2.0.0 (48 vs 56)
// V2 Immersive: outline play button (1.5px 85% white) lets the
// blurred album backdrop breathe through instead of punching a
// big lime disc that fights the art.
Button(action: { state.togglePlayPause() }) { Button(action: { state.togglePlayPause() }) {
ZStack { ZStack {
Circle() Circle()
.fill(Self.lime) .strokeBorder(Self.ink.opacity(0.85), lineWidth: 1.5)
.frame(width: 48, height: 48) .frame(width: 48, height: 48)
Image(systemName: state.isPlaying ? "pause.fill" : "play.fill") Image(systemName: state.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 18, weight: .bold)) .font(.system(size: 18, weight: .semibold))
.foregroundColor(.black) .foregroundColor(Self.ink)
.offset(x: state.isPlaying ? 0 : 2) // optical nudge for play glyph .offset(x: state.isPlaying ? 0 : 2)
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)