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:
Amir Zarrinkafsh 2026-03-22 17:27:00 +11:00
commit 75bb8ce9be
No known key found for this signature in database
GPG key ID: ECDB8EF9E77E4EBF
2 changed files with 398 additions and 18 deletions

View file

@ -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