added lyrics feature

This commit is contained in:
delli 2025-11-15 14:21:31 +01:00
commit 13e60c5211
8 changed files with 464 additions and 47 deletions

View file

@ -7,8 +7,21 @@
import AppKit
import Combine
import Defaults
import Foundation
import SwiftUI
// MARK: - Lyric Data Structures
struct LyricLine: Identifiable, Codable {
let id = UUID()
let timestamp: TimeInterval
let text: String
init(timestamp: TimeInterval, text: String) {
self.timestamp = timestamp
self.text = text
}
}
let defaultImage: NSImage = .init(
systemSymbolName: "heart.fill",
accessibilityDescription: "Album Art"
@ -48,6 +61,15 @@ class MusicManager: ObservableObject {
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@Published var usingAppIconForArtwork: Bool = false
// MARK: - Lyrics Properties
@Published var currentLyrics: String = ""
@Published var syncedLyrics: [LyricLine] = []
@Published var showLyrics: Bool = false
@Published var currentLyricIndex: Int = -1
// Task used to periodically sync displayed lyric with playback position
private var lyricSyncTask: Task<Void, Never>?
private var artworkData: Data? = nil
private var liveStreamUnknownDurationCount: Int = 0
@ -212,13 +234,14 @@ class MusicManager: ObservableObject {
}
self.artworkData = state.artwork
if artworkChanged || state.artwork == nil {
// Update last artwork change values
self.lastArtworkTitle = state.title
self.lastArtworkArtist = state.artist
self.lastArtworkAlbum = state.album
self.lastArtworkBundleIdentifier = state.bundleIdentifier
}
// Update last artwork change values
self.lastArtworkTitle = state.title
self.lastArtworkArtist = state.artist
self.lastArtworkAlbum = state.album
self.lastArtworkBundleIdentifier = state.bundleIdentifier
// Fetch lyrics for new track whenever content changes
self.fetchLyrics()
// Only update sneak peek if there's actual content and something changed
if !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying {
@ -246,6 +269,8 @@ class MusicManager: ObservableObject {
if timeChanged {
self.elapsedTime = state.currentTime
// Update current lyric based on elapsed time
self.updateCurrentLyric(for: state.currentTime)
}
if durationChanged {
@ -270,6 +295,14 @@ class MusicManager: ObservableObject {
updateLiveStreamState(with: state)
self.timestampDate = state.lastUpdated
// Manage lyric sync task based on playback/lyrics availability
if Defaults[.enableLyrics] && !self.syncedLyrics.isEmpty {
// Ensure syncing runs while lyrics are enabled
startLyricSync()
} else {
stopLyricSync()
}
}
private func triggerFlipAnimation() {
@ -509,6 +542,204 @@ class MusicManager: ObservableObject {
}
}
}
// MARK: - Lyrics Methods
func fetchLyrics() {
guard Defaults[.enableLyrics] else { return }
// If the lyrics panel is visible already, provide immediate feedback
if showLyrics {
Task { @MainActor in
self.currentLyrics = "Loading lyrics..."
self.syncedLyrics = []
self.currentLyricIndex = -1
}
}
Task {
do {
let lyrics = try await fetchLyricsFromAPI(artist: artistName, title: songTitle)
await MainActor.run {
self.syncedLyrics = lyrics
self.currentLyricIndex = -1
if !lyrics.isEmpty {
self.currentLyrics = lyrics[0].text
} else {
self.currentLyrics = ""
}
// If lyrics are enabled, start syncing them to playback position
if Defaults[.enableLyrics] && !self.syncedLyrics.isEmpty {
self.startLyricSync()
} else if self.syncedLyrics.isEmpty {
self.stopLyricSync()
}
}
} catch {
print("Failed to fetch lyrics: \(error)")
await MainActor.run {
self.syncedLyrics = []
self.currentLyrics = ""
self.currentLyricIndex = -1
self.stopLyricSync()
}
}
}
}
private func fetchLyricsFromAPI(artist: String, title: String) async throws -> [LyricLine] {
guard !artist.isEmpty, !title.isEmpty else { return [] }
// Normalize input and percent-encode
let cleanArtist = artist.folding(options: .diacriticInsensitive, locale: .current)
let cleanTitle = title.folding(options: .diacriticInsensitive, locale: .current)
guard let encodedArtist = cleanArtist.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let encodedTitle = cleanTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
return []
}
// Use LRCLIB search endpoint which returns an array JSON with `plainLyrics` and/or `syncedLyrics`.
let urlString = "https://lrclib.net/api/search?track_name=\(encodedTitle)&artist_name=\(encodedArtist)"
guard let url = URL(string: urlString) else { return [] }
let (data, response) = try await URLSession.shared.data(from: url)
if let http = response as? HTTPURLResponse, http.statusCode == 200 {
// Try parse as array JSON (preferred)
if let jsonArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
let first = jsonArray.first {
let plain = (first["plainLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let synced = (first["syncedLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !synced.isEmpty {
return parseLRC(synced)
} else if !plain.isEmpty {
return [LyricLine(timestamp: 0, text: plain)]
} else {
return []
}
} else {
// Fallback: try to decode as UTF8 and handle as LRC or plain text
if let lrcString = String(data: data, encoding: .utf8), !lrcString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
// If it contains a syncedLyrics key in an object, try that
if let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any],
let synced = json["syncedLyrics"] as? String {
return parseLRC(synced)
}
// Otherwise treat as plain lyrics blob
return [LyricLine(timestamp: 0, text: lrcString.trimmingCharacters(in: .whitespacesAndNewlines))]
}
return []
}
} else {
return []
}
}
private func parseLRC(_ lrc: String) -> [LyricLine] {
let lines = lrc.components(separatedBy: .newlines)
var lyrics: [LyricLine] = []
// Accept patterns like [m:ss], [mm:ss], [mm:ss.xx] where centiseconds are optional
let pattern = "\\[(\\d{1,2}):(\\d{2})(?:\\.(\\d{1,2}))?\\]"
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return [] }
for line in lines {
let nsLine = line as NSString
let fullRange = NSRange(location: 0, length: nsLine.length)
if let match = regex.firstMatch(in: line, options: [], range: fullRange) {
let minRange = match.range(at: 1)
let secRange = match.range(at: 2)
let centiRange = match.range(at: 3)
let minStr = minRange.location != NSNotFound ? nsLine.substring(with: minRange) : "0"
let secStr = secRange.location != NSNotFound ? nsLine.substring(with: secRange) : "0"
let centiStr = (centiRange.location != NSNotFound) ? nsLine.substring(with: centiRange) : "0"
let minutes = Double(minStr) ?? 0
let seconds = Double(secStr) ?? 0
let centis = Double(centiStr) ?? 0
let timestamp = minutes * 60 + seconds + centis / 100.0
let textStart = match.range.location + match.range.length
if textStart <= nsLine.length {
let text = nsLine.substring(from: textStart).trimmingCharacters(in: .whitespaces)
if !text.isEmpty {
lyrics.append(LyricLine(timestamp: timestamp, text: text))
}
}
}
}
return lyrics.sorted(by: { $0.timestamp < $1.timestamp })
}
func updateCurrentLyric(for elapsedTime: TimeInterval) {
guard !syncedLyrics.isEmpty else { return }
// Find the current lyric based on elapsed time
var newIndex = -1
for (index, lyric) in syncedLyrics.enumerated() {
if elapsedTime >= lyric.timestamp {
newIndex = index
} else {
break
}
}
if newIndex != currentLyricIndex {
currentLyricIndex = newIndex
if newIndex >= 0 && newIndex < syncedLyrics.count {
currentLyrics = syncedLyrics[newIndex].text
}
}
}
// Start a background task that periodically updates the displayed lyric
private func startLyricSync() {
// If already running, keep it
if lyricSyncTask != nil { return }
lyricSyncTask = Task { [weak self] in
guard let self = self else { return }
while !Task.isCancelled {
// Compute estimated playback position and update lyric
let position = self.estimatedPlaybackPosition()
await MainActor.run {
self.updateCurrentLyric(for: position)
}
// Sleep ~300ms between updates
try? await Task.sleep(nanoseconds: 300_000_000)
}
}
}
private func stopLyricSync() {
lyricSyncTask?.cancel()
lyricSyncTask = nil
}
func toggleLyrics() {
// Toggle the UI state first so the views can react immediately.
showLyrics.toggle()
// If lyrics are requested to be shown but we don't have any yet,
// show a loading placeholder and start fetching asynchronously.
if showLyrics && syncedLyrics.isEmpty {
// Provide immediate feedback so the UI can show a loading state.
currentLyrics = "Loading lyrics..."
Task {
await fetchLyrics()
// If fetch completed but no lyrics were found, show a friendly message.
await MainActor.run {
if self.syncedLyrics.isEmpty && self.currentLyrics.isEmpty {
self.currentLyrics = "No lyrics found"
}
}
}
}
}
}
// MARK: - Album Art Flip Helper