Merge pull request #369 from Ebullioscopic/feat/media-airplaydevices

Feat/media airplaydevices
This commit is contained in:
Hariharan Mudaliar 2026-03-28 12:41:59 +05:30 • committed by GitHub
commit aecbe2ac35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 790 additions and 28 deletions

View file

@ -3714,6 +3714,10 @@
}
}
},
"AirPlay" : {
"comment" : "A label for the AirPlay button.",
"isCommentAutoGenerated" : true
},
"Alignment" : {
"localizations" : {
"ko" : {
@ -20355,6 +20359,14 @@
}
}
},
"No AirPlay devices found" : {
"comment" : "A message displayed when no AirPlay devices are found.",
"isCommentAutoGenerated" : true
},
"No AirPlay outputs available" : {
"comment" : "A message displayed when there are no available AirPlay outputs.",
"isCommentAutoGenerated" : true
},
"No audio outputs available" : {
"comment" : "Displayed when no audio output devices can be listed in the media output selector.",
"isCommentAutoGenerated" : true,

View file

@ -37,6 +37,7 @@ struct LockScreenMusicPanel: View {
@ObservedObject var musicManager = MusicManager.shared
@ObservedObject private var routeManager = AudioRouteManager.shared
@StateObject private var volumeModel = MediaOutputVolumeViewModel()
@ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared
@ObservedObject private var animator: LockScreenPanelAnimator
@State private var sliderValue: Double = 0
@State private var dragging: Bool = false
@ -44,6 +45,8 @@ struct LockScreenMusicPanel: View {
@State private var isActive = true
@State private var isExpanded = false
@State private var isVolumeSliderVisible = false
@State private var isAirPlayPopoverPresented = false
@State private var collapseWorkItem: DispatchWorkItem?
@State private var parallaxResumeWorkItem: DispatchWorkItem?
@State private var isParallaxSuspended = false
@ -58,6 +61,7 @@ struct LockScreenMusicPanel: View {
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
@Default(.musicControlSlots) private var slotConfig
@Default(.musicSkipBehavior) private var musicSkipBehavior
@Default(.lockScreenMusicMergedAirPlayOutput) private var mergedAirPlayOutput
@Default(.enableLyrics) private var enableLyrics
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled
@Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth
@ -134,14 +138,17 @@ struct LockScreenMusicPanel: View {
}
.shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10)
.contentShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous))
.animation(.spring(response: 0.48, dampingFraction: 0.82, blendDuration: 0.18), value: isExpanded)
.animation(.spring(response: 0.45, dampingFraction: 0.85, blendDuration: 0.18), value: shouldShowVolumeSlider)
.animation(.easeInOut(duration: 0.28), value: isExpanded)
.animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider)
.onAppear {
sliderValue = musicManager.elapsedTime
isActive = true
logPanelAppearance()
updatePanelSize(animated: false)
routeManager.refreshDevices()
if isAppleMusicActive {
Task { await airPlayManager.refreshDevices() }
}
logGlassState(reason: "Panel appeared")
}
.onDisappear {
@ -163,6 +170,24 @@ struct LockScreenMusicPanel: View {
}
updatePanelSize()
}
.onChange(of: isVolumeSliderVisible) { _, visible in
if useMergedAirPlayOutput {
if visible && isAppleMusicActive {
Task { await airPlayManager.refreshDevices() }
}
}
updatePanelSize()
}
.onChange(of: airPlayManager.devices) { _, _ in
if useMergedAirPlayOutput {
updatePanelSize()
}
}
.onChange(of: routeManager.devices) { _, _ in
if !useMergedAirPlayOutput {
updatePanelSize()
}
}
.onChange(of: enableLyrics) { _, _ in
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
updatePanelSize()
@ -311,7 +336,7 @@ struct LockScreenMusicPanel: View {
private func toggleExpanded() {
let newState = !isExpanded
suspendParallaxInteraction()
withAnimation(.spring(response: 0.5, dampingFraction: 0.82)) {
withAnimation(.easeInOut(duration: 0.28)) {
isExpanded = newState
}
@ -330,7 +355,7 @@ struct LockScreenMusicPanel: View {
let workItem = DispatchWorkItem {
suspendParallaxInteraction()
withAnimation(.spring(response: 0.5, dampingFraction: 0.85)) {
withAnimation(.easeInOut(duration: 0.28)) {
isExpanded = false
}
logPanelAppearance(event: "⏱️ Auto-collapsed")
@ -433,6 +458,16 @@ struct LockScreenMusicPanel: View {
volumeSlider
.frame(maxWidth: .infinity, alignment: alignment)
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
if shouldShowAirPlay {
airPlaySection
.frame(maxWidth: .infinity, alignment: alignment)
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
} else if shouldShowRouteSelector {
mediaOutputDevicesSection
.frame(maxWidth: .infinity, alignment: alignment)
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity))
}
}
if enableLyrics {
@ -442,8 +477,10 @@ struct LockScreenMusicPanel: View {
}
.frame(maxWidth: .infinity, alignment: alignment)
.padding(.top, isExpanded ? 6 : 2)
.animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowVolumeSlider)
.animation(.spring(response: 0.45, dampingFraction: 0.82), value: enableLyrics)
.animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider)
.animation(.easeInOut(duration: 0.24), value: shouldShowAirPlay)
.animation(.easeInOut(duration: 0.24), value: shouldShowRouteSelector)
.animation(.easeInOut(duration: 0.24), value: enableLyrics)
}
private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View {
@ -462,7 +499,7 @@ struct LockScreenMusicPanel: View {
return fallbackSlots
}
let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl)
let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl, isAppleMusicActive: isAppleMusicActive)
return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout
}
@ -541,6 +578,12 @@ struct LockScreenMusicPanel: View {
}
case .mediaOutput:
mediaOutputControlButton
case .airPlay:
if useMergedAirPlayOutput {
mediaOutputControlButton
} else {
standaloneAirPlayButton
}
case .lyrics:
controlButton(
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
@ -618,6 +661,35 @@ struct LockScreenMusicPanel: View {
.accessibilityLabel("Media output")
}
private var standaloneAirPlayButton: some View {
let frameSize: CGFloat = isExpanded ? 56 : 32
let iconSize: CGFloat = isExpanded ? 26 : 18
return PanelControlButton(
icon: "airplayaudio",
frameSize: frameSize,
iconSize: iconSize,
iconColor: isAirPlayPopoverPresented ? .accentColor : .white.opacity(0.8),
backgroundOpacity: isAirPlayPopoverPresented ? 0.22 : 0.0,
interaction: .none,
symbolEffect: .replace
) {
registerInteraction()
isAirPlayPopoverPresented.toggle()
if isAirPlayPopoverPresented {
Task { await airPlayManager.refreshDevices() }
}
}
.accessibilityLabel("AirPlay")
.popover(isPresented: $isAirPlayPopoverPresented, arrowEdge: .bottom) {
AirPlaySelectorPopover(
airPlayManager: airPlayManager,
onHoverChanged: { _ in },
dismiss: { isAirPlayPopoverPresented = false }
)
}
}
private func lyricsSection(alignment: Alignment) -> some View {
let line = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines)
let transition: AnyTransition = .asymmetric(
@ -692,6 +764,116 @@ struct LockScreenMusicPanel: View {
)
}
private var airPlaySection: some View {
accessorySectionContainer {
if airPlayManager.devices.isEmpty {
Text("No AirPlay outputs available")
.font(.system(size: isExpanded ? 13 : 11, weight: .medium))
.foregroundColor(.white.opacity(0.68))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 8)
.padding(.horizontal, 12)
} else {
ScrollView(.vertical, showsIndicators: true) {
VStack(spacing: 6) {
ForEach(airPlayManager.devices) { device in
VStack(spacing: 4) {
Button {
registerInteraction()
Task { await airPlayManager.toggleDevice(device) }
} label: {
HStack(spacing: 8) {
Image(systemName: device.iconName)
.font(.system(size: isExpanded ? 14 : 12, weight: .medium))
.foregroundColor(.white.opacity(0.8))
Text(device.name)
.font(.system(size: isExpanded ? 13 : 11, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
Spacer()
if device.isSelected {
Image(systemName: "checkmark")
.font(.system(size: isExpanded ? 12 : 10, weight: .bold))
.foregroundColor(.accentColor)
}
}
.padding(.vertical, 4)
.padding(.horizontal, 12)
}
.buttonStyle(.plain)
if device.isSelected {
AirPlayVolumeSlider(
airPlayManager: airPlayManager,
deviceID: device.id
)
.padding(.horizontal, 12)
}
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: accessorySectionScrollMaxHeight)
}
}
.padding(.vertical, 8)
}
private var mediaOutputDevicesSection: some View {
accessorySectionContainer {
if routeManager.devices.isEmpty {
Text("No audio outputs available")
.font(.system(size: isExpanded ? 13 : 11, weight: .medium))
.foregroundColor(.white.opacity(0.68))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 8)
.padding(.horizontal, 12)
} else {
ScrollView(.vertical, showsIndicators: true) {
VStack(alignment: .leading, spacing: 6) {
ForEach(routeManager.devices) { device in
Button {
registerInteraction()
routeManager.select(device: device)
} label: {
HStack(spacing: 8) {
Image(systemName: device.iconName)
.font(.system(size: isExpanded ? 14 : 12, weight: .medium))
.foregroundColor(.white.opacity(0.82))
Text(device.name)
.font(.system(size: isExpanded ? 13 : 11, weight: .medium))
.foregroundColor(.white)
.lineLimit(1)
Spacer()
if device.id == routeManager.activeDeviceID {
Image(systemName: "checkmark")
.font(.system(size: isExpanded ? 12 : 10, weight: .bold))
.foregroundColor(.accentColor)
}
}
.padding(.vertical, 5)
.padding(.horizontal, 12)
}
.buttonStyle(.plain)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: accessorySectionScrollMaxHeight)
}
}
.padding(.vertical, 8)
}
private func accessorySectionContainer<Content: View>(@ViewBuilder content: () -> Content) -> some View {
content()
.background(
RoundedRectangle(cornerRadius: isExpanded ? 16 : 12, style: .continuous)
.fill(sliderBackgroundFill)
)
}
private var sliderBackgroundFill: Color {
if usesLiquidGlass {
return Color.white.opacity(0.05)
@ -699,6 +881,26 @@ struct LockScreenMusicPanel: View {
return Color.white.opacity(0.08)
}
private var isAppleMusicActive: Bool {
musicManager.bundleIdentifier == "com.apple.Music"
}
private var useMergedAirPlayOutput: Bool {
mergedAirPlayOutput && isAppleMusicActive && slotConfig.contains(where: { $0 == .mediaOutput || $0 == .airPlay })
}
private var shouldShowAirPlay: Bool {
useMergedAirPlayOutput && shouldShowVolumeSlider && !airPlayManager.devices.isEmpty
}
private var shouldShowRouteSelector: Bool {
!useMergedAirPlayOutput && shouldShowVolumeSlider
}
private var shouldShowAccessorySection: Bool {
shouldShowAirPlay || shouldShowRouteSelector
}
private var shouldShowVolumeSlider: Bool {
showMediaOutputControl && isVolumeSliderVisible
}
@ -707,12 +909,31 @@ struct LockScreenMusicPanel: View {
sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider)
}
private var accessorySectionScrollMaxHeight: CGFloat {
isExpanded ? 170 : 130
}
private var lyricsExtraHeight: CGFloat {
lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics)
}
private var accessorySectionExtraHeight: CGFloat {
guard shouldShowAccessorySection else { return 0 }
if shouldShowAirPlay {
let selectedCount = airPlayManager.devices.filter(\.isSelected).count
let totalCount = airPlayManager.devices.count
let deviceRows: CGFloat = CGFloat(totalCount) * 30 + CGFloat(max(totalCount - 1, 0)) * 6
let sliders: CGFloat = CGFloat(selectedCount) * 34
return min(deviceRows + sliders + 24, accessorySectionScrollMaxHeight + 16)
}
let totalCount = max(routeManager.devices.count, 1)
let deviceRows: CGFloat = CGFloat(totalCount) * 30 + CGFloat(max(totalCount - 1, 0)) * 6
return min(deviceRows + 24, accessorySectionScrollMaxHeight + 16)
}
private var totalExtraHeight: CGFloat {
sliderExtraHeight + lyricsExtraHeight
sliderExtraHeight + accessorySectionExtraHeight + lyricsExtraHeight
}
private func lyricsHeight(forExpanded expanded: Bool, enabled: Bool) -> CGFloat {
@ -722,6 +943,7 @@ struct LockScreenMusicPanel: View {
private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat {
sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) +
accessorySectionExtraHeight +
lyricsHeight(forExpanded: expanded, enabled: enableLyrics)
}
@ -758,9 +980,12 @@ struct LockScreenMusicPanel: View {
let newState = !isVolumeSliderVisible
if newState {
routeManager.refreshDevices()
if useMergedAirPlayOutput {
Task { await airPlayManager.refreshDevices() }
}
}
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
withAnimation(.easeInOut(duration: 0.24)) {
isVolumeSliderVisible = newState
}

View file

@ -876,9 +876,13 @@ private struct MinimalisticReminderDetailsView: View {
)
}
private var isAppleMusicActive: Bool {
musicManager.bundleIdentifier == "com.apple.Music"
}
private var displayedSlots: [MusicControlButton] {
if showCustomControls {
let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl)
let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl, isAppleMusicActive: isAppleMusicActive)
return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout
}
@ -943,6 +947,8 @@ private struct MinimalisticReminderDetailsView: View {
}
case .mediaOutput:
MinimalisticMediaOutputButton()
case .airPlay:
MinimalisticAirPlayButton()
case .lyrics:
controlButton(
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
@ -1027,6 +1033,70 @@ private struct MinimalisticReminderDetailsView: View {
}
}
private struct MinimalisticAirPlayButton: View {
@ObservedObject private var musicManager = MusicManager.shared
@ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared
@EnvironmentObject private var vm: DynamicIslandViewModel
@State private var isPopoverPresented = false
@State private var isHoveringPopover = false
private var isAppleMusicActive: Bool {
musicManager.bundleIdentifier == "com.apple.Music"
}
var body: some View {
MinimalisticSquircircleButton(
icon: "airplayaudio",
fontSize: 18,
fontWeight: .medium,
frameSize: CGSize(width: 40, height: 40),
cornerRadius: 16,
foregroundColor: .white.opacity(0.85),
symbolEffectStyle: .replace
) {
isPopoverPresented.toggle()
if isPopoverPresented {
Task { await airPlayManager.refreshDevices() }
}
}
.accessibilityLabel("AirPlay")
.popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
AirPlaySelectorPopover(
airPlayManager: airPlayManager,
onHoverChanged: { hovering in
isHoveringPopover = hovering
updateActivity()
}
) {
isPopoverPresented = false
isHoveringPopover = false
updateActivity()
}
}
.onChange(of: isPopoverPresented) { _, presented in
if !presented { isHoveringPopover = false }
updateActivity()
}
.onAppear {
if isAppleMusicActive {
Task { await airPlayManager.refreshDevices() }
}
}
.onChange(of: musicManager.bundleIdentifier) { _, newBundle in
if newBundle == "com.apple.Music" {
Task { await airPlayManager.refreshDevices() }
}
}
.onDisappear {
vm.isMediaOutputPopoverActive = false
}
}
private func updateActivity() {
vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
}
}
private var repeatIcon: String {
switch musicManager.repeatMode {
case .off: return "repeat"

View file

@ -398,9 +398,13 @@ struct MusicControlsView: View {
}
}
private var isAppleMusicActive: Bool {
musicManager.bundleIdentifier == "com.apple.Music"
}
private var displayedSlots: [MusicControlButton] {
if showCustomControls {
let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl)
let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl, isAppleMusicActive: isAppleMusicActive)
return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout
}
@ -474,6 +478,8 @@ struct MusicControlsView: View {
}
case .mediaOutput:
MediaOutputPickerButton()
case .airPlay:
AirPlayPickerButton()
case .lyrics:
HoverButton(
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
@ -899,6 +905,62 @@ private struct MediaOutputPickerButton: View {
}
}
private struct AirPlayPickerButton: View {
@ObservedObject private var musicManager = MusicManager.shared
@ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared
@State private var isPopoverPresented = 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: "airplayaudio", iconColor: .white, scale: .medium) {
isPopoverPresented.toggle()
if isPopoverPresented {
Task { await airPlayManager.refreshDevices() }
}
}
.accessibilityLabel("AirPlay")
.popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) {
AirPlaySelectorPopover(
airPlayManager: airPlayManager,
onHoverChanged: { hovering in
isHoveringPopover = hovering
updatePopoverActivity()
}
) {
isPopoverPresented = false
isHoveringPopover = false
updatePopoverActivity()
}
}
.onAppear {
if isAppleMusicActive {
Task { await airPlayManager.refreshDevices() }
}
}
.onChange(of: isPopoverPresented) { _, 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
}
}
private func updatePopoverActivity() {
vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover
}
}
struct MediaOutputSelectorPopover: View {
@ObservedObject var routeManager: AudioRouteManager
@ObservedObject var volumeModel: MediaOutputVolumeViewModel
@ -1008,7 +1070,7 @@ struct MediaOutputSelectorPopover: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
}
.frame(maxHeight: 180)
.frame(maxHeight: 240)
}
}
}
@ -1029,6 +1091,104 @@ 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.
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
sliderValue = Double(airPlayManager.currentVolume(for: deviceID))
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

