diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 37deed7..462833b 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -849,35 +849,70 @@ struct CustomSlider: View { private struct MediaOutputPickerButton: View { @ObservedObject private var routeManager = AudioRouteManager.shared + @ObservedObject private var musicManager = MusicManager.shared @StateObject private var volumeModel = MediaOutputVolumeViewModel() + @StateObject private var airPlayManager = AppleMusicAirPlayManager() @State private var isPopoverPresented = false + @State private var isAirPlayPopoverPresented = false @State private var isHoveringPopover = false @EnvironmentObject private var vm: DynamicIslandViewModel + private var isAppleMusicActive: Bool { + musicManager.bundleIdentifier == "com.apple.Music" + } + var body: some View { - HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) { - isPopoverPresented.toggle() - if isPopoverPresented { - routeManager.refreshDevices() + HStack(spacing: 4) { + HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) { + isPopoverPresented.toggle() + if isPopoverPresented { + routeManager.refreshDevices() + } } - } - .accessibilityLabel("Media output") - .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { - MediaOutputSelectorPopover( - routeManager: routeManager, - volumeModel: volumeModel, - onHoverChanged: { hovering in - isHoveringPopover = hovering + .accessibilityLabel("Media output") + .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { + MediaOutputSelectorPopover( + routeManager: routeManager, + volumeModel: volumeModel, + onHoverChanged: { hovering in + isHoveringPopover = hovering + updatePopoverActivity() + } + ) { + isPopoverPresented = false + isHoveringPopover = false updatePopoverActivity() } - ) { - isPopoverPresented = false - isHoveringPopover = false - updatePopoverActivity() + } + + if isAppleMusicActive && !airPlayManager.devices.isEmpty { + HoverButton(icon: "airplayaudio", iconColor: .white, scale: .medium) { + isAirPlayPopoverPresented.toggle() + if isAirPlayPopoverPresented { + Task { await airPlayManager.refreshDevices() } + } + } + .accessibilityLabel("AirPlay") + .popover(isPresented: $isAirPlayPopoverPresented, arrowEdge: .bottom) { + AirPlaySelectorPopover( + airPlayManager: airPlayManager, + onHoverChanged: { hovering in + isHoveringPopover = hovering + updatePopoverActivity() + } + ) { + isAirPlayPopoverPresented = false + isHoveringPopover = false + updatePopoverActivity() + } + } } } .onAppear { routeManager.refreshDevices() + if isAppleMusicActive { + Task { await airPlayManager.refreshDevices() } + } } .onChange(of: isPopoverPresented) { _, presented in if !presented { @@ -885,6 +920,17 @@ private struct MediaOutputPickerButton: View { } updatePopoverActivity() } + .onChange(of: isAirPlayPopoverPresented) { _, presented in + if !presented { + isHoveringPopover = false + } + updatePopoverActivity() + } + .onChange(of: musicManager.bundleIdentifier) { _, newBundle in + if newBundle == "com.apple.Music" { + Task { await airPlayManager.refreshDevices() } + } + } .onDisappear { vm.isMediaOutputPopoverActive = false } @@ -895,7 +941,7 @@ private struct MediaOutputPickerButton: View { } private func updatePopoverActivity() { - vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover + vm.isMediaOutputPopoverActive = (isPopoverPresented || isAirPlayPopoverPresented) && isHoveringPopover } } @@ -1008,7 +1054,7 @@ struct MediaOutputSelectorPopover: View { } .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxHeight: 180) + .frame(maxHeight: 240) } } } @@ -1029,6 +1075,105 @@ struct MediaOutputSelectorPopover: View { } } +struct AirPlaySelectorPopover: View { + @ObservedObject var airPlayManager: AppleMusicAirPlayManager + var onHoverChanged: (Bool) -> Void + var dismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("AirPlay") + .font(.caption) + .foregroundColor(.secondary) + + if airPlayManager.devices.isEmpty { + Text("No AirPlay devices found") + .font(.callout) + .foregroundColor(.secondary) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + ScrollView { + VStack(spacing: 6) { + ForEach(airPlayManager.devices) { device in + VStack(spacing: 4) { + Button { + Task { await airPlayManager.toggleDevice(device) } + } label: { + HStack(spacing: 8) { + Image(systemName: device.iconName) + .font(.system(size: 14, weight: .medium)) + Text(device.name) + .foregroundColor(.primary) + .lineLimit(1) + Spacer() + if device.isSelected { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + } + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(device.isSelected ? Color.primary.opacity(0.12) : .clear) + ) + } + .buttonStyle(.plain) + + if device.isSelected { + AirPlayVolumeSlider( + airPlayManager: airPlayManager, + deviceID: device.id + ) + .padding(.horizontal, 8) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 300) + } + } + .frame(width: 240) + .padding(16) + .onHover { hovering in + onHoverChanged(hovering) + } + .onDisappear { + onHoverChanged(false) + } + } +} + +/// Local @State slider decoupled from the manager's @Published state. +/// This prevents SwiftUI from resetting the slider position when other +/// published properties on the manager change during a drag. +private struct AirPlayVolumeSlider: View { + @ObservedObject var airPlayManager: AppleMusicAirPlayManager + let deviceID: String + + @State private var sliderValue: Double = 0 + @State private var isSyncing = false + + var body: some View { + Slider(value: $sliderValue, in: 0...100) + .tint(.accentColor) + .onAppear { + isSyncing = true + let volume = airPlayManager.devices.first { $0.id == deviceID }?.volume ?? 0 + sliderValue = Double(volume) + isSyncing = false + } + .onChange(of: sliderValue) { _, newValue in + guard !isSyncing else { return } + airPlayManager.setVolume(Int(newValue), for: deviceID) + } + } +} + final class MediaOutputVolumeViewModel: ObservableObject { @Published var level: Float @Published var isMuted: Bool diff --git a/DynamicIsland/managers/AppleMusicAirPlayManager.swift b/DynamicIsland/managers/AppleMusicAirPlayManager.swift new file mode 100644 index 0000000..cc34b89 --- /dev/null +++ b/DynamicIsland/managers/AppleMusicAirPlayManager.swift @@ -0,0 +1,235 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * 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 + +struct AirPlayDevice: Identifiable, Equatable { + let id: String // network address or name as fallback + let name: String + let kind: String + var isSelected: Bool + var volume: Int // 0-100 + + var iconName: String { + switch kind { + case "HomePod": + return "homepodmini" + case "AirPort Express": + return "airport.express" + case "computer": + return "laptopcomputer" + case "AirPlay device": + let normalizedName = name.lowercased() + if normalizedName.contains("tv") || normalizedName.contains("theatre") || normalizedName.contains("theater") { + return "appletv" + } + return "airplayaudio" + default: + return "airplayaudio" + } + } +} + +@MainActor +final class AppleMusicAirPlayManager: ObservableObject { + @Published private(set) var devices: [AirPlayDevice] = [] + @Published private(set) var isLoading = false + + private var isBusy = false + + // MARK: - Volume commit pipeline + // Slider values are written to userSetVolume (non-@Published) and drained + // at throttled intervals into a serialised AppleScript commit queue. + // This avoids triggering SwiftUI re-renders that would reset the Slider. + + private var userSetVolume: [String: Int] = [:] + private var throttleActive: [String: Bool] = [:] + private var lastCommittedVolume: [String: Int] = [:] + private var isCommittingVolume = false + private var pendingVolumeCommits: [(volume: Int, deviceName: String, deviceID: String)] = [] + + // MARK: - Device management + + func refreshDevices() async { + guard !isBusy else { return } + isBusy = true + defer { isBusy = false } + + isLoading = true + defer { isLoading = false } + + let script = """ + tell application "Music" + try + set deviceList to {} + set allDevices to AirPlay devices + repeat with d in allDevices + set deviceName to name of d + set deviceKind to kind of d as string + set deviceActive to selected of d + set deviceAddr to network address of d + set deviceVol to sound volume of d + set end of deviceList to {deviceName, deviceKind, deviceActive, deviceAddr, deviceVol} + end repeat + return deviceList + on error + return {} + end try + end tell + """ + + guard let result = try? await AppleScriptHelper.execute(script) else { return } + + var fetched: [AirPlayDevice] = [] + for i in 1...result.numberOfItems { + guard let item = result.atIndex(i) else { continue } + let name = item.atIndex(1)?.stringValue ?? "Unknown" + let kind = item.atIndex(2)?.stringValue ?? "AirPlay device" + let selected = item.atIndex(3)?.booleanValue ?? false + let addr = item.atIndex(4)?.stringValue ?? "" + let volume = Int(item.atIndex(5)?.int32Value ?? 50) + let id = addr.isEmpty ? name : addr + fetched.append(AirPlayDevice(id: id, name: name, kind: kind, isSelected: selected, volume: volume)) + } + devices = fetched + for device in fetched { + lastCommittedVolume[device.id] = device.volume + } + } + + func toggleDevice(_ device: AirPlayDevice) async { + guard !isBusy else { return } + isBusy = true + defer { isBusy = false } + + let newState = !device.isSelected + let escapedName = device.name.replacingOccurrences(of: "\"", with: "\\\"") + let script = """ + tell application "Music" + try + set targetDevice to first AirPlay device whose name is "\(escapedName)" + set selected of targetDevice to \(newState) + end try + end tell + """ + try? await AppleScriptHelper.executeVoid(script) + try? await Task.sleep(for: .milliseconds(200)) + + isBusy = false + await refreshDevices() + } + + // MARK: - Volume control + + /// Called by the slider on every drag value change. + /// Does NOT mutate any @Published property — this is critical to prevent + /// SwiftUI from recreating the slider view and resetting its @State. + func setVolume(_ volume: Int, for deviceID: String) { + let clamped = min(max(volume, 0), 100) + guard let device = devices.first(where: { $0.id == deviceID }) else { return } + + userSetVolume[deviceID] = clamped + guard throttleActive[deviceID] != true else { return } + + throttleActive[deviceID] = true + Task { [weak self] in + guard let self else { return } + + // Commit immediately, then drain at intervals while the user keeps dragging + if let first = self.userSetVolume.removeValue(forKey: deviceID) { + await self.enqueueVolumeCommit(volume: first, deviceName: device.name, deviceID: deviceID) + } + + var alive = true + while alive { + try? await Task.sleep(for: .milliseconds(150)) + guard let pending = self.userSetVolume.removeValue(forKey: deviceID) else { + alive = false + break + } + await self.enqueueVolumeCommit(volume: pending, deviceName: device.name, deviceID: deviceID) + } + self.throttleActive[deviceID] = false + } + } + + // MARK: - Commit queue + + /// Serialises AppleScript calls — only one runs at a time. + /// Queued entries are deduplicated per-device (only the latest value is kept). + private func enqueueVolumeCommit(volume: Int, deviceName: String, deviceID: String) async { + if isCommittingVolume { + pendingVolumeCommits.removeAll { $0.deviceID == deviceID } + pendingVolumeCommits.append((volume: volume, deviceName: deviceName, deviceID: deviceID)) + return + } + + isCommittingVolume = true + await commitVolume(volume: volume, deviceName: deviceName, deviceID: deviceID) + + while !pendingVolumeCommits.isEmpty { + var latestByDevice: [String: (volume: Int, deviceName: String, deviceID: String)] = [:] + for entry in pendingVolumeCommits { + latestByDevice[entry.deviceID] = entry + } + pendingVolumeCommits.removeAll() + + for (_, entry) in latestByDevice { + await commitVolume(volume: entry.volume, deviceName: entry.deviceName, deviceID: entry.deviceID) + } + } + isCommittingVolume = false + } + + /// Commits a volume change via AppleScript. + /// + /// Apple Music internally rebalances other AirPlay devices when one device's + /// volume drops past another device's volume in a single call. To prevent this, + /// when moving DOWN past the highest other selected device's volume, we first + /// set to that device's volume (waypoint), then to the actual target — at most + /// two AppleScript calls. + private func commitVolume(volume: Int, deviceName: String, deviceID: String) async { + let escapedName = deviceName.replacingOccurrences(of: "\"", with: "\\\"") + let current = lastCommittedVolume[deviceID] ?? volume + + let otherMaxVolume = devices + .filter { $0.id != deviceID && $0.isSelected } + .map { lastCommittedVolume[$0.id] ?? $0.volume } + .max() ?? 0 + + // Waypoint needed when crossing below another device's volume + if current > otherMaxVolume && volume < otherMaxVolume && otherMaxVolume > 0 { + await sendVolumeAppleScript(escapedName: escapedName, volume: otherMaxVolume) + } + + await sendVolumeAppleScript(escapedName: escapedName, volume: volume) + lastCommittedVolume[deviceID] = volume + } + + private func sendVolumeAppleScript(escapedName: String, volume: Int) async { + let script = """ + tell application "Music" + try + set targetDevice to first AirPlay device whose name is "\(escapedName)" + set sound volume of targetDevice to \(volume) + end try + end tell + """ + try? await AppleScriptHelper.executeVoid(script) + } +}