Atoll/DynamicIsland/managers/MusicManager.swift
2026-04-29 18:47:38 +02:00

1569 lines
55 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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
}
}
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 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)
// 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()
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.fetchLyrics()
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() {
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) {
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 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))
}
}