mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 23:24:32 +00:00
new version
This commit is contained in:
parent
cc09f17074
commit
c8a6c2b1fa
10 changed files with 1531 additions and 187 deletions
|
|
@ -38,6 +38,16 @@ struct LyricLine: Identifiable, Codable {
|
|||
}
|
||||
}
|
||||
|
||||
private struct LyricsLookupKey: Hashable {
|
||||
let title: String
|
||||
let artist: String
|
||||
let album: String
|
||||
|
||||
var isValid: Bool {
|
||||
!title.isEmpty && !artist.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
let defaultImage: NSImage = .init(
|
||||
systemSymbolName: "heart.fill",
|
||||
accessibilityDescription: "Album Art"
|
||||
|
|
@ -475,6 +485,10 @@ class MusicManager: ObservableObject {
|
|||
|
||||
// Task used to periodically sync displayed lyric with playback position
|
||||
private var lyricSyncTask: Task<Void, Never>?
|
||||
private var lyricsFetchTask: Task<Void, Never>?
|
||||
private var lyricsFetchKey: LyricsLookupKey?
|
||||
private var activeLyricsKey: LyricsLookupKey?
|
||||
private var lyricsCache: [LyricsLookupKey: [LyricLine]] = [:]
|
||||
private var explicitLookupTask: Task<Void, Never>?
|
||||
private var explicitLookupKey: String?
|
||||
|
||||
|
|
@ -514,6 +528,13 @@ class MusicManager: ObservableObject {
|
|||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
Defaults.publisher(.enableLyrics)
|
||||
.receive(on: RunLoop.main)
|
||||
.sink { [weak self] change in
|
||||
self?.handleLyricsPreferenceChange(isEnabled: change.newValue)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Observe Pear Desktop launch/terminate for auto-detection
|
||||
setupPearDesktopAutoDetection()
|
||||
|
||||
|
|
@ -582,6 +603,8 @@ class MusicManager: ObservableObject {
|
|||
|
||||
public func destroy() {
|
||||
debounceIdleTask?.cancel()
|
||||
lyricsFetchTask?.cancel()
|
||||
lyricSyncTask?.cancel()
|
||||
explicitLookupTask?.cancel()
|
||||
cancellables.removeAll()
|
||||
controllerCancellables.removeAll()
|
||||
|
|
@ -743,7 +766,7 @@ class MusicManager: ObservableObject {
|
|||
self.lastArtworkContentIdentifier = state.contentIdentifier
|
||||
self.lastArtworkContentURL = state.contentURL
|
||||
|
||||
self.fetchLyrics()
|
||||
self.prepareLyricsForCurrentTrack()
|
||||
if let liveArtworkURL = state.liveArtworkURL {
|
||||
self.videoArtworkURL = liveArtworkURL
|
||||
} else {
|
||||
|
|
@ -1252,53 +1275,108 @@ 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
|
||||
}
|
||||
prepareLyricsForCurrentTrack(forceFetch: true, prioritizeVisibleResult: Defaults[.enableLyrics])
|
||||
}
|
||||
|
||||
private func handleLyricsPreferenceChange(isEnabled: Bool) {
|
||||
showLyrics = isEnabled
|
||||
|
||||
if isEnabled {
|
||||
prepareLyricsForCurrentTrack(prioritizeVisibleResult: true)
|
||||
} else {
|
||||
stopLyricSync()
|
||||
}
|
||||
}
|
||||
|
||||
private func prepareLyricsForCurrentTrack(forceFetch: Bool = false, prioritizeVisibleResult: Bool = false) {
|
||||
guard let lookup = currentLyricsLookupContext() else {
|
||||
activeLyricsKey = nil
|
||||
lyricsFetchKey = nil
|
||||
lyricsFetchTask?.cancel()
|
||||
lyricsFetchTask = nil
|
||||
syncedLyrics = []
|
||||
currentLyrics = ""
|
||||
currentLyricIndex = -1
|
||||
stopLyricSync()
|
||||
return
|
||||
}
|
||||
|
||||
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 = ""
|
||||
}
|
||||
let key = lookup.key
|
||||
let lyricsEnabled = Defaults[.enableLyrics]
|
||||
let shouldShowLoading = lyricsEnabled && prioritizeVisibleResult
|
||||
let trackChanged = activeLyricsKey != key
|
||||
activeLyricsKey = key
|
||||
|
||||
// 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()
|
||||
}
|
||||
if trackChanged {
|
||||
syncedLyrics = []
|
||||
currentLyricIndex = -1
|
||||
currentLyrics = shouldShowLoading ? "Loading lyrics..." : ""
|
||||
stopLyricSync()
|
||||
}
|
||||
|
||||
if !forceFetch, let cachedLyrics = lyricsCache[key] {
|
||||
applyLyricsToDisplay(cachedLyrics)
|
||||
return
|
||||
}
|
||||
|
||||
if lyricsFetchKey == key {
|
||||
if shouldShowLoading && syncedLyrics.isEmpty {
|
||||
currentLyrics = "Loading lyrics..."
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lyricsFetchTask?.cancel()
|
||||
lyricsFetchKey = key
|
||||
|
||||
if shouldShowLoading || (lyricsEnabled && syncedLyrics.isEmpty) {
|
||||
currentLyrics = "Loading lyrics..."
|
||||
}
|
||||
|
||||
let requestArtist = lookup.requestArtist
|
||||
let requestTitle = lookup.requestTitle
|
||||
let requestAlbum = lookup.requestAlbum
|
||||
|
||||
lyricsFetchTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
do {
|
||||
let lyrics = try await self.fetchLyricsFromAPI(
|
||||
artist: requestArtist,
|
||||
title: requestTitle,
|
||||
album: requestAlbum
|
||||
)
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
await MainActor.run {
|
||||
guard self.activeLyricsKey == key else { return }
|
||||
self.lyricsCache[key] = lyrics
|
||||
self.lyricsFetchKey = nil
|
||||
self.lyricsFetchTask = nil
|
||||
self.applyLyricsToDisplay(lyrics)
|
||||
}
|
||||
} catch {
|
||||
print("Failed to fetch lyrics: \(error)")
|
||||
await MainActor.run {
|
||||
guard self.activeLyricsKey == key else { return }
|
||||
self.lyricsFetchKey = nil
|
||||
self.lyricsFetchTask = nil
|
||||
self.syncedLyrics = []
|
||||
self.currentLyrics = ""
|
||||
self.currentLyricIndex = -1
|
||||
self.currentLyrics = lyricsEnabled ? "No lyrics found" : ""
|
||||
self.stopLyricSync()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchLyricsFromAPI(artist: String, title: String) async throws -> [LyricLine] {
|
||||
private func fetchLyricsFromAPI(artist: String, title: String, album: 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)
|
||||
let cleanAlbum = album.folding(options: .diacriticInsensitive, locale: .current)
|
||||
guard let encodedArtist = cleanArtist.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||||
let encodedTitle = cleanTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
|
||||
return []
|
||||
|
|
@ -1312,7 +1390,8 @@ class MusicManager: ObservableObject {
|
|||
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 bestMatch = bestLyricsMatch(in: jsonArray, artist: cleanArtist, title: cleanTitle, album: cleanAlbum) {
|
||||
let first = bestMatch
|
||||
let plain = (first["plainLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let synced = (first["syncedLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
|
||||
|
|
@ -1354,6 +1433,107 @@ class MusicManager: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
private func currentLyricsLookupContext() -> (key: LyricsLookupKey, requestArtist: String, requestTitle: String, requestAlbum: String)? {
|
||||
let requestArtist = normalizedLyricsRequestComponent(artistName)
|
||||
let requestTitle = normalizedLyricsTitle(songTitle)
|
||||
let requestAlbum = normalizedLyricsRequestComponent(album)
|
||||
|
||||
let key = LyricsLookupKey(
|
||||
title: requestTitle.lowercased(),
|
||||
artist: requestArtist.lowercased(),
|
||||
album: requestAlbum.lowercased()
|
||||
)
|
||||
|
||||
return key.isValid ? (key, requestArtist, requestTitle, requestAlbum) : nil
|
||||
}
|
||||
|
||||
private func normalizedLyricsRequestComponent(_ value: String) -> String {
|
||||
value
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
||||
}
|
||||
|
||||
private func normalizedLyricsTitle(_ value: String) -> String {
|
||||
var normalized = normalizedLyricsRequestComponent(value)
|
||||
let cleanupPatterns = [
|
||||
"\\s*\\((feat\\.?|ft\\.?|featuring)[^\\)]*\\)",
|
||||
"\\s*\\[(feat\\.?|ft\\.?|featuring)[^\\]]*\\]",
|
||||
"\\s*-\\s*(feat\\.?|ft\\.?|featuring)\\s+.*$",
|
||||
"\\s*\\((remaster(ed)?|live|mono|stereo)[^\\)]*\\)$",
|
||||
"\\s*\\[(remaster(ed)?|live|mono|stereo)[^\\]]*\\]$"
|
||||
]
|
||||
|
||||
for pattern in cleanupPatterns {
|
||||
normalized = normalized.replacingOccurrences(
|
||||
of: pattern,
|
||||
with: "",
|
||||
options: [.regularExpression, .caseInsensitive]
|
||||
)
|
||||
}
|
||||
|
||||
return normalizedLyricsRequestComponent(normalized)
|
||||
}
|
||||
|
||||
private func bestLyricsMatch(in results: [[String: Any]], artist: String, title: String, album: String) -> [String: Any]? {
|
||||
let normalizedArtist = artist.lowercased()
|
||||
let normalizedTitle = title.lowercased()
|
||||
let normalizedAlbum = album.lowercased()
|
||||
|
||||
return results.max { lhs, rhs in
|
||||
lyricsMatchScore(for: lhs, artist: normalizedArtist, title: normalizedTitle, album: normalizedAlbum)
|
||||
< lyricsMatchScore(for: rhs, artist: normalizedArtist, title: normalizedTitle, album: normalizedAlbum)
|
||||
}
|
||||
}
|
||||
|
||||
private func lyricsMatchScore(for result: [String: Any], artist: String, title: String, album: String) -> Int {
|
||||
let resultArtist = ((result["artistName"] as? String) ?? "").lowercased()
|
||||
let resultTitle = ((result["trackName"] as? String) ?? "").lowercased()
|
||||
let resultAlbum = ((result["albumName"] as? String) ?? "").lowercased()
|
||||
|
||||
var score = 0
|
||||
|
||||
if resultTitle == title { score += 8 }
|
||||
else if resultTitle.contains(title) || title.contains(resultTitle) { score += 4 }
|
||||
|
||||
if resultArtist == artist { score += 8 }
|
||||
else if resultArtist.contains(artist) || artist.contains(resultArtist) { score += 4 }
|
||||
|
||||
if !album.isEmpty {
|
||||
if resultAlbum == album { score += 4 }
|
||||
else if resultAlbum.contains(album) || album.contains(resultAlbum) { score += 2 }
|
||||
}
|
||||
|
||||
if !(result["syncedLyrics"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
score += 3
|
||||
}
|
||||
|
||||
return score
|
||||
}
|
||||
|
||||
private func applyLyricsToDisplay(_ lyrics: [LyricLine]) {
|
||||
syncedLyrics = lyrics
|
||||
currentLyricIndex = -1
|
||||
|
||||
guard !lyrics.isEmpty else {
|
||||
currentLyrics = Defaults[.enableLyrics] ? "No lyrics found" : ""
|
||||
stopLyricSync()
|
||||
return
|
||||
}
|
||||
|
||||
let playbackPosition = max(estimatedPlaybackPosition(), elapsedTime)
|
||||
updateCurrentLyric(for: playbackPosition)
|
||||
|
||||
if currentLyricIndex == -1, let firstLine = lyrics.first?.text {
|
||||
currentLyrics = firstLine
|
||||
}
|
||||
|
||||
if Defaults[.enableLyrics] {
|
||||
startLyricSync()
|
||||
} else {
|
||||
stopLyricSync()
|
||||
}
|
||||
}
|
||||
|
||||
private func parseLRC(_ lrc: String) -> [LyricLine] {
|
||||
let lines = lrc.components(separatedBy: .newlines)
|
||||
var lyrics: [LyricLine] = []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue