Atoll/DynamicIsland/managers/MusicManager.swift
Hariharan Mudaliar 9ff3ba9748
Merge pull request #325 from AmriteshKhare/feature/auto-detect-pear-desktop
feat(media): auto-detect Pear Desktop for seamless YouTube Music sync
2026-03-10 22:15:48 +05:30

1035 lines
38 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"
)!
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
/// 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 artworkData: Data? = 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
@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()
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()
}
// 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
// Check for artwork changes
let artworkChanged = state.artwork != nil && state.artwork != self.artworkData
let hasContentChange = titleChanged || artistChanged || albumChanged || artworkChanged || bundleChanged
// 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
// Fetch lyrics for new track whenever content changes
self.fetchLyrics()
// Only update sneak peek if there's actual content and something changed
if shouldAutoPeekOnTrackChange && !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying {
self.updateSneakPeek()
}
}
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()
}
}
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 }
Task {
await MainActor.run {
let newState = !isPlaying
pendingOptimisticPlayState = newState
applyPlayState(newState, animation: .smooth(duration: 0.18))
}
await controller.togglePlay()
}
}
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
}
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 .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
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)
}
// 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 }
// At the exact edge, prefer the side we're animating toward.
return degrees.truncatingRemainder(dividingBy: 360) >= 0 ? -1 : 1
}
}
extension View {
func albumArtFlip(angle: Double) -> some View {
modifier(AlbumArtFlipModifier(angle: angle))
}
}