From c004a83cbb5aad38a08cec7a9d78c05b56b884d6 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sat, 9 May 2026 21:11:07 +0000
Subject: [PATCH 1/4] Replace Genius HTML scraping with LRCLIB API and synced
lyrics UI
- Add LRCLIBLyricsService: GET /api/get with track signature, search fallback,
LRC timestamp parsing, and recommended User-Agent header.
- Mini player passes album, duration, and live playback position for matching
and time-synced display.
- New SyncedLyricsScrollView auto-scrolls with spring animation; active line
uses glow, depth fade, and TimelineView shimmer when playing.
- Plain lyrics use renamed PlainLyricsLineByLineView with LRCLIB attribution.
- Remove Genius HTML lyric scraper.
Co-authored-by: coolbanjo
---
Lightify/Genius/GeniusLyricsService.swift | 487 ----------------------
Lightify/LRCLIB/LRCLIBLyricsService.swift | 294 +++++++++++++
Lightify/Views/GeniusLyricsViews.swift | 249 +++++++++--
Lightify/Views/MiniPlayerWindowView.swift | 10 +-
4 files changed, 524 insertions(+), 516 deletions(-)
delete mode 100644 Lightify/Genius/GeniusLyricsService.swift
create mode 100644 Lightify/LRCLIB/LRCLIBLyricsService.swift
diff --git a/Lightify/Genius/GeniusLyricsService.swift b/Lightify/Genius/GeniusLyricsService.swift
deleted file mode 100644
index e6aa54a..0000000
--- a/Lightify/Genius/GeniusLyricsService.swift
+++ /dev/null
@@ -1,487 +0,0 @@
-//
-// GeniusLyricsService.swift
-// Lightify
-//
-// Fetches genius.com lyric pages: try canonical URL, then `/api/search/multi` (JSON), then parse song HTML.
-// Primary URL pattern: https://genius.com/{artistSlug}-{titleSlug}-lyrics
-// Lyrics live in , in divs with data-lyrics-container="true".
-//
-
-import Foundation
-
-enum GeniusLyricsError: Error, LocalizedError, Sendable {
- case noSearchResults
- case emptyLyrics
- case invalidResponse
- case httpStatus(Int)
-
- var errorDescription: String? {
- switch self {
- case .noSearchResults:
- return "We couldn't find the lyrics for this one."
- case .emptyLyrics:
- return "Could not read lyrics from the Genius page."
- case .invalidResponse:
- return "Unexpected response from Genius."
- case .httpStatus(let code):
- return "Request failed (HTTP \(code))."
- }
- }
-}
-
-/// Load Genius lyric pages (direct URL or search API), parse `data-lyrics-container` inside ``, then drop text before the first `[` (section tags); keep the rest through the end so unbracketed lines after the last `]` (e.g. outro) stay included.
-struct GeniusLyricsService: Sendable {
- private let session: URLSession
-
- nonisolated init(session: URLSession = .shared) {
- self.session = session
- }
-
- func fetchLyrics(title: String, artist: String) async throws -> String {
- let titleTrimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
- let (titleT, titleHadFeatCollaboratorSuffix) = Self.stripFeatCollaboratorSuffixes(from: titleTrimmed)
- var artistT = Self.normalizedArtistForGenius(artist)
- if titleHadFeatCollaboratorSuffix {
- artistT = Self.firstCommaSeparatedArtistCredit(artistT)
- }
- guard !artistT.isEmpty, !titleT.isEmpty else {
- throw GeniusLyricsError.noSearchResults
- }
-
- /// Primary: `https://genius.com/{artist-slug}-{title-slug}-lyrics`
- if let directURL = Self.geniusLyricsPageURL(artist: artistT, title: titleT) {
- if let html = try await fetchHTML(url: directURL, requireHTTP200: false),
- let lyrics = Self.extractLyricsFromSongPageHTML(html),
- !lyrics.isEmpty {
- return lyrics
- }
- }
-
- let query = [artistT, titleT].joined(separator: " ")
- guard let songURL = try await songLyricsURLFromSearchMulti(
- query: query,
- artist: artistT,
- title: titleT
- ) else {
- throw GeniusLyricsError.noSearchResults
- }
-
- guard let pageHTML = try await fetchHTML(url: songURL, requireHTTP200: true) else {
- throw GeniusLyricsError.httpStatus(0)
- }
-
- guard let lyrics = Self.extractLyricsFromSongPageHTML(pageHTML), !lyrics.isEmpty else {
- throw GeniusLyricsError.emptyLyrics
- }
- return lyrics
- }
-
- /// When `requireHTTP200` is false, returns `nil` for non-200 (e.g. wrong guessed slug).
- private func fetchHTML(url: URL, requireHTTP200: Bool) async throws -> String? {
- var request = URLRequest(url: url)
- request.setValue(Self.browserUserAgent, forHTTPHeaderField: "User-Agent")
- request.setValue("text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", forHTTPHeaderField: "Accept")
- request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")
-
- let (data, response) = try await session.data(for: request)
- let status = (response as? HTTPURLResponse)?.statusCode ?? 0
- if status != 200 {
- if requireHTTP200 {
- throw GeniusLyricsError.httpStatus(status)
- }
- return nil
- }
- guard let html = String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1) else {
- throw GeniusLyricsError.invalidResponse
- }
- return html
- }
-
- /// Genius search HTML embeds unrelated `hot_songs_preview` links before real results; the public multi search API returns ordered hits instead.
- private func songLyricsURLFromSearchMulti(query: String, artist: String, title: String) async throws -> URL? {
- var searchComponents = URLComponents(string: "https://genius.com/api/search/multi")!
- searchComponents.queryItems = [URLQueryItem(name: "q", value: query)]
- guard let searchURL = searchComponents.url else { return nil }
-
- var request = URLRequest(url: searchURL)
- request.setValue(Self.browserUserAgent, forHTTPHeaderField: "User-Agent")
- request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept")
-
- let (data, response) = try await session.data(for: request)
- let status = (response as? HTTPURLResponse)?.statusCode ?? 0
- guard status == 200 else { return nil }
-
- guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any],
- let responseObj = root["response"] as? [String: Any],
- let sections = responseObj["sections"] as? [[String: Any]]
- else { return nil }
-
- var bestURL: URL?
- var bestScore = -1
-
- for section in sections {
- guard let hits = section["hits"] as? [[String: Any]] else { continue }
- for hit in hits {
- guard let hitType = hit["type"] as? String, hitType == "song",
- let result = hit["result"] as? [String: Any],
- let resultType = result["_type"] as? String, resultType == "song",
- let urlString = result["url"] as? String,
- let url = URL(string: urlString),
- let core = Self.lyricsSlugCore(fromGeniusPath: url.path)
- else { continue }
-
- let score = Self.geniusSearchHitScore(
- lyricsSlugCore: core,
- artist: artist,
- title: title,
- fullTitle: result["full_title"] as? String
- )
- if score > bestScore {
- bestScore = score
- bestURL = url
- }
- }
- }
-
- /// Reject weak substring matches (they were a common source of wrong songs when HTML search was used).
- return bestScore >= 250 ? bestURL : nil
- }
-
- private static let browserUserAgent =
- "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15"
-
- // MARK: - Direct URL (genius.com/Artist-song-lyrics)
-
- /// Strips common featured-artist suffixes so slugs match Genius URLs better.
- /// `didStrip` is true when any marker matched (e.g. `"Like That (feat. …)"` → `"Like That"`).
- private static func stripFeatCollaboratorSuffixes(from s: String) -> (String, didStrip: Bool) {
- var t = s
- var didStrip = false
- let cutMarkers = [
- " (feat.", " (featuring ", " (ft.", " (with ", " [feat.", " feat.",
- ]
- for m in cutMarkers {
- if let r = t.range(of: m, options: .caseInsensitive) {
- t = String(t[.. String {
- guard let comma = artist.firstIndex(of: ",") else { return artist }
- let head = artist[.. String {
- let trimmed = artist.trimmingCharacters(in: .whitespacesAndNewlines)
- let withoutFeat = stripFeatCollaboratorSuffixes(from: trimmed).0
- return withoutFeat
- .components(separatedBy: "+")
- .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
- .filter { !$0.isEmpty }
- .joined(separator: " and ")
- }
-
- private static func geniusSlugSegment(_ raw: String) -> String {
- let folded = raw.folding(options: .diacriticInsensitive, locale: Locale(identifier: "en_US_POSIX"))
- var result: [Character] = []
- var lastWasHyphen = false
- for c in folded.lowercased() {
- if c.isLetter || c.isNumber {
- result.append(c)
- lastWasHyphen = false
- } else if c == "'" || c == "\u{2019}" || c == "\"" || c == "\u{201d}" || c == "\u{201c}" {
- continue
- } else {
- if !result.isEmpty, !lastWasHyphen {
- result.append("-")
- lastWasHyphen = true
- }
- }
- }
- while result.last == "-" { result.removeLast() }
- while result.first == "-" { result.removeFirst() }
- return String(result)
- }
-
- private static func geniusLyricsPageURL(artist: String, title: String) -> URL? {
- let a = geniusSlugSegment(artist)
- let t = geniusSlugSegment(title)
- guard !a.isEmpty, !t.isEmpty else { return nil }
- return URL(string: "https://genius.com/\(a)-\(t)-lyrics")
- }
-
- /// Primary song pages use a single path segment ending in `-lyrics` (not `/albums/...` or `...-annotated`).
- private static func lyricsSlugCore(fromGeniusPath path: String) -> String? {
- let segments = path.split(separator: "/").map(String.init).filter { !$0.isEmpty }
- guard segments.count == 1 else { return nil }
- let leaf = segments[0].lowercased()
- guard leaf.hasSuffix("-lyrics") else { return nil }
- return String(leaf.dropLast("-lyrics".count))
- }
-
- private static func geniusSearchHitScore(
- lyricsSlugCore core: String,
- artist: String,
- title: String,
- fullTitle: String?
- ) -> Int {
- let a = geniusSlugSegment(artist)
- let t = geniusSlugSegment(title)
- guard !a.isEmpty, !t.isEmpty else { return -1 }
-
- let expected = a + "-" + t
- let score: Int
- if core == expected {
- score = 1000
- } else if core.hasPrefix(expected + "-") {
- score = 850
- } else if core.hasPrefix(a + "-"), core.contains(t) {
- score = 700
- } else if core.split(separator: "-").contains(where: { $0 == a }), core.contains(t) {
- score = 550
- } else if core.contains(a), core.contains(t) {
- score = 400
- } else if core.hasPrefix(a + "-") {
- score = 250
- } else if core.contains(a) || core.contains(t) {
- score = 120
- } else {
- return -1
- }
-
- var adjusted = score
- let ft = fullTitle?.lowercased() ?? ""
- let penaltyTerms = ["translation", "перевод", "türkçe", "русский", "annotated"]
- if penaltyTerms.contains(where: { ft.contains($0) }) {
- adjusted -= 200
- }
- if ft.contains("live"), !title.lowercased().contains("live") {
- adjusted -= 80
- }
- return adjusted
- }
-
- // MARK: - Lyrics DOM (inside `` only)
-
- /// Parses only `…` so About, nav, and scripts are excluded.
- private static func htmlMainFragment(_ html: String) -> String {
- guard let mainOpen = html.range(of: "", options: .caseInsensitive) else {
- return String(fromMain)
- }
- return String(fromMain[.. String? {
- let scoped = htmlMainFragment(fullHTML)
- var extracted = extractLyricsFromDataContainers(scoped)
- extracted = normalizeLyricWhitespace(extracted)
- extracted = sliceFromFirstOpenBracketThroughEnd(extracted)
- let trimmed = extracted.trimmingCharacters(in: .whitespacesAndNewlines)
- return trimmed.isEmpty ? nil : trimmed
- }
-
- /// Drops leading chrome before the first `[`. Keeps everything from that `[` through the end of the extracted block so lines after the final `]` (e.g. outro with no trailing bracket) are not cut off.
- private static func sliceFromFirstOpenBracketThroughEnd(_ s: String) -> String {
- guard let first = s.firstIndex(of: "[") else { return s }
- return String(s[first...])
- }
-
- /// Collapses odd-width spaces Genius uses inside annotated lines.
- private static func normalizeLyricWhitespace(_ s: String) -> String {
- s.replacingOccurrences(of: "\u{2005}", with: " ") // four-per-em
- .replacingOccurrences(of: "\u{2009}", with: " ") // thin
- .replacingOccurrences(of: "\u{200b}", with: "") // ZWSP (Genius inserts these in markup)
- .replacingOccurrences(of: "\u{feff}", with: "") // BOM
- }
-
- private static func extractLyricsFromDataContainers(_ html: String) -> String {
- var pieces: [String] = []
- var searchStart = html.startIndex
-
- while searchStart < html.endIndex {
- guard let markerRange = html.range(of: "data-lyrics-container", range: searchStart..") else {
- searchStart = html.index(after: marker)
- continue
- }
-
- let contentStart = html.index(after: openTagEnd)
- guard let (inner, afterBlock) = balancedClosingDivHTML(html, contentStart: contentStart) else {
- searchStart = html.index(after: marker)
- continue
- }
-
- let text = htmlFragmentToLyricLines(inner)
- if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- pieces.append(text)
- }
- searchStart = afterBlock
- }
-
- return pieces.joined(separator: "\n\n")
- }
-
- private static func balancedClosingDivHTML(
- _ html: String,
- contentStart: String.Index
- ) -> (inner: Substring, afterClosing: String.Index)? {
- var i = contentStart
- var depth = 1
-
- while i < html.endIndex {
- if isClosingDivTag(html, at: i) {
- depth -= 1
- if depth == 0 {
- let inner = html[contentStart..") else { break }
- depth += 1
- i = html.index(after: gt)
- continue
- }
-
- i = html.index(after: i)
- }
- return nil
- }
-
- private static func isClosingDivTag(_ html: String, at i: String.Index) -> Bool {
- guard let end = html.index(i, offsetBy: 6, limitedBy: html.endIndex) else { return false }
- return html[i..") == .orderedSame
- }
-
- private static func isOpeningDivTag(_ html: String, at i: String.Index) -> Bool {
- guard let end = html.index(i, offsetBy: 4, limitedBy: html.endIndex) else { return false }
- return html[i.. String {
- var s = String(fragment)
- s = s.replacingOccurrences(of: "
", with: "\n", options: .caseInsensitive)
- s = s.replacingOccurrences(of: "
", with: "\n", options: .caseInsensitive)
- s = s.replacingOccurrences(of: "
", with: "\n", options: .caseInsensitive)
- s = s.replacingOccurrences(of: "
", with: "\n", options: .caseInsensitive)
- s = stripHTMLTags(s)
- s = decodeAllHTMLEntities(s)
- s = s.replacingOccurrences(of: "\u{00a0}", with: " ")
- return s
- .replacingOccurrences(of: "\r\n", with: "\n")
- .replacingOccurrences(of: "\r", with: "\n")
- .split(separator: "\n", omittingEmptySubsequences: false)
- .map { $0.trimmingCharacters(in: .whitespaces) }
- .filter { !$0.isEmpty }
- .joined(separator: "\n")
- }
-
- private static func stripHTMLTags(_ html: String) -> String {
- var out = ""
- out.reserveCapacity(html.count)
- var i = html.startIndex
- while i < html.endIndex {
- if html[i] == "<" {
- if let close = html[i...].firstIndex(of: ">") {
- i = html.index(after: close)
- } else {
- out.append(html[i])
- i = html.index(after: i)
- }
- } else {
- out.append(html[i])
- i = html.index(after: i)
- }
- }
- return out
- }
-
- private static func decodeAllHTMLEntities(_ s: String) -> String {
- decodeNumericHTMLEntities(
- s.replacingOccurrences(of: "&", with: "&")
- .replacingOccurrences(of: "<", with: "<")
- .replacingOccurrences(of: ">", with: ">")
- .replacingOccurrences(of: """, with: "\"")
- .replacingOccurrences(of: "'", with: "'")
- .replacingOccurrences(of: "'", with: "'")
- )
- }
-
- /// Handles `'`, `'`, etc. Run after `&` so numeric codes are final.
- private static func decodeNumericHTMLEntities(_ s: String) -> String {
- var out = ""
- var i = s.startIndex
- while i < s.endIndex {
- if s[i] == "&",
- let hashIdx = s.index(i, offsetBy: 1, limitedBy: s.endIndex),
- hashIdx < s.endIndex, s[hashIdx] == "#" {
- let afterHash = s.index(after: hashIdx)
- if afterHash < s.endIndex {
- var value: UInt32?
- var scan = afterHash
- if s[scan] == "x" || s[scan] == "X" {
- scan = s.index(after: scan)
- var hex = 0 as UInt32
- var any = false
- while scan < s.endIndex {
- let ch = s[scan]
- guard let d = ch.hexDigitValue else { break }
- hex = hex * 16 + UInt32(d)
- any = true
- scan = s.index(after: scan)
- }
- if any, scan < s.endIndex, s[scan] == ";" {
- value = hex
- i = s.index(after: scan)
- }
- } else {
- var dec: UInt32 = 0
- var any = false
- while scan < s.endIndex {
- let ch = s[scan]
- guard let d = ch.wholeNumberValue else { break }
- dec = dec * 10 + UInt32(d)
- any = true
- scan = s.index(after: scan)
- }
- if any, scan < s.endIndex, s[scan] == ";" {
- value = dec
- i = s.index(after: scan)
- }
- }
- if let v = value, let scalar = UnicodeScalar(v) {
- out.append(Character(scalar))
- continue
- }
- }
- }
- out.append(s[i])
- i = s.index(after: i)
- }
- return out
- }
-}
diff --git a/Lightify/LRCLIB/LRCLIBLyricsService.swift b/Lightify/LRCLIB/LRCLIBLyricsService.swift
new file mode 100644
index 0000000..36054a8
--- /dev/null
+++ b/Lightify/LRCLIB/LRCLIBLyricsService.swift
@@ -0,0 +1,294 @@
+//
+// LRCLIBLyricsService.swift
+// Lightify
+//
+// Fetches lyrics from https://lrclib.net (no API key). Uses GET /api/get with track signature,
+// then falls back to /api/search when no exact match exists. Parses LRC `syncedLyrics` for timing.
+//
+
+import Foundation
+
+enum LRCLIBLyricsError: Error, LocalizedError, Sendable {
+ case notFound
+ case emptyLyrics
+ case invalidResponse
+ case httpStatus(Int)
+
+ var errorDescription: String? {
+ switch self {
+ case .notFound:
+ return "We couldn't find the lyrics for this one."
+ case .emptyLyrics:
+ return "This track has no readable lyrics in LRCLIB yet."
+ case .invalidResponse:
+ return "Unexpected response from LRCLIB."
+ case .httpStatus(let code):
+ return "Request failed (HTTP \(code))."
+ }
+ }
+}
+
+struct SyncedLyricLine: Identifiable, Sendable, Equatable {
+ let id: Int
+ let startMs: Int
+ let text: String
+}
+
+struct LRCLIBFetchedLyrics: Sendable, Equatable {
+ var plainText: String
+ var syncedLines: [SyncedLyricLine]?
+ var instrumental: Bool
+}
+
+struct LRCLIBLyricsService: Sendable {
+ private let session: URLSession
+ private let baseURL = URL(string: "https://lrclib.net")!
+
+ nonisolated init(session: URLSession = .shared) {
+ self.session = session
+ }
+
+ func fetchLyrics(trackName: String, artistName: String, albumName: String?, durationMs: Int) async throws -> LRCLIBFetchedLyrics {
+ let titleT = Self.cleanTitle(trackName)
+ let artistT = Self.cleanArtist(artistName, pairedWithTitle: trackName)
+ let albumT = albumName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ?? "Unknown Album"
+ let durationSec = max(1, durationMs / 1000)
+
+ guard !titleT.isEmpty, !artistT.isEmpty else {
+ throw LRCLIBLyricsError.notFound
+ }
+
+ if let record = try await getBySignature(
+ trackName: titleT,
+ artistName: artistT,
+ albumName: albumT,
+ durationSec: durationSec
+ ) {
+ return Self.fetched(from: record)
+ }
+
+ if let record = try await searchFallback(
+ trackName: titleT,
+ artistName: artistT,
+ albumName: albumT,
+ durationSec: durationSec
+ ) {
+ return Self.fetched(from: record)
+ }
+
+ throw LRCLIBLyricsError.notFound
+ }
+
+ // MARK: - Network
+
+ private func getBySignature(
+ trackName: String,
+ artistName: String,
+ albumName: String,
+ durationSec: Int
+ ) async throws -> LRCLIBRecordDTO? {
+ var c = URLComponents(url: baseURL.appendingPathComponent("api/get"), resolvingAgainstBaseURL: false)!
+ c.queryItems = [
+ URLQueryItem(name: "track_name", value: trackName),
+ URLQueryItem(name: "artist_name", value: artistName),
+ URLQueryItem(name: "album_name", value: albumName),
+ URLQueryItem(name: "duration", value: String(durationSec)),
+ ]
+ guard let url = c.url else { return nil }
+ return try await requestRecord(url: url, acceptNotFound: true)
+ }
+
+ private func searchFallback(
+ trackName: String,
+ artistName: String,
+ albumName: String,
+ durationSec: Int
+ ) async throws -> LRCLIBRecordDTO? {
+ var c = URLComponents(url: baseURL.appendingPathComponent("api/search"), resolvingAgainstBaseURL: false)!
+ c.queryItems = [
+ URLQueryItem(name: "track_name", value: trackName),
+ URLQueryItem(name: "artist_name", value: artistName),
+ ]
+ guard let url = c.url else { return nil }
+ let records = try await requestSearchArray(url: url)
+ return Self.pickSearchMatch(records: records, durationSec: durationSec, trackName: trackName, artistName: artistName, albumName: albumName)
+ }
+
+ private func requestRecord(url: URL, acceptNotFound: Bool) async throws -> LRCLIBRecordDTO? {
+ var request = URLRequest(url: url)
+ request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+
+ let (data, response) = try await session.data(for: request)
+ let status = (response as? HTTPURLResponse)?.statusCode ?? 0
+ if status == 404, acceptNotFound { return nil }
+ guard status == 200 else {
+ throw LRCLIBLyricsError.httpStatus(status)
+ }
+ let decoder = JSONDecoder()
+ return try decoder.decode(LRCLIBRecordDTO.self, from: data)
+ }
+
+ private func requestSearchArray(url: URL) async throws -> [LRCLIBRecordDTO] {
+ var request = URLRequest(url: url)
+ request.setValue(Self.userAgent, forHTTPHeaderField: "User-Agent")
+ request.setValue("application/json", forHTTPHeaderField: "Accept")
+
+ let (data, response) = try await session.data(for: request)
+ let status = (response as? HTTPURLResponse)?.statusCode ?? 0
+ guard status == 200 else {
+ throw LRCLIBLyricsError.httpStatus(status)
+ }
+ let decoder = JSONDecoder()
+ return try decoder.decode([LRCLIBRecordDTO].self, from: data)
+ }
+
+ private static let userAgent: String = {
+ let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1"
+ return "Lightify/\(version) (wss.Lightify)"
+ }()
+
+ // MARK: - Match + parse
+
+ private static func fetched(from record: LRCLIBRecordDTO) -> LRCLIBFetchedLyrics {
+ let plain = record.plainLyrics?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ let syncedRaw = record.syncedLyrics?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ let parsed = syncedRaw.isEmpty ? [] : LRCLIBLRCParser.parse(syncedLRC: syncedRaw)
+ let synced: [SyncedLyricLine]? = parsed.isEmpty ? nil : parsed
+ let instrumental = record.instrumental == true
+ return LRCLIBFetchedLyrics(plainText: plain, syncedLines: synced, instrumental: instrumental)
+ }
+
+ private static func pickSearchMatch(
+ records: [LRCLIBRecordDTO],
+ durationSec: Int,
+ trackName: String,
+ artistName: String,
+ albumName: String
+ ) -> LRCLIBRecordDTO? {
+ guard !records.isEmpty else { return nil }
+ let tLower = trackName.lowercased()
+ let aLower = artistName.lowercased()
+ let albLower = albumName.lowercased()
+
+ func score(_ r: LRCLIBRecordDTO) -> Int {
+ let durDelta = abs(r.duration - durationSec)
+ var s = 10_000 - min(durDelta, 120) * 40
+ if let syn = r.syncedLyrics, !syn.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ s += 800
+ } else if let pl = r.plainLyrics, !pl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ s += 200
+ }
+ let rt = r.trackName.lowercased()
+ if rt == tLower { s += 500 }
+ else if rt.contains(tLower) || tLower.contains(rt) { s += 220 }
+ let ra = r.artistName.lowercased()
+ if ra == aLower { s += 400 }
+ else if ra.contains(aLower) || aLower.contains(ra) { s += 180 }
+ if let al = r.albumName?.lowercased(), al == albLower { s += 150 }
+ return s
+ }
+
+ return records.max(by: { score($0) < score($1) })
+ }
+
+ private static func cleanTitle(_ raw: String) -> String {
+ var t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ let cutMarkers = [
+ " (feat.", " (featuring ", " (ft.", " (with ", " [feat.", " feat.",
+ ]
+ for m in cutMarkers {
+ if let r = t.range(of: m, options: .caseInsensitive) {
+ t = String(t[.. String {
+ var a = raw.trimmingCharacters(in: .whitespacesAndNewlines)
+ let titleHadFeat =
+ title.range(of: "feat.", options: .caseInsensitive) != nil
+ || title.range(of: "featuring", options: .caseInsensitive) != nil
+ || title.range(of: "(ft.", options: .caseInsensitive) != nil
+ if titleHadFeat, let comma = a.firstIndex(of: ",") {
+ let head = a[.. [SyncedLyricLine] {
+ var result: [SyncedLyricLine] = []
+ var idCounter = 0
+ for raw in syncedLRC.components(separatedBy: .newlines) {
+ var remainder = raw
+ var firstStart: Int?
+ while true {
+ let ns = remainder.startIndex == remainder.endIndex
+ ? NSRange(location: 0, length: 0)
+ : NSRange(remainder.startIndex.. Int {
+ let m = Int(minutes) ?? 0
+ let s = Int(seconds) ?? 0
+ var frac = 0.0
+ if let f = fraction, !f.isEmpty, let v = Double(f) {
+ if f.count >= 3 {
+ frac = v / 1000.0
+ } else {
+ frac = v / 100.0
+ }
+ }
+ let totalSec = Double(m * 60 + s) + frac
+ return Int((totalSec * 1000.0).rounded())
+ }
+}
diff --git a/Lightify/Views/GeniusLyricsViews.swift b/Lightify/Views/GeniusLyricsViews.swift
index 3a126cd..cc7cdd6 100644
--- a/Lightify/Views/GeniusLyricsViews.swift
+++ b/Lightify/Views/GeniusLyricsViews.swift
@@ -8,7 +8,7 @@ import SwiftUI
// MARK: - Formatting (bracket labels bold, hide round parens)
enum LyricsDisplayFormat {
- /// Square brackets: Genius section tags (`[Verse 1]`, `[Chorus]`, …) keep visible `[]` with semibold inner; other `[…]` notes stay semibold inner only. Plain segments: strip `(...)`.
+ /// Square brackets: section tags (`[Verse 1]`, `[Chorus]`, …) keep visible `[]` with semibold inner; other `[…]` notes stay semibold inner only. Plain segments: strip `(...)`.
static func attributedLine(_ line: String) -> AttributedString {
var result = AttributedString()
var rest = line[...]
@@ -110,7 +110,7 @@ enum LyricsDisplayFormat {
return result
}
- /// Matches Genius-style structural labels so we keep `[` `]` visible (they are not “parsed away”).
+ /// Matches structural labels so we keep `[` `]` visible (they are not “parsed away”).
private static func isGeniusSectionBracketInner(_ inner: String) -> Bool {
let t = inner.trimmingCharacters(in: .whitespacesAndNewlines)
guard !t.isEmpty else { return false }
@@ -169,10 +169,27 @@ private struct LyricsLineMetricsKey: PreferenceKey {
}
}
-// MARK: - Line-by-line (now playing expanded)
+// MARK: - LRCLIB attribution
-/// One row per lyric line with center-weighted emphasis to mimic the large, focused lyric wall. Scroll is manual; Genius text has no per-line timestamps.
-struct GeniusLyricsLineByLineView: View {
+private struct LRCLIBAttributionChip: View {
+ var body: some View {
+ Text("Lyrics from LRCLIB")
+ .font(.system(size: 11, weight: .medium, design: .rounded))
+ .foregroundStyle(.primary)
+ .multilineTextAlignment(.center)
+ .padding(.horizontal, 14)
+ .padding(.vertical, 7)
+ .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
+ .padding(.horizontal, 20)
+ .padding(.bottom, 10)
+ .allowsHitTesting(false)
+ }
+}
+
+// MARK: - Line-by-line (plain)
+
+/// One row per lyric line with center-weighted emphasis. Scroll is manual when timestamps are unavailable.
+struct PlainLyricsLineByLineView: View {
let lyrics: String
@State private var viewportMidY: CGFloat = 0
@@ -225,7 +242,7 @@ struct GeniusLyricsLineByLineView: View {
.onPreferenceChange(LyricsLineMetricsKey.self) { metrics = $0 }
.background(Color.clear)
- geniusAttributionFootnote
+ LRCLIBAttributionChip()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.clear)
@@ -234,20 +251,6 @@ struct GeniusLyricsLineByLineView: View {
.background(Color.clear)
}
- /// Liquid Glass chip via `glassEffect` / `regular`; non-interactive so the scroll view still receives drags.
- private var geniusAttributionFootnote: some View {
- Text("Lyrics provided by Genius")
- .font(.system(size: 11, weight: .medium, design: .rounded))
- .foregroundStyle(.primary)
- .multilineTextAlignment(.center)
- .padding(.horizontal, 14)
- .padding(.vertical, 7)
- .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
- .padding(.horizontal, 20)
- .padding(.bottom, 10)
- .allowsHitTesting(false)
- }
-
private func lyricLine(_ line: String, index: Int) -> some View {
let emphasis = emphasisForLine(at: index)
return Text(LyricsDisplayFormat.attributedLine(line))
@@ -287,18 +290,182 @@ struct GeniusLyricsLineByLineView: View {
}
}
+// MARK: - Time-synced (LRCLIB LRC)
+
+struct SyncedLyricsScrollView: View {
+ let lines: [SyncedLyricLine]
+ let positionMs: Int
+ let isPlaying: Bool
+
+ private static let scrollSpring = Animation.spring(duration: 0.52, bounce: 0.22)
+
+ private var activeIndex: Int {
+ Self.activeLineIndex(lines: lines, positionMs: positionMs)
+ }
+
+ private static func activeLineIndex(lines: [SyncedLyricLine], positionMs: Int) -> Int {
+ guard !lines.isEmpty else { return 0 }
+ var low = 0
+ var high = lines.count - 1
+ var best = 0
+ while low <= high {
+ let mid = (low + high) / 2
+ if lines[mid].startMs <= positionMs {
+ best = mid
+ low = mid + 1
+ } else {
+ high = mid - 1
+ }
+ }
+ return best
+ }
+
+ var body: some View {
+ GeometryReader { proxy in
+ ZStack(alignment: .bottom) {
+ ScrollViewReader { scrollProxy in
+ ScrollView(.vertical, showsIndicators: false) {
+ LazyVStack(alignment: .leading, spacing: 20) {
+ ForEach(Array(lines.enumerated()), id: \.element.id) { idx, line in
+ SyncedLyricLineView(
+ line: line,
+ rankDistance: abs(idx - activeIndex),
+ isCurrent: idx == activeIndex,
+ isPlaying: isPlaying
+ )
+ .id(line.id)
+ }
+ }
+ .padding(.horizontal, 32)
+ .padding(.vertical, max(proxy.size.height * 0.28, 100))
+ }
+ .coordinateSpace(name: "SyncedLyricsSpace")
+ .mask {
+ LinearGradient(
+ stops: [
+ .init(color: .clear, location: 0),
+ .init(color: .white.opacity(0.7), location: 0.1),
+ .init(color: .white, location: 0.32),
+ .init(color: .white, location: 0.68),
+ .init(color: .white.opacity(0.7), location: 0.9),
+ .init(color: .clear, location: 1)
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ }
+ .onChange(of: activeIndex) { _, newIdx in
+ guard lines.indices.contains(newIdx) else { return }
+ withAnimation(Self.scrollSpring) {
+ scrollProxy.scrollTo(lines[newIdx].id, anchor: UnitPoint(x: 0.5, y: 0.34))
+ }
+ }
+ .onAppear {
+ let idx = activeIndex
+ guard lines.indices.contains(idx) else { return }
+ DispatchQueue.main.async {
+ scrollProxy.scrollTo(lines[idx].id, anchor: UnitPoint(x: 0.5, y: 0.34))
+ }
+ }
+ }
+
+ LRCLIBAttributionChip()
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
+private struct SyncedLyricLineView: View {
+ let line: SyncedLyricLine
+ let rankDistance: Int
+ let isCurrent: Bool
+ let isPlaying: Bool
+
+ private var depthFade: CGFloat {
+ let d = CGFloat(min(rankDistance, 12))
+ return pow(max(0, 1 - d * 0.085), 1.15)
+ }
+
+ private var attributed: AttributedString {
+ LyricsDisplayFormat.attributedLine(line.text)
+ }
+
+ var body: some View {
+ let fontSize: CGFloat = isCurrent ? 22 : (14.5 + depthFade * 2.8)
+ let opacity: CGFloat = isCurrent ? 1 : (0.26 + 0.48 * depthFade)
+ let scale: CGFloat = isCurrent ? 1.045 : (0.965 + 0.035 * depthFade)
+
+ HStack(alignment: .firstTextBaseline, spacing: 14) {
+ Capsule()
+ .fill(Color.white.opacity(isCurrent ? 0.95 : (0.12 + 0.14 * depthFade)))
+ .frame(width: isCurrent ? 5 : 2.5, height: isCurrent ? 28 : max(9, 11 * depthFade))
+ .shadow(color: .white.opacity(isCurrent ? (isPlaying ? 0.5 : 0.28) : 0), radius: isCurrent ? 16 : 0, y: 0)
+ .animation(.spring(duration: 0.38, bounce: 0.2), value: isCurrent)
+
+ ZStack(alignment: .leading) {
+ if isCurrent {
+ Text(attributed)
+ .font(.system(size: fontSize, weight: .bold, design: .rounded))
+ .foregroundStyle(.white.opacity(0.35))
+ .blur(radius: 16)
+ .offset(x: 0, y: 1)
+ }
+
+ Text(attributed)
+ .font(.system(size: fontSize, weight: isCurrent ? .bold : .semibold, design: .rounded))
+ .foregroundStyle(Color.white.opacity(Double(opacity)))
+ .overlay {
+ if isCurrent {
+ TimelineView(.animation(minimumInterval: .milliseconds(isPlaying ? 22 : 500), paused: !isPlaying)) { ctx in
+ let t = ctx.date.timeIntervalSinceReferenceDate
+ let sweep = (sin(t * 2.05) + 1) * 0.5
+ LinearGradient(
+ stops: [
+ .init(color: .clear, location: 0),
+ .init(color: .white.opacity(0.18 + sweep * 0.14), location: 0.35 + sweep * 0.12),
+ .init(color: .clear, location: 1)
+ ],
+ startPoint: UnitPoint(x: -0.45 + sweep * 0.35, y: 0.5),
+ endPoint: UnitPoint(x: 0.55 + sweep * 0.45, y: 0.5)
+ )
+ .blendMode(.plusLighter)
+ .mask(
+ Text(attributed)
+ .font(.system(size: fontSize, weight: .bold, design: .rounded))
+ )
+ }
+ }
+ }
+ }
+ .multilineTextAlignment(.leading)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .textSelection(.enabled)
+ .fixedSize(horizontal: false, vertical: true)
+ .scaleEffect(x: scale, y: scale, anchor: .leading)
+ }
+ .animation(.spring(duration: 0.4, bounce: 0.16), value: isCurrent)
+ .animation(.spring(duration: 0.36, bounce: 0.14), value: rankDistance)
+ }
+}
+
// MARK: - Fetch + load (mini player)
struct MiniPlayerLyricsPanel: View {
let trackName: String
let artistName: String
+ let albumName: String?
+ let durationMs: Int
+ let positionMs: Int
+ let isPlaying: Bool
@State private var loadState: LoadState = .idle
private enum LoadState: Equatable {
case idle
case loading
- case loaded(String)
+ case loaded(LRCLIBFetchedLyrics)
case failed(String)
}
@@ -310,8 +477,8 @@ struct MiniPlayerLyricsPanel: View {
.tint(.white.opacity(0.9))
.foregroundStyle(.white.opacity(0.8))
.frame(maxWidth: .infinity, maxHeight: .infinity)
- case .loaded(let text):
- GeniusLyricsLineByLineView(lyrics: text)
+ case .loaded(let payload):
+ lyricsBody(for: payload)
case .failed(let message):
Text(message)
.font(.body)
@@ -321,16 +488,46 @@ struct MiniPlayerLyricsPanel: View {
.padding(28)
}
}
- .task(id: "\(trackName)|\(artistName)") {
+ .task(id: "\(trackName)|\(artistName)|\(albumName ?? "")|\(durationMs)") {
await fetchLyrics()
}
}
+ @ViewBuilder
+ private func lyricsBody(for payload: LRCLIBFetchedLyrics) -> some View {
+ let hasSynced = (payload.syncedLines?.isEmpty == false)
+ let hasPlain = !payload.plainText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+
+ if payload.instrumental && !hasSynced && !hasPlain {
+ Text("Instrumental")
+ .font(.system(size: 20, weight: .semibold, design: .rounded))
+ .foregroundStyle(.white.opacity(0.75))
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .overlay(alignment: .bottom) {
+ LRCLIBAttributionChip()
+ }
+ } else if hasSynced, let synced = payload.syncedLines {
+ SyncedLyricsScrollView(lines: synced, positionMs: positionMs, isPlaying: isPlaying)
+ } else if hasPlain {
+ PlainLyricsLineByLineView(lyrics: payload.plainText)
+ } else {
+ Text("No lyric lines for this track.")
+ .font(.body)
+ .foregroundStyle(.white.opacity(0.72))
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+
private func fetchLyrics() async {
loadState = .loading
do {
- let text = try await GeniusLyricsService().fetchLyrics(title: trackName, artist: artistName)
- loadState = .loaded(text)
+ let payload = try await LRCLIBLyricsService().fetchLyrics(
+ trackName: trackName,
+ artistName: artistName,
+ albumName: albumName,
+ durationMs: durationMs
+ )
+ loadState = .loaded(payload)
} catch {
let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
loadState = .failed(message)
diff --git a/Lightify/Views/MiniPlayerWindowView.swift b/Lightify/Views/MiniPlayerWindowView.swift
index 4434b58..1df6f5d 100644
--- a/Lightify/Views/MiniPlayerWindowView.swift
+++ b/Lightify/Views/MiniPlayerWindowView.swift
@@ -106,7 +106,7 @@ struct MiniPlayerWindowView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
- /// Wide layout: controls + artwork on the left, fetched lyrics (line-by-line) on the right.
+ /// Wide layout: controls + artwork on the left, LRCLIB lyrics (synced when available) on the right.
private var expandedPlayerWithLyricsLayout: some View {
HStack(alignment: .center, spacing: 44) {
VStack(spacing: 14) {
@@ -132,7 +132,11 @@ struct MiniPlayerWindowView: View {
if let np = playback.nowPlaying {
MiniPlayerLyricsPanel(
trackName: np.trackName,
- artistName: np.artistName
+ artistName: np.artistName,
+ albumName: np.albumName,
+ durationMs: np.durationMs,
+ positionMs: np.positionMs,
+ isPlaying: np.isPlaying
)
} else {
Text("Nothing playing")
@@ -453,7 +457,7 @@ struct MiniPlayerWindowView: View {
}
}
-// MARK: - Control chrome (pop-out player only; Genius attribution in lyrics keeps system glass)
+// MARK: - Control chrome (pop-out player only; LRCLIB attribution chip in lyrics keeps system glass)
/// Control chrome: light-on-dark for almost all artwork; dark-on-light only when the sampled average is
/// near-white (very high luminance), plus when there is no sample (window background reads light).
From efb25e33329a7bed33716f2a63d797d63c4d7a2c Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 10 May 2026 12:14:29 +0000
Subject: [PATCH 2/4] Fix TimelineView schedule and remove redundant awaits in
ArtworkPipeline
- Use periodic TimelineView with TimeInterval instead of animation +
Duration.milliseconds, which failed to compile on the current SDK.
- Drop await on synchronous actor cache writes (fixes redundant-await warnings).
Co-authored-by: coolbanjo
---
Lightify/Utilities/ArtworkPipeline.swift | 4 ++--
Lightify/Views/GeniusLyricsViews.swift | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/Lightify/Utilities/ArtworkPipeline.swift b/Lightify/Utilities/ArtworkPipeline.swift
index ac5952f..a290af3 100644
--- a/Lightify/Utilities/ArtworkPipeline.swift
+++ b/Lightify/Utilities/ArtworkPipeline.swift
@@ -48,7 +48,7 @@ actor ArtworkPipeline {
throw CocoaError(.coderInvalidValue)
}
let cost = max(1, normalizedSize * normalizedSize * 4)
- await self.storeImage(image, for: key, cost: cost)
+ self.storeImage(image, for: key, cost: cost)
return image
}
@@ -71,7 +71,7 @@ actor ArtworkPipeline {
guard let http = response as? HTTPURLResponse, (200 ..< 300).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
- await self.storeData(data, for: key)
+ self.storeData(data, for: key)
return data
}
diff --git a/Lightify/Views/GeniusLyricsViews.swift b/Lightify/Views/GeniusLyricsViews.swift
index cc7cdd6..bb52351 100644
--- a/Lightify/Views/GeniusLyricsViews.swift
+++ b/Lightify/Views/GeniusLyricsViews.swift
@@ -418,7 +418,7 @@ private struct SyncedLyricLineView: View {
.foregroundStyle(Color.white.opacity(Double(opacity)))
.overlay {
if isCurrent {
- TimelineView(.animation(minimumInterval: .milliseconds(isPlaying ? 22 : 500), paused: !isPlaying)) { ctx in
+ TimelineView(.periodic(from: Date(), by: isPlaying ? 0.022 : 0.55)) { ctx in
let t = ctx.date.timeIntervalSinceReferenceDate
let sweep = (sin(t * 2.05) + 1) * 0.5
LinearGradient(
From 71bba165eafb7c939fc86559fd938c7374512792 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 10 May 2026 12:22:41 +0000
Subject: [PATCH 3/4] Minimal lyrics UI: default font, blur inactive lines,
dots when empty
- Synced view drops capsule, glow, shimmer, and LRCLIB chip; active line is
sharp semibold default design, others use blur + lower opacity.
- Plain line-by-line view uses default design, similar blur for off-center lines,
no attribution chip.
- LRCLIB miss, fetch failure, or empty payload shows centered "..." placeholder;
loading uses a small indeterminate progress only.
- Remove unused isPlaying from the lyrics panel.
Co-authored-by: coolbanjo
---
Lightify/Views/GeniusLyricsViews.swift | 293 +++++++++-------------
Lightify/Views/MiniPlayerWindowView.swift | 3 +-
2 files changed, 114 insertions(+), 182 deletions(-)
diff --git a/Lightify/Views/GeniusLyricsViews.swift b/Lightify/Views/GeniusLyricsViews.swift
index bb52351..57be612 100644
--- a/Lightify/Views/GeniusLyricsViews.swift
+++ b/Lightify/Views/GeniusLyricsViews.swift
@@ -169,20 +169,15 @@ private struct LyricsLineMetricsKey: PreferenceKey {
}
}
-// MARK: - LRCLIB attribution
+// MARK: - Minimal placeholders
-private struct LRCLIBAttributionChip: View {
+private struct NoLyricsPlaceholder: View {
var body: some View {
- Text("Lyrics from LRCLIB")
- .font(.system(size: 11, weight: .medium, design: .rounded))
- .foregroundStyle(.primary)
- .multilineTextAlignment(.center)
- .padding(.horizontal, 14)
- .padding(.vertical, 7)
- .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 18, style: .continuous))
- .padding(.horizontal, 20)
- .padding(.bottom, 10)
- .allowsHitTesting(false)
+ Text("...")
+ .font(.system(size: 32, weight: .regular, design: .default))
+ .foregroundStyle(.white.opacity(0.45))
+ .tracking(2)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
@@ -204,65 +199,60 @@ struct PlainLyricsLineByLineView: View {
var body: some View {
GeometryReader { proxy in
- ZStack(alignment: .bottom) {
- ScrollView(.vertical, showsIndicators: false) {
- VStack(alignment: .leading, spacing: 22) {
- ForEach(Array(lines.enumerated()), id: \.offset) { index, line in
- lyricLine(line, index: index)
- }
- }
- .padding(.horizontal, 36)
- .padding(.vertical, max(proxy.size.height * 0.3, 120))
- }
- .coordinateSpace(name: "LyricsScrollSpace")
- .background {
- GeometryReader { scrollProxy in
- Color.clear
- .preference(
- key: LyricsViewportMidYKey.self,
- value: scrollProxy.frame(in: .named("LyricsScrollSpace")).midY
- )
+ ScrollView(.vertical, showsIndicators: false) {
+ VStack(alignment: .leading, spacing: 26) {
+ ForEach(Array(lines.enumerated()), id: \.offset) { index, line in
+ lyricLine(line, index: index)
}
}
- .mask {
- LinearGradient(
- stops: [
- .init(color: .clear, location: 0),
- .init(color: .white.opacity(0.72), location: 0.12),
- .init(color: .white, location: 0.34),
- .init(color: .white, location: 0.66),
- .init(color: .white.opacity(0.72), location: 0.88),
- .init(color: .clear, location: 1)
- ],
- startPoint: .top,
- endPoint: .bottom
- )
+ .padding(.horizontal, 28)
+ .padding(.vertical, max(proxy.size.height * 0.3, 120))
+ }
+ .coordinateSpace(name: "LyricsScrollSpace")
+ .background {
+ GeometryReader { scrollProxy in
+ Color.clear
+ .preference(
+ key: LyricsViewportMidYKey.self,
+ value: scrollProxy.frame(in: .named("LyricsScrollSpace")).midY
+ )
}
- .onPreferenceChange(LyricsViewportMidYKey.self) { viewportMidY = $0 }
- .onPreferenceChange(LyricsLineMetricsKey.self) { metrics = $0 }
- .background(Color.clear)
-
- LRCLIBAttributionChip()
}
+ .mask {
+ LinearGradient(
+ stops: [
+ .init(color: .clear, location: 0),
+ .init(color: .white.opacity(0.85), location: 0.08),
+ .init(color: .white, location: 0.22),
+ .init(color: .white, location: 0.78),
+ .init(color: .white.opacity(0.85), location: 0.92),
+ .init(color: .clear, location: 1)
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ }
+ .onPreferenceChange(LyricsViewportMidYKey.self) { viewportMidY = $0 }
+ .onPreferenceChange(LyricsLineMetricsKey.self) { metrics = $0 }
.frame(maxWidth: .infinity, maxHeight: .infinity)
- .background(Color.clear)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
- .background(Color.clear)
}
private func lyricLine(_ line: String, index: Int) -> some View {
let emphasis = emphasisForLine(at: index)
+ let fontSize: CGFloat = 17 + (emphasis * 5)
+ let blurRadius: CGFloat = emphasis > 0.72 ? 0 : min(14, 3.5 + (1 - emphasis) * 16)
+ let opacity: CGFloat = 0.22 + (emphasis * 0.78)
return Text(LyricsDisplayFormat.attributedLine(line))
- .font(.system(size: 19 + (emphasis * 9), weight: emphasis > 0.78 ? .bold : .semibold, design: .rounded))
- .foregroundStyle(.white.opacity(0.18 + (emphasis * 0.82)))
+ .font(.system(size: fontSize, weight: emphasis > 0.75 ? .semibold : .regular, design: .default))
+ .foregroundStyle(.white.opacity(Double(opacity)))
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
- .scaleEffect(0.97 + (emphasis * 0.05), anchor: .leading)
- .blur(radius: emphasis < 0.18 ? 0.6 : 0)
- .animation(.smooth(duration: 0.16), value: emphasis)
+ .blur(radius: blurRadius)
+ .animation(.smooth(duration: 0.18), value: emphasis)
.background {
GeometryReader { lineProxy in
Color.clear.preference(
@@ -295,7 +285,6 @@ struct PlainLyricsLineByLineView: View {
struct SyncedLyricsScrollView: View {
let lines: [SyncedLyricLine]
let positionMs: Int
- let isPlaying: Bool
private static let scrollSpring = Animation.spring(duration: 0.52, bounce: 0.22)
@@ -322,54 +311,48 @@ struct SyncedLyricsScrollView: View {
var body: some View {
GeometryReader { proxy in
- ZStack(alignment: .bottom) {
- ScrollViewReader { scrollProxy in
- ScrollView(.vertical, showsIndicators: false) {
- LazyVStack(alignment: .leading, spacing: 20) {
- ForEach(Array(lines.enumerated()), id: \.element.id) { idx, line in
- SyncedLyricLineView(
- line: line,
- rankDistance: abs(idx - activeIndex),
- isCurrent: idx == activeIndex,
- isPlaying: isPlaying
- )
- .id(line.id)
- }
+ ScrollViewReader { scrollProxy in
+ ScrollView(.vertical, showsIndicators: false) {
+ LazyVStack(alignment: .leading, spacing: 26) {
+ ForEach(Array(lines.enumerated()), id: \.element.id) { idx, line in
+ SyncedLyricLineView(
+ line: line,
+ rankDistance: abs(idx - activeIndex),
+ isCurrent: idx == activeIndex
+ )
+ .id(line.id)
}
- .padding(.horizontal, 32)
- .padding(.vertical, max(proxy.size.height * 0.28, 100))
- }
- .coordinateSpace(name: "SyncedLyricsSpace")
- .mask {
- LinearGradient(
- stops: [
- .init(color: .clear, location: 0),
- .init(color: .white.opacity(0.7), location: 0.1),
- .init(color: .white, location: 0.32),
- .init(color: .white, location: 0.68),
- .init(color: .white.opacity(0.7), location: 0.9),
- .init(color: .clear, location: 1)
- ],
- startPoint: .top,
- endPoint: .bottom
- )
}
- .onChange(of: activeIndex) { _, newIdx in
- guard lines.indices.contains(newIdx) else { return }
- withAnimation(Self.scrollSpring) {
- scrollProxy.scrollTo(lines[newIdx].id, anchor: UnitPoint(x: 0.5, y: 0.34))
- }
+ .padding(.horizontal, 28)
+ .padding(.vertical, max(proxy.size.height * 0.28, 100))
+ }
+ .mask {
+ LinearGradient(
+ stops: [
+ .init(color: .clear, location: 0),
+ .init(color: .white.opacity(0.88), location: 0.08),
+ .init(color: .white, location: 0.2),
+ .init(color: .white, location: 0.8),
+ .init(color: .white.opacity(0.88), location: 0.92),
+ .init(color: .clear, location: 1)
+ ],
+ startPoint: .top,
+ endPoint: .bottom
+ )
+ }
+ .onChange(of: activeIndex) { _, newIdx in
+ guard lines.indices.contains(newIdx) else { return }
+ withAnimation(Self.scrollSpring) {
+ scrollProxy.scrollTo(lines[newIdx].id, anchor: UnitPoint(x: 0.5, y: 0.34))
}
- .onAppear {
- let idx = activeIndex
- guard lines.indices.contains(idx) else { return }
- DispatchQueue.main.async {
- scrollProxy.scrollTo(lines[idx].id, anchor: UnitPoint(x: 0.5, y: 0.34))
- }
+ }
+ .onAppear {
+ let idx = activeIndex
+ guard lines.indices.contains(idx) else { return }
+ DispatchQueue.main.async {
+ scrollProxy.scrollTo(lines[idx].id, anchor: UnitPoint(x: 0.5, y: 0.34))
}
}
-
- LRCLIBAttributionChip()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@@ -381,72 +364,38 @@ private struct SyncedLyricLineView: View {
let line: SyncedLyricLine
let rankDistance: Int
let isCurrent: Bool
- let isPlaying: Bool
- private var depthFade: CGFloat {
- let d = CGFloat(min(rankDistance, 12))
- return pow(max(0, 1 - d * 0.085), 1.15)
+ private var displayText: String {
+ LyricsDisplayFormat.stripRoundParentheticals(line.text)
}
- private var attributed: AttributedString {
- LyricsDisplayFormat.attributedLine(line.text)
+ /// Softer lines further from the active lyric (reference-style depth).
+ private var inactiveBlur: CGFloat {
+ guard !isCurrent else { return 0 }
+ let d = CGFloat(min(rankDistance, 10))
+ return min(11, 4 + d * 0.85)
+ }
+
+ private var inactiveOpacity: CGFloat {
+ guard !isCurrent else { return 1 }
+ let d = CGFloat(min(rankDistance, 8))
+ return max(0.28, 0.72 - d * 0.055)
}
var body: some View {
- let fontSize: CGFloat = isCurrent ? 22 : (14.5 + depthFade * 2.8)
- let opacity: CGFloat = isCurrent ? 1 : (0.26 + 0.48 * depthFade)
- let scale: CGFloat = isCurrent ? 1.045 : (0.965 + 0.035 * depthFade)
-
- HStack(alignment: .firstTextBaseline, spacing: 14) {
- Capsule()
- .fill(Color.white.opacity(isCurrent ? 0.95 : (0.12 + 0.14 * depthFade)))
- .frame(width: isCurrent ? 5 : 2.5, height: isCurrent ? 28 : max(9, 11 * depthFade))
- .shadow(color: .white.opacity(isCurrent ? (isPlaying ? 0.5 : 0.28) : 0), radius: isCurrent ? 16 : 0, y: 0)
- .animation(.spring(duration: 0.38, bounce: 0.2), value: isCurrent)
-
- ZStack(alignment: .leading) {
- if isCurrent {
- Text(attributed)
- .font(.system(size: fontSize, weight: .bold, design: .rounded))
- .foregroundStyle(.white.opacity(0.35))
- .blur(radius: 16)
- .offset(x: 0, y: 1)
- }
+ let fontSize: CGFloat = isCurrent ? 23 : 17
+ let weight: Font.Weight = isCurrent ? .semibold : .regular
- Text(attributed)
- .font(.system(size: fontSize, weight: isCurrent ? .bold : .semibold, design: .rounded))
- .foregroundStyle(Color.white.opacity(Double(opacity)))
- .overlay {
- if isCurrent {
- TimelineView(.periodic(from: Date(), by: isPlaying ? 0.022 : 0.55)) { ctx in
- let t = ctx.date.timeIntervalSinceReferenceDate
- let sweep = (sin(t * 2.05) + 1) * 0.5
- LinearGradient(
- stops: [
- .init(color: .clear, location: 0),
- .init(color: .white.opacity(0.18 + sweep * 0.14), location: 0.35 + sweep * 0.12),
- .init(color: .clear, location: 1)
- ],
- startPoint: UnitPoint(x: -0.45 + sweep * 0.35, y: 0.5),
- endPoint: UnitPoint(x: 0.55 + sweep * 0.45, y: 0.5)
- )
- .blendMode(.plusLighter)
- .mask(
- Text(attributed)
- .font(.system(size: fontSize, weight: .bold, design: .rounded))
- )
- }
- }
- }
- }
+ Text(displayText)
+ .font(.system(size: fontSize, weight: weight, design: .default))
+ .foregroundStyle(Color.white.opacity(Double(isCurrent ? 1 : inactiveOpacity)))
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
- .scaleEffect(x: scale, y: scale, anchor: .leading)
- }
- .animation(.spring(duration: 0.4, bounce: 0.16), value: isCurrent)
- .animation(.spring(duration: 0.36, bounce: 0.14), value: rankDistance)
+ .blur(radius: inactiveBlur)
+ .animation(.smooth(duration: 0.2), value: isCurrent)
+ .animation(.smooth(duration: 0.2), value: rankDistance)
}
}
@@ -458,7 +407,6 @@ struct MiniPlayerLyricsPanel: View {
let albumName: String?
let durationMs: Int
let positionMs: Int
- let isPlaying: Bool
@State private var loadState: LoadState = .idle
@@ -466,26 +414,21 @@ struct MiniPlayerLyricsPanel: View {
case idle
case loading
case loaded(LRCLIBFetchedLyrics)
- case failed(String)
+ case failed
}
var body: some View {
Group {
switch loadState {
case .idle, .loading:
- ProgressView("Loading lyrics…")
- .tint(.white.opacity(0.9))
- .foregroundStyle(.white.opacity(0.8))
+ ProgressView()
+ .controlSize(.regular)
+ .tint(.white.opacity(0.35))
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .loaded(let payload):
lyricsBody(for: payload)
- case .failed(let message):
- Text(message)
- .font(.body)
- .foregroundStyle(.white.opacity(0.72))
- .multilineTextAlignment(.center)
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .padding(28)
+ case .failed:
+ NoLyricsPlaceholder()
}
}
.task(id: "\(trackName)|\(artistName)|\(albumName ?? "")|\(durationMs)") {
@@ -499,22 +442,13 @@ struct MiniPlayerLyricsPanel: View {
let hasPlain = !payload.plainText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
if payload.instrumental && !hasSynced && !hasPlain {
- Text("Instrumental")
- .font(.system(size: 20, weight: .semibold, design: .rounded))
- .foregroundStyle(.white.opacity(0.75))
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- .overlay(alignment: .bottom) {
- LRCLIBAttributionChip()
- }
+ NoLyricsPlaceholder()
} else if hasSynced, let synced = payload.syncedLines {
- SyncedLyricsScrollView(lines: synced, positionMs: positionMs, isPlaying: isPlaying)
+ SyncedLyricsScrollView(lines: synced, positionMs: positionMs)
} else if hasPlain {
PlainLyricsLineByLineView(lyrics: payload.plainText)
} else {
- Text("No lyric lines for this track.")
- .font(.body)
- .foregroundStyle(.white.opacity(0.72))
- .frame(maxWidth: .infinity, maxHeight: .infinity)
+ NoLyricsPlaceholder()
}
}
@@ -529,8 +463,7 @@ struct MiniPlayerLyricsPanel: View {
)
loadState = .loaded(payload)
} catch {
- let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription
- loadState = .failed(message)
+ loadState = .failed
}
}
}
diff --git a/Lightify/Views/MiniPlayerWindowView.swift b/Lightify/Views/MiniPlayerWindowView.swift
index 1df6f5d..d1d0209 100644
--- a/Lightify/Views/MiniPlayerWindowView.swift
+++ b/Lightify/Views/MiniPlayerWindowView.swift
@@ -135,8 +135,7 @@ struct MiniPlayerWindowView: View {
artistName: np.artistName,
albumName: np.albumName,
durationMs: np.durationMs,
- positionMs: np.positionMs,
- isPlaying: np.isPlaying
+ positionMs: np.positionMs
)
} else {
Text("Nothing playing")
From 1198d6740290c46da234ce11f8dc45cc8bc1566d Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 10 May 2026 12:26:51 +0000
Subject: [PATCH 4/4] Lyrics: larger type, bouncy springs, lighter inactive
blur
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Bump synced sizes to 30pt current / 21pt others; plain lines 22–30pt by emphasis.
- Replace smooth curves with high-bounce springs for scroll, line emphasis, and
synced scale; initial scroll uses the same spring.
- Reduce off-focus blur (synced max ~4.2, plain max ~5.5) and slightly raise
inactive opacity for readability.
Co-authored-by: coolbanjo
---
Lightify/Views/GeniusLyricsViews.swift | 34 ++++++++++++++++----------
1 file changed, 21 insertions(+), 13 deletions(-)
diff --git a/Lightify/Views/GeniusLyricsViews.swift b/Lightify/Views/GeniusLyricsViews.swift
index 57be612..1cb1d83 100644
--- a/Lightify/Views/GeniusLyricsViews.swift
+++ b/Lightify/Views/GeniusLyricsViews.swift
@@ -200,7 +200,7 @@ struct PlainLyricsLineByLineView: View {
var body: some View {
GeometryReader { proxy in
ScrollView(.vertical, showsIndicators: false) {
- VStack(alignment: .leading, spacing: 26) {
+ VStack(alignment: .leading, spacing: 30) {
ForEach(Array(lines.enumerated()), id: \.offset) { index, line in
lyricLine(line, index: index)
}
@@ -239,10 +239,12 @@ struct PlainLyricsLineByLineView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
+ private static let emphasisSpring = Animation.spring(duration: 0.46, bounce: 0.4)
+
private func lyricLine(_ line: String, index: Int) -> some View {
let emphasis = emphasisForLine(at: index)
- let fontSize: CGFloat = 17 + (emphasis * 5)
- let blurRadius: CGFloat = emphasis > 0.72 ? 0 : min(14, 3.5 + (1 - emphasis) * 16)
+ let fontSize: CGFloat = 22 + (emphasis * 8)
+ let blurRadius: CGFloat = emphasis > 0.72 ? 0 : min(5.5, 0.9 + (1 - emphasis) * 6.5)
let opacity: CGFloat = 0.22 + (emphasis * 0.78)
return Text(LyricsDisplayFormat.attributedLine(line))
.font(.system(size: fontSize, weight: emphasis > 0.75 ? .semibold : .regular, design: .default))
@@ -252,7 +254,8 @@ struct PlainLyricsLineByLineView: View {
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.blur(radius: blurRadius)
- .animation(.smooth(duration: 0.18), value: emphasis)
+ .scaleEffect(0.97 + (emphasis * 0.05), anchor: .leading)
+ .animation(Self.emphasisSpring, value: emphasis)
.background {
GeometryReader { lineProxy in
Color.clear.preference(
@@ -286,7 +289,7 @@ struct SyncedLyricsScrollView: View {
let lines: [SyncedLyricLine]
let positionMs: Int
- private static let scrollSpring = Animation.spring(duration: 0.52, bounce: 0.22)
+ private static let scrollSpring = Animation.spring(duration: 0.5, bounce: 0.42)
private var activeIndex: Int {
Self.activeLineIndex(lines: lines, positionMs: positionMs)
@@ -313,7 +316,7 @@ struct SyncedLyricsScrollView: View {
GeometryReader { proxy in
ScrollViewReader { scrollProxy in
ScrollView(.vertical, showsIndicators: false) {
- LazyVStack(alignment: .leading, spacing: 26) {
+ LazyVStack(alignment: .leading, spacing: 30) {
ForEach(Array(lines.enumerated()), id: \.element.id) { idx, line in
SyncedLyricLineView(
line: line,
@@ -350,7 +353,9 @@ struct SyncedLyricsScrollView: View {
let idx = activeIndex
guard lines.indices.contains(idx) else { return }
DispatchQueue.main.async {
- scrollProxy.scrollTo(lines[idx].id, anchor: UnitPoint(x: 0.5, y: 0.34))
+ withAnimation(Self.scrollSpring) {
+ scrollProxy.scrollTo(lines[idx].id, anchor: UnitPoint(x: 0.5, y: 0.34))
+ }
}
}
}
@@ -365,25 +370,27 @@ private struct SyncedLyricLineView: View {
let rankDistance: Int
let isCurrent: Bool
+ private static let lineSpring = Animation.spring(duration: 0.48, bounce: 0.44)
+
private var displayText: String {
LyricsDisplayFormat.stripRoundParentheticals(line.text)
}
- /// Softer lines further from the active lyric (reference-style depth).
+ /// Light depth: inactive lines stay readable with only a hint of blur.
private var inactiveBlur: CGFloat {
guard !isCurrent else { return 0 }
let d = CGFloat(min(rankDistance, 10))
- return min(11, 4 + d * 0.85)
+ return min(4.2, 0.65 + d * 0.32)
}
private var inactiveOpacity: CGFloat {
guard !isCurrent else { return 1 }
let d = CGFloat(min(rankDistance, 8))
- return max(0.28, 0.72 - d * 0.055)
+ return max(0.32, 0.78 - d * 0.05)
}
var body: some View {
- let fontSize: CGFloat = isCurrent ? 23 : 17
+ let fontSize: CGFloat = isCurrent ? 30 : 21
let weight: Font.Weight = isCurrent ? .semibold : .regular
Text(displayText)
@@ -394,8 +401,9 @@ private struct SyncedLyricLineView: View {
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.blur(radius: inactiveBlur)
- .animation(.smooth(duration: 0.2), value: isCurrent)
- .animation(.smooth(duration: 0.2), value: rankDistance)
+ .scaleEffect(isCurrent ? 1.035 : 1, anchor: .leading)
+ .animation(Self.lineSpring, value: isCurrent)
+ .animation(Self.lineSpring, value: rankDistance)
}
}