/*
* 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 .
*/
import Foundation
import Combine
import SwiftUI
class SpotifyController: MediaControllerProtocol {
// MARK: - Properties
@Published private var playbackState: PlaybackState = PlaybackState(
bundleIdentifier: "com.spotify.client"
)
var playbackStatePublisher: AnyPublisher {
$playbackState.eraseToAnyPublisher()
}
var isWorking: Bool {
return true // Spotify controller always works
}
private var notificationTask: Task?
// Constant for time between command and update
private let commandUpdateDelay: Duration = .milliseconds(25)
private var lastArtworkURL: String?
private var artworkFetchTask: Task?
init() {
setupPlaybackStateChangeObserver()
Task {
if isActive() {
await updatePlaybackInfo()
}
}
}
private func setupPlaybackStateChangeObserver() {
notificationTask = Task { @Sendable [weak self] in
let notifications = DistributedNotificationCenter.default().notifications(
named: NSNotification.Name("com.spotify.client.PlaybackStateChanged")
)
for await _ in notifications {
await self?.updatePlaybackInfo()
}
}
}
deinit {
notificationTask?.cancel()
artworkFetchTask?.cancel()
}
// MARK: - Protocol Implementation
func play() async { await executeCommand("play") }
func pause() async { await executeCommand("pause") }
func togglePlay() async { await executeCommand("playpause") }
func nextTrack() async { await executeCommand("next track") }
func previousTrack() async {
await executeAndRefresh("previous track")
}
func seek(to time: Double) async {
await executeAndRefresh("set player position to \(time)")
}
func toggleShuffle() async {
await executeAndRefresh("set shuffling to not shuffling")
}
func toggleRepeat() async {
await executeAndRefresh("set repeating to not repeating")
}
func isActive() -> Bool {
NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == playbackState.bundleIdentifier }
}
func updatePlaybackInfo() async {
guard let descriptor = try? await fetchPlaybackInfoAsync() else { return }
guard descriptor.numberOfItems >= 9 else { return }
let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false
let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown"
let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown"
let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown"
let currentTime = descriptor.atIndex(5)?.doubleValue ?? 0
let duration = (descriptor.atIndex(6)?.doubleValue ?? 0)/1000
let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false
let artworkURL = descriptor.atIndex(9)?.stringValue ?? ""
var state = PlaybackState(
bundleIdentifier: "com.spotify.client",
isPlaying: isPlaying,
title: currentTrack,
artist: currentTrackArtist,
album: currentTrackAlbum,
currentTime: currentTime,
duration: duration,
playbackRate: 1,
isShuffled: isShuffled,
repeatMode: isRepeating ? .all : .off,
lastUpdated: Date()
)
if artworkURL == lastArtworkURL, let existingArtwork = self.playbackState.artwork {
state.artwork = existingArtwork
}
playbackState = state
if !artworkURL.isEmpty, let url = URL(string: artworkURL) {
guard artworkURL != lastArtworkURL || state.artwork == nil else { return }
artworkFetchTask?.cancel()
let currentState = state
artworkFetchTask = Task {
do {
let data = try await ImageService.shared.fetchImageData(from: url)
await MainActor.run { [weak self] in
guard let self = self else { return }
var updatedState = currentState
updatedState.artwork = data
self.playbackState = updatedState
self.lastArtworkURL = artworkURL
self.artworkFetchTask = nil
}
} catch {
await MainActor.run { [weak self] in
self?.artworkFetchTask = nil
}
}
}
}
}
// MARK: - Private Methods
private func executeCommand(_ command: String) async {
let script = "tell application \"Spotify\" to \(command)"
try? await AppleScriptHelper.executeVoid(script)
}
private func executeAndRefresh(_ command: String) async {
await executeCommand(command)
try? await Task.sleep(for: commandUpdateDelay)
await updatePlaybackInfo()
}
private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? {
let script = """
tell application "Spotify"
set isRunning to true
try
set playerState to player state is playing
set currentTrackName to name of current track
set currentTrackArtist to artist of current track
set currentTrackAlbum to album of current track
set trackPosition to player position
set trackDuration to duration of current track
set shuffleState to shuffling
set repeatState to repeating
set artworkURL to artwork url of current track
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL}
on error
return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, ""}
end try
end tell
"""
return try await AppleScriptHelper.execute(script)
}
}