mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
1749 lines
62 KiB
Swift
1749 lines
62 KiB
Swift
/*
|
||
* Atoll (DynamicIsland)
|
||
* Copyright (C) 2024-2026 Atoll Contributors
|
||
*
|
||
* Originally from boring.notch project
|
||
* Modified and adapted for Atoll (DynamicIsland)
|
||
* See NOTICE for details.
|
||
*
|
||
* This program is free software: you can redistribute it and/or modify
|
||
* it under the terms of the GNU General Public License as published by
|
||
* the Free Software Foundation, either version 3 of the License, or
|
||
* (at your option) any later version.
|
||
*
|
||
* This program is distributed in the hope that it will be useful,
|
||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||
* GNU General Public License for more details.
|
||
*
|
||
* You should have received a copy of the GNU General Public License
|
||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||
*/
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
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"
|
||
)!
|
||
|
||
private struct ITunesExplicitnessSearchResponse: Decodable {
|
||
let results: [ITunesExplicitnessTrack]
|
||
}
|
||
|
||
private struct ITunesExplicitnessTrack: Decodable {
|
||
let trackName: String?
|
||
let artistName: String?
|
||
let collectionName: String?
|
||
let trackExplicitness: String?
|
||
}
|
||
|
||
private actor MusicExplicitnessResolver {
|
||
struct LookupKey: Hashable, Sendable {
|
||
let title: String
|
||
let artist: String
|
||
let album: String
|
||
|
||
init(title: String, artist: String, album: String) {
|
||
self.title = MusicExplicitnessResolver.normalize(title)
|
||
self.artist = MusicExplicitnessResolver.normalize(artist)
|
||
self.album = MusicExplicitnessResolver.normalize(album)
|
||
}
|
||
|
||
var canResolve: Bool {
|
||
!title.isEmpty && !artist.isEmpty
|
||
}
|
||
}
|
||
|
||
static let shared = MusicExplicitnessResolver()
|
||
|
||
private let session = URLSession(configuration: .ephemeral)
|
||
private var cache: [LookupKey: Bool] = [:]
|
||
private var inFlightTasks: [LookupKey: Task<Bool, Never>] = [:]
|
||
|
||
func resolve(title: String, artist: String, album: String) async -> Bool {
|
||
let key = LookupKey(title: title, artist: artist, album: album)
|
||
guard key.canResolve else { return false }
|
||
|
||
if let cached = cache[key] {
|
||
return cached
|
||
}
|
||
|
||
if let inFlightTask = inFlightTasks[key] {
|
||
return await inFlightTask.value
|
||
}
|
||
|
||
let task = Task<Bool, Never> { [session] in
|
||
await Self.fetchExplicitness(for: key, using: session)
|
||
}
|
||
|
||
inFlightTasks[key] = task
|
||
let result = await task.value
|
||
cache[key] = result
|
||
inFlightTasks[key] = nil
|
||
return result
|
||
}
|
||
|
||
private static func fetchExplicitness(for key: LookupKey, using session: URLSession) async -> Bool {
|
||
let query = "\(key.title) \(key.artist)"
|
||
guard let encoded = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
|
||
let url = URL(
|
||
string: "https://itunes.apple.com/search?term=\(encoded)&media=music&entity=song&limit=15")
|
||
else {
|
||
return false
|
||
}
|
||
|
||
do {
|
||
let (data, _) = try await session.data(from: url)
|
||
let response = try JSONDecoder().decode(ITunesExplicitnessSearchResponse.self, from: data)
|
||
|
||
let bestMatch = response.results
|
||
.map { track in (track, matchScore(for: track, key: key)) }
|
||
.max { lhs, rhs in lhs.1 < rhs.1 }
|
||
|
||
guard let bestMatch,
|
||
bestMatch.1 >= 8
|
||
else {
|
||
return false
|
||
}
|
||
|
||
return bestMatch.0.trackExplicitness?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||
== "explicit"
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
private static func matchScore(for track: ITunesExplicitnessTrack, key: LookupKey) -> Int {
|
||
let trackTitle = canonicalTitle(track.trackName ?? "")
|
||
guard !trackTitle.isEmpty else { return Int.min }
|
||
|
||
let keyTitle = canonicalTitle(key.title)
|
||
let trackArtist = normalize(track.artistName ?? "")
|
||
let trackAlbum = normalize(track.collectionName ?? "")
|
||
|
||
var score = 0
|
||
|
||
if trackTitle == keyTitle {
|
||
score += 6
|
||
} else if trackTitle.contains(keyTitle) || keyTitle.contains(trackTitle) {
|
||
score += 4
|
||
} else {
|
||
return Int.min
|
||
}
|
||
|
||
if !key.artist.isEmpty {
|
||
if trackArtist == key.artist {
|
||
score += 4
|
||
} else if trackArtist.contains(key.artist) || key.artist.contains(trackArtist) {
|
||
score += 2
|
||
}
|
||
}
|
||
|
||
if !key.album.isEmpty {
|
||
if trackAlbum == key.album {
|
||
score += 2
|
||
} else if !trackAlbum.isEmpty && (trackAlbum.contains(key.album) || key.album.contains(trackAlbum)) {
|
||
score += 1
|
||
}
|
||
}
|
||
|
||
return score
|
||
}
|
||
|
||
private static func canonicalTitle(_ value: String) -> String {
|
||
var title = value.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||
title = title.replacingOccurrences(
|
||
of: #"\([^)]*\)|\[[^\]]*\]"#,
|
||
with: " ",
|
||
options: .regularExpression
|
||
)
|
||
title = title.replacingOccurrences(
|
||
of: #"\s-\s(?:\d{4}\s)?(?:remaster(?:ed)?|live|edit|mix|version|mono|stereo).*$"#,
|
||
with: " ",
|
||
options: .regularExpression
|
||
)
|
||
return normalize(title)
|
||
}
|
||
|
||
private static func normalize(_ value: String) -> String {
|
||
let folded = value.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current)
|
||
let stripped = folded.replacingOccurrences(
|
||
of: #"[^a-z0-9]+"#,
|
||
with: " ",
|
||
options: .regularExpression
|
||
)
|
||
return stripped
|
||
.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
}
|
||
}
|
||
|
||
private actor SpotifyExplicitnessResolver {
|
||
struct LookupKey: Hashable, Sendable {
|
||
let trackID: String
|
||
|
||
init?(contentIdentifier: String?, contentURL: String?) {
|
||
guard let trackID = SpotifyExplicitnessResolver.extractTrackID(from: contentIdentifier)
|
||
?? SpotifyExplicitnessResolver.extractTrackID(from: contentURL)
|
||
else {
|
||
return nil
|
||
}
|
||
|
||
self.trackID = trackID
|
||
}
|
||
}
|
||
|
||
static let shared = SpotifyExplicitnessResolver()
|
||
|
||
private let session: URLSession = {
|
||
let configuration = URLSessionConfiguration.ephemeral
|
||
configuration.httpAdditionalHeaders = [
|
||
"User-Agent": "Mozilla/5.0",
|
||
"Accept-Language": "en-US,en;q=0.9"
|
||
]
|
||
return URLSession(configuration: configuration)
|
||
}()
|
||
|
||
private var cache: [LookupKey: Bool] = [:]
|
||
private var inFlightTasks: [LookupKey: Task<Bool?, Never>] = [:]
|
||
|
||
func resolve(key: LookupKey) async -> Bool? {
|
||
if let cached = cache[key] {
|
||
return cached
|
||
}
|
||
|
||
if let inFlightTask = inFlightTasks[key] {
|
||
return await inFlightTask.value
|
||
}
|
||
|
||
let task = Task<Bool?, Never> { [session] in
|
||
await Self.fetchExplicitness(for: key, using: session)
|
||
}
|
||
|
||
inFlightTasks[key] = task
|
||
let result = await task.value
|
||
if let result {
|
||
cache[key] = result
|
||
}
|
||
inFlightTasks[key] = nil
|
||
return result
|
||
}
|
||
|
||
private static func fetchExplicitness(for key: LookupKey, using session: URLSession) async -> Bool? {
|
||
guard let url = URL(string: "https://open.spotify.com/embed/track/\(key.trackID)") else {
|
||
return nil
|
||
}
|
||
|
||
do {
|
||
let (data, response) = try await session.data(from: url)
|
||
guard let httpResponse = response as? HTTPURLResponse,
|
||
(200...299).contains(httpResponse.statusCode),
|
||
let html = String(data: data, encoding: .utf8)
|
||
else {
|
||
return nil
|
||
}
|
||
|
||
if let isExplicit = parseIsExplicitDirectly(from: html) {
|
||
return isExplicit
|
||
}
|
||
|
||
if let nextDataJSON = extractNextDataJSON(from: html),
|
||
let isExplicit = parseIsExplicit(from: nextDataJSON) {
|
||
return isExplicit
|
||
}
|
||
|
||
return nil
|
||
} catch {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private static func extractNextDataJSON(from html: String) -> String? {
|
||
let pattern = #"<script[^>]*id="__NEXT_DATA__"[^>]*>(.*?)</script>"#
|
||
guard let regex = try? NSRegularExpression(
|
||
pattern: pattern,
|
||
options: [.dotMatchesLineSeparators, .caseInsensitive]
|
||
) else {
|
||
return nil
|
||
}
|
||
|
||
let range = NSRange(html.startIndex..<html.endIndex, in: html)
|
||
guard let match = regex.firstMatch(in: html, options: [], range: range),
|
||
match.numberOfRanges > 1,
|
||
let jsonRange = Range(match.range(at: 1), in: html)
|
||
else {
|
||
return nil
|
||
}
|
||
|
||
return String(html[jsonRange])
|
||
}
|
||
|
||
private static func parseIsExplicitDirectly(from html: String) -> Bool? {
|
||
if let value = captureFirstMatch(
|
||
in: html,
|
||
pattern: #""isExplicit"\s*:\s*(true|false)"#
|
||
) {
|
||
return value == "true"
|
||
}
|
||
|
||
if let label = captureFirstMatch(
|
||
in: html,
|
||
pattern: #""label"\s*:\s*"([A-Z_]+)""#
|
||
) {
|
||
switch label {
|
||
case "EXPLICIT":
|
||
return true
|
||
case "NON_EXPLICIT":
|
||
return false
|
||
default:
|
||
break
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
private static func captureFirstMatch(in source: String, pattern: String) -> String? {
|
||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive]) else {
|
||
return nil
|
||
}
|
||
|
||
let range = NSRange(source.startIndex..<source.endIndex, in: source)
|
||
guard let match = regex.firstMatch(in: source, options: [], range: range),
|
||
match.numberOfRanges > 1,
|
||
let valueRange = Range(match.range(at: 1), in: source)
|
||
else {
|
||
return nil
|
||
}
|
||
|
||
return String(source[valueRange]).lowercased()
|
||
}
|
||
|
||
private static func parseIsExplicit(from nextDataJSON: String) -> Bool? {
|
||
guard let data = nextDataJSON.data(using: .utf8),
|
||
let rootObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||
let props = rootObject["props"] as? [String: Any],
|
||
let pageProps = props["pageProps"] as? [String: Any],
|
||
let state = pageProps["state"] as? [String: Any],
|
||
let dataObject = state["data"] as? [String: Any],
|
||
let entity = dataObject["entity"] as? [String: Any]
|
||
else {
|
||
return nil
|
||
}
|
||
|
||
if let isExplicit = entity["isExplicit"] as? Bool {
|
||
return isExplicit
|
||
}
|
||
|
||
if let contentRating = entity["contentRating"] as? [String: Any],
|
||
let label = contentRating["label"] as? String {
|
||
return label.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() == "EXPLICIT"
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
private static func extractTrackID(from rawValue: String?) -> String? {
|
||
guard let rawValue else { return nil }
|
||
|
||
let trimmed = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else { return nil }
|
||
|
||
if trimmed.hasPrefix("spotify:track:") {
|
||
return validatedTrackID(String(trimmed.split(separator: ":").last ?? ""))
|
||
}
|
||
|
||
if let url = URL(string: trimmed) {
|
||
let pathComponents = url.pathComponents.filter { $0 != "/" && !$0.isEmpty }
|
||
if let trackIndex = pathComponents.firstIndex(of: "track"),
|
||
trackIndex + 1 < pathComponents.count {
|
||
return validatedTrackID(pathComponents[trackIndex + 1])
|
||
}
|
||
}
|
||
|
||
return validatedTrackID(trimmed)
|
||
}
|
||
|
||
private static func validatedTrackID<S: StringProtocol>(_ candidate: S) -> String? {
|
||
let value = String(candidate).trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard value.range(of: #"^[A-Za-z0-9]{22}$"#, options: .regularExpression) != nil else {
|
||
return nil
|
||
}
|
||
|
||
return value
|
||
}
|
||
}
|
||
|
||
class MusicManager: ObservableObject {
|
||
enum SkipDirection: Equatable {
|
||
case backward
|
||
case forward
|
||
}
|
||
|
||
struct SkipGesturePulse: Equatable {
|
||
let token: Int
|
||
let direction: SkipDirection
|
||
let behavior: MusicSkipBehavior
|
||
}
|
||
|
||
static let skipGestureSeekInterval: TimeInterval = 10
|
||
|
||
// MARK: - Properties
|
||
static let shared = MusicManager()
|
||
private var cancellables = Set<AnyCancellable>()
|
||
private var controllerCancellables = Set<AnyCancellable>()
|
||
private var debounceIdleTask: Task<Void, Never>?
|
||
@MainActor private var pendingOptimisticPlayState: Bool?
|
||
|
||
// Helper to check if macOS has removed support for NowPlayingController
|
||
public private(set) var isNowPlayingDeprecated: Bool = false
|
||
private let mediaChecker = MediaChecker()
|
||
|
||
// Active controller
|
||
private var activeController: (any MediaControllerProtocol)?
|
||
|
||
// Pear Desktop auto-detection
|
||
private static let pearDesktopBundleID = YouTubeMusicConfiguration.default.bundleIdentifier
|
||
private var isPearDesktopAutoSwitched: Bool = false
|
||
|
||
// Published properties for UI
|
||
@Published var songTitle: String = "I'm Handsome"
|
||
@Published var artistName: String = "Me"
|
||
@Published var albumArt: NSImage = defaultImage
|
||
@Published var isPlaying = false
|
||
@Published var album: String = "Self Love"
|
||
@Published var isPlayerIdle: Bool = true
|
||
@Published var isCurrentTrackExplicit: Bool = false
|
||
|
||
/// Whether there is an active music session with real metadata.
|
||
/// Returns `false` only when the metadata is still placeholder/fallback defaults
|
||
/// (i.e. nothing has been played since app launch, or the controller returned
|
||
/// unknown/not-playing placeholders). Paused music with real metadata is still
|
||
/// considered an active session.
|
||
private static let placeholderTitles: Set<String> = [
|
||
"i'm handsome", "unknown", "not playing"
|
||
]
|
||
private static let placeholderArtists: Set<String> = [
|
||
"me", "unknown"
|
||
]
|
||
|
||
var hasActiveSession: Bool {
|
||
if isPlaying { return true }
|
||
let trimmedTitle = songTitle.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||
let trimmedArtist = artistName.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||
let hasRealTitle = !trimmedTitle.isEmpty && !Self.placeholderTitles.contains(trimmedTitle)
|
||
let hasRealArtist = !trimmedArtist.isEmpty && !Self.placeholderArtists.contains(trimmedArtist)
|
||
return hasRealTitle || hasRealArtist
|
||
}
|
||
|
||
@Published var animations: DynamicIslandAnimations = .init()
|
||
@Published var avgColor: NSColor = .white
|
||
@Published var bundleIdentifier: String? = nil
|
||
@Published var songDuration: TimeInterval = 0
|
||
@Published var elapsedTime: TimeInterval = 0
|
||
@Published var timestampDate: Date = .init()
|
||
@Published var playbackRate: Double = 1
|
||
@Published var isShuffled: Bool = false
|
||
@Published var repeatMode: RepeatMode = .off
|
||
@Published var isLiveStream: Bool = false
|
||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||
@Published var usingAppIconForArtwork: Bool = false
|
||
@Published private(set) var skipGesturePulse: SkipGesturePulse?
|
||
|
||
// 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 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?
|
||
|
||
private(set) var artworkData: Data? = nil
|
||
|
||
@Published var videoArtworkURL: URL? = nil
|
||
|
||
private var liveStreamUnknownDurationCount: Int = 0
|
||
private var liveStreamEdgeObservationCount: Int = 0
|
||
private var liveStreamCompletionObservationCount: Int = 0
|
||
private var liveStreamCompletionReleaseCount: Int = 0
|
||
|
||
// Store last values at the time artwork was changed
|
||
private var lastArtworkTitle: String = "I'm Handsome"
|
||
private var lastArtworkArtist: String = "Me"
|
||
private var lastArtworkAlbum: String = "Self Love"
|
||
private var lastArtworkBundleIdentifier: String? = nil
|
||
private var lastArtworkContentIdentifier: String? = nil
|
||
private var lastArtworkContentURL: String? = nil
|
||
|
||
@Published var flipAngle: Double = 0
|
||
@Published var lastFlipDirection: SkipDirection = .forward
|
||
private let flipAnimationDuration: TimeInterval = 0.45
|
||
private var flipCooldownActive: Bool = false
|
||
|
||
@Published var isTransitioning: Bool = false
|
||
private var transitionWorkItem: DispatchWorkItem?
|
||
private var skipGestureToken: Int = 0
|
||
|
||
// MARK: - Initialization
|
||
init() {
|
||
// Listen for changes to the default controller preference
|
||
NotificationCenter.default.publisher(for: Notification.Name.mediaControllerChanged)
|
||
.sink { [weak self] _ in
|
||
self?.isPearDesktopAutoSwitched = false
|
||
self?.setActiveControllerBasedOnPreference()
|
||
}
|
||
.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()
|
||
|
||
// Initialize deprecation check asynchronously
|
||
Task { @MainActor in
|
||
do {
|
||
self.isNowPlayingDeprecated = try await self.mediaChecker.checkDeprecationStatus()
|
||
print("Deprecation check completed: \(self.isNowPlayingDeprecated)")
|
||
} catch {
|
||
print("Failed to check deprecation status: \(error). Defaulting to false.")
|
||
self.isNowPlayingDeprecated = false
|
||
}
|
||
|
||
// Check if Pear Desktop is already running at startup
|
||
let pearDesktopRunning = NSWorkspace.shared.runningApplications.contains {
|
||
$0.bundleIdentifier == Self.pearDesktopBundleID
|
||
}
|
||
|
||
if pearDesktopRunning {
|
||
print("[MusicManager] Pear Desktop detected at startup, auto-switching to YouTubeMusicController")
|
||
self.isPearDesktopAutoSwitched = true
|
||
if let controller = self.createController(for: .youtubeMusic) {
|
||
self.setActiveController(controller)
|
||
}
|
||
} else {
|
||
// Initialize the active controller after deprecation check
|
||
self.setActiveControllerBasedOnPreference()
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Pear Desktop Auto-Detection
|
||
private func setupPearDesktopAutoDetection() {
|
||
NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didLaunchApplicationNotification)
|
||
.sink { [weak self] notification in
|
||
guard let self = self,
|
||
let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
|
||
app.bundleIdentifier == Self.pearDesktopBundleID else { return }
|
||
|
||
print("[MusicManager] Pear Desktop launched, auto-switching to YouTubeMusicController")
|
||
self.isPearDesktopAutoSwitched = true
|
||
if let controller = self.createController(for: .youtubeMusic) {
|
||
self.setActiveController(controller)
|
||
}
|
||
}
|
||
.store(in: &cancellables)
|
||
|
||
NSWorkspace.shared.notificationCenter.publisher(for: NSWorkspace.didTerminateApplicationNotification)
|
||
.sink { [weak self] notification in
|
||
guard let self = self,
|
||
let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication,
|
||
app.bundleIdentifier == Self.pearDesktopBundleID else { return }
|
||
|
||
print("[MusicManager] Pear Desktop terminated, reverting to preferred controller")
|
||
if self.isPearDesktopAutoSwitched {
|
||
self.isPearDesktopAutoSwitched = false
|
||
self.setActiveControllerBasedOnPreference()
|
||
}
|
||
}
|
||
.store(in: &cancellables)
|
||
}
|
||
|
||
deinit {
|
||
destroy()
|
||
}
|
||
|
||
public func destroy() {
|
||
debounceIdleTask?.cancel()
|
||
lyricsFetchTask?.cancel()
|
||
lyricSyncTask?.cancel()
|
||
explicitLookupTask?.cancel()
|
||
cancellables.removeAll()
|
||
controllerCancellables.removeAll()
|
||
transitionWorkItem?.cancel()
|
||
|
||
// Release active controller
|
||
activeController = nil
|
||
}
|
||
|
||
// MARK: - Setup Methods
|
||
private func createController(for type: MediaControllerType) -> (any MediaControllerProtocol)? {
|
||
// Cleanup previous controller
|
||
if activeController != nil {
|
||
controllerCancellables.removeAll()
|
||
activeController = nil
|
||
}
|
||
|
||
let newController: (any MediaControllerProtocol)?
|
||
|
||
switch type {
|
||
case .nowPlaying:
|
||
// Only create NowPlayingController if not deprecated on this macOS version
|
||
if !self.isNowPlayingDeprecated {
|
||
newController = NowPlayingController()
|
||
} else {
|
||
return nil
|
||
}
|
||
case .appleMusic:
|
||
newController = AppleMusicController()
|
||
case .spotify:
|
||
newController = SpotifyController()
|
||
case .youtubeMusic:
|
||
newController = YouTubeMusicController()
|
||
case .amazonMusic:
|
||
newController = AmazonMusicController()
|
||
}
|
||
|
||
// Set up state observation for the new controller
|
||
if let controller = newController {
|
||
controller.playbackStatePublisher
|
||
.receive(on: DispatchQueue.main)
|
||
.sink { [weak self] state in
|
||
guard let self = self,
|
||
self.activeController === controller else { return }
|
||
self.updateFromPlaybackState(state)
|
||
}
|
||
.store(in: &controllerCancellables)
|
||
}
|
||
|
||
return newController
|
||
}
|
||
|
||
private func setActiveControllerBasedOnPreference() {
|
||
let preferredType = Defaults[.mediaController]
|
||
print("Preferred Media Controller: \(preferredType)")
|
||
|
||
// If NowPlaying is deprecated but that's the preference, use Apple Music instead
|
||
let controllerType = (self.isNowPlayingDeprecated && preferredType == .nowPlaying)
|
||
? .appleMusic
|
||
: preferredType
|
||
|
||
if let controller = createController(for: controllerType) {
|
||
setActiveController(controller)
|
||
} else if controllerType != .appleMusic, let fallbackController = createController(for: .appleMusic) {
|
||
// Fallback to Apple Music if preferred controller couldn't be created
|
||
setActiveController(fallbackController)
|
||
}
|
||
}
|
||
|
||
private func setActiveController(_ controller: any MediaControllerProtocol) {
|
||
// Set new active controller
|
||
activeController = controller
|
||
|
||
// Get current state from active controller
|
||
forceUpdate()
|
||
}
|
||
|
||
@MainActor
|
||
private func applyPlayState(_ state: Bool, animation: Animation?) {
|
||
if let animation {
|
||
var transaction = Transaction()
|
||
transaction.animation = animation
|
||
withTransaction(transaction) {
|
||
self.isPlaying = state
|
||
}
|
||
} else {
|
||
self.isPlaying = state
|
||
}
|
||
|
||
self.updateIdleState(state: state)
|
||
}
|
||
|
||
// MARK: - Update Methods
|
||
@MainActor
|
||
private func updateFromPlaybackState(_ state: PlaybackState) {
|
||
// Check for playback state changes (playing/paused)
|
||
let eventIsPlaying = state.isPlaying
|
||
let expectedState = pendingOptimisticPlayState
|
||
pendingOptimisticPlayState = nil
|
||
|
||
if eventIsPlaying != self.isPlaying {
|
||
let animation: Animation? = (expectedState == eventIsPlaying) ? .smooth(duration: 0.18) : .smooth
|
||
applyPlayState(eventIsPlaying, animation: animation)
|
||
|
||
if eventIsPlaying && !state.title.isEmpty && !state.artist.isEmpty {
|
||
self.updateSneakPeek()
|
||
}
|
||
} else {
|
||
self.updateIdleState(state: eventIsPlaying)
|
||
}
|
||
|
||
// Check for changes in track metadata using last artwork change values
|
||
let titleChanged = state.title != self.lastArtworkTitle
|
||
let artistChanged = state.artist != self.lastArtworkArtist
|
||
let albumChanged = state.album != self.lastArtworkAlbum
|
||
let bundleChanged = state.bundleIdentifier != self.lastArtworkBundleIdentifier
|
||
let contentIdentifierChanged = state.contentIdentifier != self.lastArtworkContentIdentifier
|
||
let contentURLChanged = state.contentURL != self.lastArtworkContentURL
|
||
|
||
// Check for artwork changes
|
||
let artworkChanged = state.artwork != nil && state.artwork != self.artworkData
|
||
|
||
let hasContentChange =
|
||
titleChanged
|
||
|| artistChanged
|
||
|| albumChanged
|
||
|| artworkChanged
|
||
|| bundleChanged
|
||
|| contentIdentifierChanged
|
||
|| contentURLChanged
|
||
let liveArtworkChanged = state.liveArtworkURL != self.videoArtworkURL
|
||
|
||
if liveArtworkChanged {
|
||
self.videoArtworkURL = state.liveArtworkURL
|
||
}
|
||
|
||
// Handle artwork and visual transitions for changed content
|
||
let shouldAutoPeekOnTrackChange = Defaults[.showSneakPeekOnTrackChange]
|
||
|
||
if hasContentChange {
|
||
self.triggerFlipAnimation()
|
||
|
||
if artworkChanged, let artwork = state.artwork {
|
||
self.updateArtwork(artwork)
|
||
} else if state.artwork == nil {
|
||
// Try to use app icon if no artwork but track changed
|
||
if let appIconImage = AppIconAsNSImage(for: state.bundleIdentifier) {
|
||
self.usingAppIconForArtwork = true
|
||
self.updateAlbumArt(newAlbumArt: appIconImage)
|
||
}
|
||
}
|
||
self.artworkData = state.artwork
|
||
|
||
// Update last artwork change values
|
||
self.lastArtworkTitle = state.title
|
||
self.lastArtworkArtist = state.artist
|
||
self.lastArtworkAlbum = state.album
|
||
self.lastArtworkBundleIdentifier = state.bundleIdentifier
|
||
self.lastArtworkContentIdentifier = state.contentIdentifier
|
||
self.lastArtworkContentURL = state.contentURL
|
||
|
||
self.prepareLyricsForCurrentTrack()
|
||
if let liveArtworkURL = state.liveArtworkURL {
|
||
self.videoArtworkURL = liveArtworkURL
|
||
} else {
|
||
self.fetchVideoArtwork()
|
||
}
|
||
|
||
self.refreshExplicitFlag(for: state)
|
||
|
||
|
||
// Only update sneak peek if there's actual content and something changed
|
||
if shouldAutoPeekOnTrackChange && !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying {
|
||
self.updateSneakPeek()
|
||
}
|
||
} else if state.isExplicit != nil {
|
||
self.refreshExplicitFlag(for: state)
|
||
}
|
||
|
||
let timeChanged = state.currentTime != self.elapsedTime
|
||
let durationChanged = state.duration != self.songDuration
|
||
let playbackRateChanged = state.playbackRate != self.playbackRate
|
||
let shuffleChanged = state.isShuffled != self.isShuffled
|
||
let repeatModeChanged = state.repeatMode != self.repeatMode
|
||
|
||
if state.title != self.songTitle {
|
||
self.songTitle = state.title
|
||
}
|
||
|
||
if state.artist != self.artistName {
|
||
self.artistName = state.artist
|
||
}
|
||
|
||
if state.album != self.album {
|
||
self.album = state.album
|
||
}
|
||
|
||
if timeChanged {
|
||
self.elapsedTime = state.currentTime
|
||
// Update current lyric based on elapsed time
|
||
self.updateCurrentLyric(for: state.currentTime)
|
||
}
|
||
|
||
if durationChanged {
|
||
self.songDuration = state.duration
|
||
}
|
||
|
||
if playbackRateChanged {
|
||
self.playbackRate = state.playbackRate
|
||
}
|
||
|
||
if shuffleChanged {
|
||
self.isShuffled = state.isShuffled
|
||
}
|
||
|
||
if state.bundleIdentifier != self.bundleIdentifier {
|
||
self.bundleIdentifier = state.bundleIdentifier
|
||
}
|
||
|
||
if repeatModeChanged {
|
||
self.repeatMode = state.repeatMode
|
||
}
|
||
|
||
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()
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func refreshExplicitFlag(for state: PlaybackState) {
|
||
if let explicitValue = state.isExplicit {
|
||
explicitLookupTask?.cancel()
|
||
explicitLookupTask = nil
|
||
explicitLookupKey = nil
|
||
|
||
if isCurrentTrackExplicit != explicitValue {
|
||
isCurrentTrackExplicit = explicitValue
|
||
}
|
||
return
|
||
}
|
||
|
||
if state.bundleIdentifier == SpotifyController.bundleIdentifier,
|
||
let spotifyLookupKey = SpotifyExplicitnessResolver.LookupKey(
|
||
contentIdentifier: state.contentIdentifier,
|
||
contentURL: state.contentURL
|
||
) {
|
||
let lookupIdentifier = "spotify|\(spotifyLookupKey.trackID)"
|
||
let fallbackTitle = state.title
|
||
let fallbackArtist = state.artist
|
||
let fallbackAlbum = state.album
|
||
guard explicitLookupKey != lookupIdentifier else { return }
|
||
|
||
explicitLookupTask?.cancel()
|
||
explicitLookupKey = lookupIdentifier
|
||
|
||
if isCurrentTrackExplicit {
|
||
isCurrentTrackExplicit = false
|
||
}
|
||
|
||
explicitLookupTask = Task { [weak self] in
|
||
let isExplicit = await SpotifyExplicitnessResolver.shared.resolve(key: spotifyLookupKey)
|
||
|
||
guard !Task.isCancelled else { return }
|
||
|
||
await MainActor.run {
|
||
guard let self,
|
||
self.explicitLookupKey == lookupIdentifier
|
||
else {
|
||
return
|
||
}
|
||
|
||
self.explicitLookupTask = nil
|
||
|
||
if let isExplicit {
|
||
self.isCurrentTrackExplicit = isExplicit
|
||
} else {
|
||
self.explicitLookupKey = nil
|
||
self.refreshGenericExplicitFlag(
|
||
title: fallbackTitle,
|
||
artist: fallbackArtist,
|
||
album: fallbackAlbum
|
||
)
|
||
}
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
refreshGenericExplicitFlag(title: state.title, artist: state.artist, album: state.album)
|
||
}
|
||
|
||
@MainActor
|
||
private func refreshGenericExplicitFlag(title: String, artist: String, album: String) {
|
||
let lookupKey = MusicExplicitnessResolver.LookupKey(
|
||
title: title,
|
||
artist: artist,
|
||
album: album
|
||
)
|
||
let lookupIdentifier = "generic|\(lookupKey.title)|\(lookupKey.artist)|\(lookupKey.album)"
|
||
|
||
guard lookupKey.canResolve,
|
||
!Self.placeholderTitles.contains(lookupKey.title),
|
||
!Self.placeholderArtists.contains(lookupKey.artist)
|
||
else {
|
||
explicitLookupTask?.cancel()
|
||
explicitLookupTask = nil
|
||
explicitLookupKey = nil
|
||
if isCurrentTrackExplicit {
|
||
isCurrentTrackExplicit = false
|
||
}
|
||
return
|
||
}
|
||
|
||
guard explicitLookupKey != lookupIdentifier else { return }
|
||
|
||
explicitLookupTask?.cancel()
|
||
explicitLookupKey = lookupIdentifier
|
||
|
||
if isCurrentTrackExplicit {
|
||
isCurrentTrackExplicit = false
|
||
}
|
||
|
||
explicitLookupTask = Task { [weak self] in
|
||
let isExplicit = await MusicExplicitnessResolver.shared.resolve(
|
||
title: title,
|
||
artist: artist,
|
||
album: album
|
||
)
|
||
|
||
guard !Task.isCancelled else { return }
|
||
|
||
await MainActor.run {
|
||
guard let self,
|
||
self.explicitLookupKey == lookupIdentifier
|
||
else {
|
||
return
|
||
}
|
||
|
||
self.isCurrentTrackExplicit = isExplicit
|
||
self.explicitLookupTask = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
private func triggerFlipAnimation() {
|
||
// Debounce: rapid metadata updates (title, artwork, bundle arriving
|
||
// separately for one track change) should only produce a single flip.
|
||
guard !flipCooldownActive else { return }
|
||
flipCooldownActive = true
|
||
|
||
// Direction: positive rotation = next (page turn forward),
|
||
// negative rotation = previous (page turn backward).
|
||
let delta: Double = lastFlipDirection == .forward ? 180 : -180
|
||
withAnimation(.easeInOut(duration: flipAnimationDuration)) {
|
||
flipAngle += delta
|
||
}
|
||
|
||
// Reset cooldown after the animation completes so the next
|
||
// genuine track change can flip again.
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + flipAnimationDuration + 0.15) { [weak self] in
|
||
self?.flipCooldownActive = false
|
||
}
|
||
}
|
||
|
||
private func updateLiveStreamState(with state: PlaybackState) {
|
||
let duration = state.duration
|
||
let current = max(state.currentTime, elapsedTime)
|
||
let hasKnownDuration = duration.isFinite && duration > 0
|
||
let isPlaying = state.isPlaying
|
||
|
||
if hasKnownDuration {
|
||
liveStreamUnknownDurationCount = 0
|
||
|
||
let remaining = duration - current
|
||
let clampedDuration = max(duration, 0)
|
||
let clampedCurrent = clampedDuration > 0
|
||
? max(0, min(current, clampedDuration))
|
||
: max(0, current)
|
||
let progress = clampedDuration > 0 ? clampedCurrent / clampedDuration : 0
|
||
let sliderAppearsComplete = isPlaying && clampedDuration > 0 && progress >= 0.999
|
||
let nearDurationEdge = isPlaying && remaining.isFinite && remaining <= 1.0 && clampedCurrent >= 10
|
||
|
||
if sliderAppearsComplete {
|
||
liveStreamCompletionObservationCount = min(liveStreamCompletionObservationCount + 1, 8)
|
||
liveStreamCompletionReleaseCount = 0
|
||
} else {
|
||
liveStreamCompletionReleaseCount = min(liveStreamCompletionReleaseCount + 1, 8)
|
||
if liveStreamCompletionObservationCount > 0 {
|
||
liveStreamCompletionObservationCount = max(liveStreamCompletionObservationCount - 1, 0)
|
||
}
|
||
}
|
||
|
||
if nearDurationEdge || sliderAppearsComplete {
|
||
liveStreamEdgeObservationCount = min(liveStreamEdgeObservationCount + 1, 12)
|
||
} else if liveStreamEdgeObservationCount > 0 {
|
||
liveStreamEdgeObservationCount = max(liveStreamEdgeObservationCount - 1, 0)
|
||
}
|
||
|
||
if !isLiveStream {
|
||
if liveStreamCompletionObservationCount >= 3 || liveStreamEdgeObservationCount >= 5 {
|
||
isLiveStream = true
|
||
}
|
||
} else {
|
||
let shouldClearForKnownDuration =
|
||
(duration > 10 && remaining > 5)
|
||
|| (liveStreamCompletionObservationCount == 0
|
||
&& liveStreamEdgeObservationCount == 0
|
||
&& liveStreamCompletionReleaseCount >= 4)
|
||
|
||
if shouldClearForKnownDuration {
|
||
isLiveStream = false
|
||
}
|
||
}
|
||
} else if isPlaying {
|
||
liveStreamEdgeObservationCount = max(liveStreamEdgeObservationCount - 1, 0)
|
||
liveStreamCompletionObservationCount = max(liveStreamCompletionObservationCount - 1, 0)
|
||
liveStreamCompletionReleaseCount = 0
|
||
|
||
liveStreamUnknownDurationCount = min(liveStreamUnknownDurationCount + 1, 8)
|
||
if liveStreamUnknownDurationCount >= 3 && !isLiveStream {
|
||
isLiveStream = true
|
||
}
|
||
} else {
|
||
liveStreamUnknownDurationCount = 0
|
||
liveStreamEdgeObservationCount = 0
|
||
liveStreamCompletionObservationCount = 0
|
||
liveStreamCompletionReleaseCount = 0
|
||
}
|
||
}
|
||
|
||
private func updateArtwork(_ artworkData: Data) {
|
||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||
guard let self = self else { return }
|
||
|
||
if let artworkImage = NSImage(data: artworkData) {
|
||
DispatchQueue.main.async { [weak self] in
|
||
self?.usingAppIconForArtwork = false
|
||
self?.updateAlbumArt(newAlbumArt: artworkImage)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func updateIdleState(state: Bool) {
|
||
if state {
|
||
isPlayerIdle = false
|
||
debounceIdleTask?.cancel()
|
||
} else {
|
||
debounceIdleTask?.cancel()
|
||
debounceIdleTask = Task { [weak self] in
|
||
guard let self = self else { return }
|
||
try? await Task.sleep(for: .seconds(Defaults[.waitInterval]))
|
||
withAnimation {
|
||
self.isPlayerIdle = !self.isPlaying
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var workItem: DispatchWorkItem?
|
||
|
||
func updateAlbumArt(newAlbumArt: NSImage) {
|
||
workItem?.cancel()
|
||
workItem = DispatchWorkItem { [weak self] in
|
||
withAnimation(.smooth) {
|
||
self?.albumArt = newAlbumArt
|
||
if Defaults[.coloredSpectrogram] {
|
||
self?.calculateAverageColor()
|
||
}
|
||
}
|
||
}
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: workItem!)
|
||
}
|
||
|
||
// MARK: - Playback Position Estimation
|
||
public func estimatedPlaybackPosition(at date: Date = Date()) -> TimeInterval {
|
||
guard isPlaying else { return min(elapsedTime, songDuration) }
|
||
|
||
let timeDifference = date.timeIntervalSince(timestampDate)
|
||
let estimated = elapsedTime + (timeDifference * playbackRate)
|
||
return min(max(0, estimated), songDuration)
|
||
}
|
||
|
||
func calculateAverageColor() {
|
||
albumArt.averageColor { [weak self] color in
|
||
DispatchQueue.main.async {
|
||
withAnimation(.smooth) {
|
||
self?.avgColor = color ?? .white
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private func updateSneakPeek() {
|
||
let standardControlsEnabled = Defaults[.showStandardMediaControls]
|
||
let minimalisticEnabled = Defaults[.enableMinimalisticUI]
|
||
|
||
guard standardControlsEnabled || minimalisticEnabled else { return }
|
||
|
||
if isPlaying && Defaults[.enableSneakPeek] {
|
||
if Defaults[.sneakPeekStyles] == .standard {
|
||
coordinator.toggleSneakPeek(status: true, type: .music)
|
||
} else {
|
||
coordinator.toggleExpandingView(status: true, type: .music)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Public Methods for controlling playback
|
||
func playPause() {
|
||
Task {
|
||
await activeController?.togglePlay()
|
||
}
|
||
}
|
||
|
||
func play() {
|
||
Task {
|
||
await activeController?.play()
|
||
}
|
||
}
|
||
|
||
func pause() {
|
||
Task {
|
||
await activeController?.pause()
|
||
}
|
||
}
|
||
|
||
func toggleShuffle() {
|
||
Task {
|
||
await activeController?.toggleShuffle()
|
||
}
|
||
}
|
||
|
||
func toggleRepeat() {
|
||
Task {
|
||
await activeController?.toggleRepeat()
|
||
}
|
||
}
|
||
|
||
func togglePlay() {
|
||
guard let controller = activeController else { return }
|
||
let targetState = !isPlaying
|
||
|
||
Task {
|
||
await MainActor.run {
|
||
pendingOptimisticPlayState = targetState
|
||
applyPlayState(targetState, animation: .smooth(duration: 0.18))
|
||
}
|
||
|
||
if targetState {
|
||
await controller.play()
|
||
} else {
|
||
await controller.pause()
|
||
}
|
||
}
|
||
}
|
||
|
||
func nextTrack() {
|
||
Task {
|
||
await activeController?.nextTrack()
|
||
}
|
||
}
|
||
|
||
func previousTrack() {
|
||
Task {
|
||
await activeController?.previousTrack()
|
||
}
|
||
}
|
||
|
||
func seek(to position: TimeInterval) {
|
||
Task {
|
||
await activeController?.seek(to: position)
|
||
}
|
||
}
|
||
|
||
func seek(by offset: TimeInterval) {
|
||
guard !isLiveStream else { return }
|
||
let duration = songDuration
|
||
guard duration > 0 else { return }
|
||
|
||
let current = estimatedPlaybackPosition()
|
||
let magnitude = abs(offset)
|
||
|
||
if offset < 0, current <= magnitude {
|
||
previousTrack()
|
||
return
|
||
}
|
||
|
||
if offset > 0, (duration - current) <= magnitude {
|
||
nextTrack()
|
||
return
|
||
}
|
||
|
||
let target = min(max(0, current + offset), duration)
|
||
seek(to: target)
|
||
}
|
||
|
||
@MainActor
|
||
func handleSkipGesture(direction: SkipDirection) {
|
||
guard Defaults[.enableHorizontalMusicGestures] else { return }
|
||
guard !isPlayerIdle || bundleIdentifier != nil else { return }
|
||
|
||
let behavior = Defaults[.musicGestureBehavior]
|
||
|
||
switch behavior {
|
||
case .track:
|
||
if direction == .forward {
|
||
lastFlipDirection = .forward
|
||
nextTrack()
|
||
} else {
|
||
lastFlipDirection = .backward
|
||
previousTrack()
|
||
}
|
||
case .tenSecond:
|
||
let interval = Self.skipGestureSeekInterval
|
||
let offset = direction == .forward ? interval : -interval
|
||
seek(by: offset)
|
||
}
|
||
|
||
skipGestureToken = skipGestureToken &+ 1
|
||
skipGesturePulse = SkipGesturePulse(
|
||
token: skipGestureToken,
|
||
direction: direction,
|
||
behavior: behavior
|
||
)
|
||
}
|
||
|
||
func openMusicApp() {
|
||
guard let bundleID = bundleIdentifier else {
|
||
print("Error: appBundleIdentifier is nil")
|
||
return
|
||
}
|
||
|
||
let workspace = NSWorkspace.shared
|
||
if let appURL = workspace.urlForApplication(withBundleIdentifier: bundleID) {
|
||
let configuration = NSWorkspace.OpenConfiguration()
|
||
workspace.openApplication(at: appURL, configuration: configuration) { (app, error) in
|
||
if let error = error {
|
||
print("Failed to launch app with bundle ID: \(bundleID), error: \(error)")
|
||
} else {
|
||
print("Launched app with bundle ID: \(bundleID)")
|
||
}
|
||
}
|
||
} else {
|
||
print("Failed to find app with bundle ID: \(bundleID)")
|
||
}
|
||
}
|
||
|
||
func forceUpdate() {
|
||
// Request immediate update from the active controller
|
||
Task { [weak self] in
|
||
if self?.activeController?.isActive() == true {
|
||
if let youtubeController = self?.activeController as? YouTubeMusicController {
|
||
await youtubeController.pollPlaybackState()
|
||
} else {
|
||
await self?.activeController?.updatePlaybackInfo()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Lyrics Methods
|
||
func fetchLyrics() {
|
||
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
|
||
}
|
||
|
||
let key = lookup.key
|
||
let lyricsEnabled = Defaults[.enableLyrics]
|
||
let shouldShowLoading = lyricsEnabled && prioritizeVisibleResult
|
||
let trackChanged = activeLyricsKey != key
|
||
activeLyricsKey = key
|
||
|
||
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.currentLyricIndex = -1
|
||
self.currentLyrics = lyricsEnabled ? "No lyrics found" : ""
|
||
self.stopLyricSync()
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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 []
|
||
}
|
||
|
||
// 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 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) ?? ""
|
||
|
||
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) {
|
||
let trimmed = lrcString.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
|
||
if trimmed.isEmpty {
|
||
return []
|
||
}
|
||
|
||
// If it contains a syncedLyrics key in an object, try that
|
||
if let json = try? JSONSerialization.jsonObject(with: data, options: []) {
|
||
if let dict = json as? [String: Any],
|
||
let synced = dict["syncedLyrics"] as? String
|
||
{
|
||
return parseLRC(synced)
|
||
}
|
||
if let array = json as? [Any], array.isEmpty {
|
||
return []
|
||
}
|
||
}
|
||
|
||
// Otherwise treat as plain lyrics blob
|
||
return [LyricLine(timestamp: 0, text: trimmed)]
|
||
}
|
||
return []
|
||
}
|
||
} else {
|
||
return []
|
||
}
|
||
}
|
||
|
||
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] = []
|
||
|
||
// 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
|
||
}
|
||
|
||
// MARK: - Video Artwork
|
||
|
||
func fetchVideoArtwork() {
|
||
guard Defaults[.lockScreenMusicFullscreenVideoArtwork] else {
|
||
videoArtworkURL = nil
|
||
return
|
||
}
|
||
// Se il player non è Apple Music, non toccare videoArtworkURL:
|
||
// SpotifyController gestisce il canvas in modo autonomo tramite liveArtworkURL.
|
||
guard bundleIdentifier == "com.apple.Music" else {
|
||
return
|
||
}
|
||
|
||
let title = songTitle
|
||
let artist = artistName
|
||
|
||
Task {
|
||
let url = await AnimatedArtworkManager.shared.fetchAnimatedArtworkURL(
|
||
title: title, artist: artist
|
||
)
|
||
await MainActor.run {
|
||
self.videoArtworkURL = url
|
||
}
|
||
}
|
||
}
|
||
|
||
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: - Media Branding
|
||
|
||
extension MusicManager {
|
||
var brandAccentColor: Color {
|
||
Self.brandAccentColor(for: Defaults[.mediaController], bundleIdentifier: bundleIdentifier)
|
||
}
|
||
|
||
private static func brandAccentColor(for controller: MediaControllerType, bundleIdentifier: String?) -> Color {
|
||
switch controller {
|
||
case .appleMusic:
|
||
return appleMusicPink
|
||
case .spotify:
|
||
return spotifyGreen
|
||
case .amazonMusic:
|
||
return amazonOrange
|
||
case .nowPlaying:
|
||
if let bundleIdentifier,
|
||
let bundleColor = brandAccentColor(forBundleIdentifier: bundleIdentifier) {
|
||
return bundleColor
|
||
}
|
||
fallthrough
|
||
case .youtubeMusic:
|
||
return .accentColor
|
||
}
|
||
}
|
||
|
||
private static func brandAccentColor(forBundleIdentifier bundleIdentifier: String) -> Color? {
|
||
switch bundleIdentifier {
|
||
case "com.apple.Music":
|
||
return appleMusicPink
|
||
case "com.spotify.client":
|
||
return spotifyGreen
|
||
case AmazonMusicController.bundleIdentifier:
|
||
return amazonOrange
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private static let appleMusicPink = Color(red: 0.999, green: 0.171, blue: 0.331)
|
||
private static let spotifyGreen = Color(red: 0.0, green: 0.857, blue: 0.302)
|
||
private static let amazonOrange = Color(red: 1.0, green: 0.6, blue: 0.0)
|
||
}
|
||
|
||
// MARK: - Album Art Flip Helper
|
||
|
||
private struct AlbumArtFlipModifier: ViewModifier {
|
||
let angle: Double
|
||
|
||
func body(content: Content) -> some View {
|
||
content
|
||
.rotation3DEffect(
|
||
.degrees(angle),
|
||
axis: (x: 0, y: 1, z: 0),
|
||
anchor: .center,
|
||
anchorZ: 0,
|
||
perspective: 0.5
|
||
)
|
||
// Counter-rotate the content so the image never appears mirrored.
|
||
// At odd multiples of 180° the 3D rotation mirrors along X;
|
||
// applying an opposite scaleEffect cancels that out.
|
||
.scaleEffect(x: cosineSign(for: angle), y: 1)
|
||
}
|
||
|
||
/// Returns +1 when the front face is showing, −1 when the back face is showing.
|
||
private func cosineSign(for degrees: Double) -> CGFloat {
|
||
let cos = Darwin.cos(degrees * .pi / 180)
|
||
// Use a small tolerance to avoid flickering exactly at 90°/270°.
|
||
if cos > 0.001 { return 1 }
|
||
if cos < -0.001 { return -1 }
|
||
return degrees.truncatingRemainder(dividingBy: 360) >= 0 ? -1 : 1
|
||
}
|
||
}
|
||
|
||
extension View {
|
||
func albumArtFlip(angle: Double) -> some View {
|
||
modifier(AlbumArtFlipModifier(angle: angle))
|
||
}
|
||
}
|