View file

@ -34,6 +34,10 @@ struct MusicSlotConfigurationView: View {
private let slotCount = MusicControlButton.slotCount
private var isAppleMusicActive: Bool {
musicManager.bundleIdentifier == "com.apple.Music"
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
header
@ -195,15 +199,23 @@ private struct ScrollHintIndicator: View {
}
}
private func isControlDisabled(_ control: MusicControlButton) -> Bool {
if control == .mediaOutput && !showMediaOutputControl { return true }
if control.isAppleMusicExclusive && !isAppleMusicActive { return true }
return false
}
private func paletteItem(for control: MusicControlButton) -> some View {
VStack(spacing: 6) {
let disabled = isControlDisabled(control)
return VStack(spacing: 6) {
RoundedRectangle(cornerRadius: 8)
.fill(Color(nsColor: .controlBackgroundColor))
.frame(width: 44, height: 44)
.overlay {
Image(systemName: control.iconName)
.font(.system(size: control.prefersLargeScale ? 18 : 15, weight: .medium))
.foregroundStyle(control == .mediaOutput && !showMediaOutputControl ? .secondary : .primary)
.foregroundStyle(disabled ? .secondary : .primary)
}
.onDrag {
NSItemProvider(object: NSString(string: "control:\(control.rawValue)"))
@ -220,8 +232,8 @@ private struct ScrollHintIndicator: View {
.minimumScaleFactor(0.8)
.multilineTextAlignment(.center)
}
.opacity(control == .mediaOutput && !showMediaOutputControl ? 0.4 : 1)
.disabled(control == .mediaOutput && !showMediaOutputControl)
.opacity(disabled ? 0.4 : 1)
.disabled(disabled)
}
private func place(_ control: MusicControlButton) {
@ -233,17 +245,20 @@ private struct ScrollHintIndicator: View {
}
private func slotValue(at index: Int) -> MusicControlButton {
let normalized = musicControlSlots.normalized(allowingMediaOutput: showMediaOutputControl)
let normalized = musicControlSlots.normalized(allowingMediaOutput: showMediaOutputControl, isAppleMusicActive: isAppleMusicActive)
guard normalized.indices.contains(index) else { return .none }
return normalized[index]
}
private var pickerOptions: [MusicControlButton] {
let base = MusicControlButton.pickerOptions
if showMediaOutputControl {
return base
var base = MusicControlButton.pickerOptions
if !showMediaOutputControl {
base = base.filter { $0 != .mediaOutput }
}
return base.filter { $0 != .mediaOutput }
if !isAppleMusicActive {
base = base.filter { !$0.isAppleMusicExclusive }
}
return base
}
private func previewIconColor(for slot: MusicControlButton) -> Color {

View file

@ -2443,6 +2443,11 @@ struct Media: View {
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenMusicAlbumParallaxEnabled
@Default(.showStandardMediaControls) private var showStandardMediaControls
@Default(.autoHideInactiveNotchMediaPlayer) private var autoHideInactiveNotchMediaPlayer
@ObservedObject private var musicManager = MusicManager.shared
private var isAppleMusicActive: Bool {
musicManager.bundleIdentifier == "com.apple.Music"
}
private func highlightID(_ title: String) -> String {
SettingsTab.media.highlightID(for: title)
@ -2616,6 +2621,11 @@ struct Media: View {
Text("Show media app icon")
}
.disabled(!enableLockScreenMediaWidget)
if isAppleMusicActive {
Defaults.Toggle("Show merged AirPlay and output devices", key: .lockScreenMusicMergedAirPlayOutput)
.disabled(!enableLockScreenMediaWidget)
.settingsHighlight(id: highlightID("Show merged AirPlay and output devices"))
}
Defaults.Toggle(key: .lockScreenPanelShowsBorder) {
Text("Show panel border")
}
@ -4458,6 +4468,12 @@ struct LockScreenSettings: View {
@Default(.lockScreenCalendarSelectionMode) private var lockScreenCalendarSelectionMode
@Default(.lockScreenSelectedCalendarIDs) private var lockScreenSelectedCalendarIDs
@Default(.lockScreenShowCalendarEventAfterStartEnabled) private var lockScreenShowCalendarEventAfterStartEnabled
@Default(.lockScreenMusicMergedAirPlayOutput) private var lockScreenMusicMergedAirPlayOutput
@ObservedObject private var musicManager = MusicManager.shared
private var isAppleMusicActive: Bool {
musicManager.bundleIdentifier == "com.apple.Music"
}
private func highlightID(_ title: String) -> String {
SettingsTab.lockScreen.highlightID(for: title)
@ -4621,6 +4637,11 @@ struct LockScreenSettings: View {
}
.disabled(!enableLockScreenMediaWidget)
.settingsHighlight(id: highlightID("Show media app icon"))
if isAppleMusicActive {
Defaults.Toggle("Show merged AirPlay and output devices", key: .lockScreenMusicMergedAirPlayOutput)
.disabled(!enableLockScreenMediaWidget)
.settingsHighlight(id: highlightID("Show merged AirPlay and output devices"))
}
Defaults.Toggle(key: .lockScreenPanelShowsBorder) {
Text("Show panel border")
}

View file

@ -0,0 +1,245 @@
/*
* 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 {
static let shared = AppleMusicAirPlayManager()
@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
/// Returns the most recent volume the user set for a device, whether it's
/// still pending, already committed, or (as fallback) the last fetched value.
func currentVolume(for deviceID: String) -> Int {
if let pending = userSetVolume[deviceID] { return pending }
if let committed = lastCommittedVolume[deviceID] { return committed }
return devices.first { $0.id == deviceID }?.volume ?? 0
}
/// 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)
}
}

View file

@ -166,6 +166,8 @@ class LockScreenPanelManager {
return
}
let resizeDuration: CFTimeInterval = 0.28
let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize
let targetWidth = baseSize.width
let targetHeight = baseSize.height + additionalHeight
@ -175,7 +177,7 @@ class LockScreenPanelManager {
if animated {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.45
context.duration = resizeDuration
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
window.animator().setFrame(targetFrame, display: true)
}
@ -191,7 +193,8 @@ class LockScreenPanelManager {
let targetRadius = expanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius
if animated {
CATransaction.begin()
CATransaction.setAnimationDuration(0.28)
CATransaction.setAnimationDuration(resizeDuration)
CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeInEaseOut))
window.contentView?.layer?.cornerRadius = targetRadius
CATransaction.commit()
} else {

View file

@ -834,6 +834,7 @@ extension Defaults.Keys {
static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: false)
static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true)
static let lockScreenMusicMergedAirPlayOutput = Key<Bool>("lockScreenMusicMergedAirPlayOutput", default: true)
static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: false)
static let lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor)
static let lockScreenReminderWidgetHorizontalAlignment = Key<String>("lockScreenReminderWidgetHorizontalAlignment", default: "center")

View file

@ -29,6 +29,7 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S
case trackForward
case repeatMode
case mediaOutput
case airPlay
case lyrics
case seekBackward
case seekForward
@ -61,9 +62,15 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S
.shuffle,
.repeatMode,
.lyrics,
.mediaOutput
.mediaOutput,
.airPlay
]
/// Controls that are only available when Apple Music is the active media source.
var isAppleMusicExclusive: Bool {
self == .airPlay
}
var id: String { rawValue }
var label: String {
@ -80,6 +87,8 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S
return "Repeat"
case .mediaOutput:
return "Change Media Output"
case .airPlay:
return "AirPlay"
case .lyrics:
return "Lyrics"
case .seekBackward:
@ -105,6 +114,8 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S
return "repeat"
case .mediaOutput:
return "speaker.wave.2"
case .airPlay:
return "airplayaudio"
case .lyrics:
return "quote.bubble"
case .seekBackward:
@ -122,11 +133,10 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S
}
extension Array where Element == MusicControlButton {
func normalized(allowingMediaOutput: Bool) -> [MusicControlButton] {
func normalized(allowingMediaOutput: Bool, isAppleMusicActive: Bool = true) -> [MusicControlButton] {
var sanitized = map { button -> MusicControlButton in
if button == .mediaOutput && !allowingMediaOutput {
return .none
}
if button == .mediaOutput && !allowingMediaOutput { return .none }
if button.isAppleMusicExclusive && !isAppleMusicActive { return .none }
return button
}