mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 23:24:32 +00:00
544 lines
18 KiB
Swift
544 lines
18 KiB
Swift
//
|
|
// MusicManager.swift
|
|
// DynamicIsland
|
|
//
|
|
// Created by Harsh Vardhan Goswami on 03/08/24.
|
|
//
|
|
import AppKit
|
|
import Combine
|
|
import Defaults
|
|
import SwiftUI
|
|
|
|
let defaultImage: NSImage = .init(
|
|
systemSymbolName: "heart.fill",
|
|
accessibilityDescription: "Album Art"
|
|
)!
|
|
|
|
class MusicManager: ObservableObject {
|
|
// MARK: - Properties
|
|
static let shared = MusicManager()
|
|
private var cancellables = Set<AnyCancellable>()
|
|
private var controllerCancellables = Set<AnyCancellable>()
|
|
private var debounceIdleTask: Task<Void, Never>?
|
|
|
|
// 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)?
|
|
|
|
// 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 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
|
|
|
|
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
|
|
private let flipAnimationDuration: TimeInterval = 0.45
|
|
|
|
@Published var isTransitioning: Bool = false
|
|
private var transitionWorkItem: DispatchWorkItem?
|
|
|
|
// MARK: - Initialization
|
|
init() {
|
|
// Listen for changes to the default controller preference
|
|
NotificationCenter.default.publisher(for: Notification.Name.mediaControllerChanged)
|
|
.sink { [weak self] _ in
|
|
self?.setActiveControllerBasedOnPreference()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
// 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
|
|
}
|
|
|
|
// Initialize the active controller after deprecation check
|
|
self.setActiveControllerBasedOnPreference()
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
|
|
// MARK: - Update Methods
|
|
@MainActor
|
|
private func updateFromPlaybackState(_ state: PlaybackState) {
|
|
// Check for playback state changes (playing/paused)
|
|
if state.isPlaying != self.isPlaying {
|
|
NSLog("Playback state changed: \(state.isPlaying ? "Playing" : "Paused")")
|
|
withAnimation(.smooth) {
|
|
self.isPlaying = state.isPlaying
|
|
self.updateIdleState(state: state.isPlaying)
|
|
}
|
|
|
|
if state.isPlaying && !state.title.isEmpty && !state.artist.isEmpty {
|
|
self.updateSneakPeek()
|
|
}
|
|
}
|
|
|
|
// 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
|
|
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
|
|
|
|
if artworkChanged || state.artwork == nil {
|
|
// Update last artwork change values
|
|
self.lastArtworkTitle = state.title
|
|
self.lastArtworkArtist = state.artist
|
|
self.lastArtworkAlbum = state.album
|
|
self.lastArtworkBundleIdentifier = state.bundleIdentifier
|
|
}
|
|
|
|
// Only update sneak peek if there's actual content and something changed
|
|
if !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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
private func triggerFlipAnimation() {
|
|
withAnimation(.easeInOut(duration: flipAnimationDuration)) {
|
|
flipAngle += 180
|
|
}
|
|
}
|
|
|
|
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 =
|
|
!isPlaying
|
|
|| (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
|
|
if !hasKnownDuration && isLiveStream {
|
|
isLiveStream = false
|
|
}
|
|
}
|
|
}
|
|
|
|
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() {
|
|
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() {
|
|
Task {
|
|
await activeController?.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 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: - Album Art Flip Helper
|
|
|
|
private struct AlbumArtFlipModifier: ViewModifier {
|
|
let angle: Double
|
|
|
|
private var normalizedAngle: Double {
|
|
var value = angle.truncatingRemainder(dividingBy: 360)
|
|
if value < 0 { value += 360 }
|
|
return value
|
|
}
|
|
|
|
private var mirrorScale: CGFloat {
|
|
(normalizedAngle > 90 && normalizedAngle < 270) ? -1 : 1
|
|
}
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.rotation3DEffect(
|
|
.degrees(angle),
|
|
axis: (x: 0, y: 1, z: 0),
|
|
perspective: 0.65
|
|
)
|
|
.scaleEffect(x: mirrorScale, y: 1)
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func albumArtFlip(angle: Double) -> some View {
|
|
modifier(AlbumArtFlipModifier(angle: angle))
|
|
}
|
|
}
|