mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 23:24:32 +00:00
feat(media): add airplay device selector with volume control for apple music
Adds an AirPlay device picker popover to the music player controls, allowing users to select and control volume for individual AirPlay devices (HomePod, Apple TV, AirPort Express, etc.) when Apple Music is the active player.
This commit is contained in:
parent
6bc3af0431
commit
75bb8ce9be
2 changed files with 398 additions and 18 deletions
|
|
@ -849,35 +849,70 @@ struct CustomSlider: View {
|
||||||
|
|
||||||
private struct MediaOutputPickerButton: View {
|
private struct MediaOutputPickerButton: View {
|
||||||
@ObservedObject private var routeManager = AudioRouteManager.shared
|
@ObservedObject private var routeManager = AudioRouteManager.shared
|
||||||
|
@ObservedObject private var musicManager = MusicManager.shared
|
||||||
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
|
||||||
|
@StateObject private var airPlayManager = AppleMusicAirPlayManager()
|
||||||
@State private var isPopoverPresented = false
|
@State private var isPopoverPresented = false
|
||||||
|
@State private var isAirPlayPopoverPresented = false
|
||||||
@State private var isHoveringPopover = false
|
@State private var isHoveringPopover = false
|
||||||
@EnvironmentObject private var vm: DynamicIslandViewModel
|
@EnvironmentObject private var vm: DynamicIslandViewModel
|
||||||
|
|
||||||
|
private var isAppleMusicActive: Bool {
|
||||||
|
musicManager.bundleIdentifier == "com.apple.Music"
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) {
|
HStack(spacing: 4) {
|
||||||
isPopoverPresented.toggle()
|
HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) {
|
||||||
if isPopoverPresented {
|
isPopoverPresented.toggle()
|
||||||
routeManager.refreshDevices()
|
if isPopoverPresented {
|
||||||
|
routeManager.refreshDevices()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
.accessibilityLabel("Media output")
|
||||||
.accessibilityLabel("Media output")
|
.popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
|
||||||
.popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
|
MediaOutputSelectorPopover(
|
||||||
MediaOutputSelectorPopover(
|
routeManager: routeManager,
|
||||||
routeManager: routeManager,
|
volumeModel: volumeModel,
|
||||||
volumeModel: volumeModel,
|
onHoverChanged: { hovering in
|
||||||
onHoverChanged: { hovering in
|
isHoveringPopover = hovering
|
||||||
isHoveringPopover = hovering
|
updatePopoverActivity()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
isPopoverPresented = false
|
||||||
|
isHoveringPopover = false
|
||||||
updatePopoverActivity()
|
updatePopoverActivity()
|
||||||
}
|
}
|
||||||
) {
|
}
|
||||||
isPopoverPresented = false
|
|
||||||
isHoveringPopover = false
|
if isAppleMusicActive && !airPlayManager.devices.isEmpty {
|
||||||
updatePopoverActivity()
|
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 {
|
.onAppear {
|
||||||
routeManager.refreshDevices()
|
routeManager.refreshDevices()
|
||||||
|
if isAppleMusicActive {
|
||||||
|
Task { await airPlayManager.refreshDevices() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: isPopoverPresented) { _, presented in
|
.onChange(of: isPopoverPresented) { _, presented in
|
||||||
if !presented {
|
if !presented {
|
||||||
|
|
@ -885,6 +920,17 @@ private struct MediaOutputPickerButton: View {
|
||||||
}
|
}
|
||||||
updatePopoverActivity()
|
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 {
|
.onDisappear {
|
||||||
vm.isMediaOutputPopoverActive = false
|
vm.isMediaOutputPopoverActive = false
|
||||||
}
|
}
|
||||||
|
|
@ -895,7 +941,7 @@ private struct MediaOutputPickerButton: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updatePopoverActivity() {
|
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(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 {
|
final class MediaOutputVolumeViewModel: ObservableObject {
|
||||||
@Published var level: Float
|
@Published var level: Float
|
||||||
@Published var isMuted: Bool
|
@Published var isMuted: Bool
|
||||||
|
|
|
||||||
235
DynamicIsland/managers/AppleMusicAirPlayManager.swift
Normal file
235
DynamicIsland/managers/AppleMusicAirPlayManager.swift
Normal file
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue