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) } }