mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +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 {
|
||||
@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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue