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/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 3a126cd..1cb1d83 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,22 @@ private struct LyricsLineMetricsKey: PreferenceKey {
}
}
-// MARK: - Line-by-line (now playing expanded)
+// MARK: - Minimal placeholders
-/// 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 NoLyricsPlaceholder: View {
+ var body: some View {
+ Text("...")
+ .font(.system(size: 32, weight: .regular, design: .default))
+ .foregroundStyle(.white.opacity(0.45))
+ .tracking(2)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
+// 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
@@ -187,79 +199,63 @@ struct GeniusLyricsLineByLineView: 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: 30) {
+ 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)
-
- geniusAttributionFootnote
}
+ .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)
}
- /// 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 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 = 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: 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)
+ .blur(radius: blurRadius)
.scaleEffect(0.97 + (emphasis * 0.05), anchor: .leading)
- .blur(radius: emphasis < 0.18 ? 0.6 : 0)
- .animation(.smooth(duration: 0.16), value: emphasis)
+ .animation(Self.emphasisSpring, value: emphasis)
.background {
GeometryReader { lineProxy in
Color.clear.preference(
@@ -287,53 +283,195 @@ struct GeniusLyricsLineByLineView: View {
}
}
+// MARK: - Time-synced (LRCLIB LRC)
+
+struct SyncedLyricsScrollView: View {
+ let lines: [SyncedLyricLine]
+ let positionMs: Int
+
+ private static let scrollSpring = Animation.spring(duration: 0.5, bounce: 0.42)
+
+ 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
+ ScrollViewReader { scrollProxy in
+ ScrollView(.vertical, showsIndicators: false) {
+ LazyVStack(alignment: .leading, spacing: 30) {
+ 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, 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 {
+ withAnimation(Self.scrollSpring) {
+ scrollProxy.scrollTo(lines[idx].id, anchor: UnitPoint(x: 0.5, y: 0.34))
+ }
+ }
+ }
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+}
+
+private struct SyncedLyricLineView: View {
+ let line: SyncedLyricLine
+ 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)
+ }
+
+ /// 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(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.32, 0.78 - d * 0.05)
+ }
+
+ var body: some View {
+ let fontSize: CGFloat = isCurrent ? 30 : 21
+ let weight: Font.Weight = isCurrent ? .semibold : .regular
+
+ 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)
+ .blur(radius: inactiveBlur)
+ .scaleEffect(isCurrent ? 1.035 : 1, anchor: .leading)
+ .animation(Self.lineSpring, value: isCurrent)
+ .animation(Self.lineSpring, 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
@State private var loadState: LoadState = .idle
private enum LoadState: Equatable {
case idle
case loading
- case loaded(String)
- case failed(String)
+ case loaded(LRCLIBFetchedLyrics)
+ 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))
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- case .loaded(let text):
- GeniusLyricsLineByLineView(lyrics: text)
- case .failed(let message):
- Text(message)
- .font(.body)
- .foregroundStyle(.white.opacity(0.72))
- .multilineTextAlignment(.center)
+ ProgressView()
+ .controlSize(.regular)
+ .tint(.white.opacity(0.35))
.frame(maxWidth: .infinity, maxHeight: .infinity)
- .padding(28)
+ case .loaded(let payload):
+ lyricsBody(for: payload)
+ case .failed:
+ NoLyricsPlaceholder()
}
}
- .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 {
+ NoLyricsPlaceholder()
+ } else if hasSynced, let synced = payload.syncedLines {
+ SyncedLyricsScrollView(lines: synced, positionMs: positionMs)
+ } else if hasPlain {
+ PlainLyricsLineByLineView(lyrics: payload.plainText)
+ } else {
+ NoLyricsPlaceholder()
+ }
+ }
+
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)
+ loadState = .failed
}
}
}
diff --git a/Lightify/Views/MiniPlayerWindowView.swift b/Lightify/Views/MiniPlayerWindowView.swift
index 4434b58..d1d0209 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,10 @@ 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
)
} else {
Text("Nothing playing")
@@ -453,7 +456,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).