From 5e1e8f613ba0783b774055087fe940643b3467ed Mon Sep 17 00:00:00 2001 From: Amir Zarrinkafsh <3339418+nightah@users.noreply.github.com> Date: Sun, 22 Mar 2026 21:26:27 +1100 Subject: [PATCH] feat(media): include airplay for apple music on lock screen widget --- .../LockScreen/LockScreenMusicPanel.swift | 103 +++++++++++++++++- .../components/Notch/NotchHomeView.swift | 7 +- .../managers/AppleMusicAirPlayManager.swift | 10 ++ 3 files changed, 115 insertions(+), 5 deletions(-) diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 2b32047..37001be 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -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 @@ -142,6 +143,9 @@ struct LockScreenMusicPanel: View { logPanelAppearance() updatePanelSize(animated: false) routeManager.refreshDevices() + if isAppleMusicActive { + Task { await airPlayManager.refreshDevices() } + } logGlassState(reason: "Panel appeared") } .onDisappear { @@ -163,6 +167,17 @@ struct LockScreenMusicPanel: View { } updatePanelSize() } + .onChange(of: isVolumeSliderVisible) { _, visible in + if visible && isAppleMusicActive { + Task { await airPlayManager.refreshDevices() } + } else { + measuredAirPlayHeight = nil + } + updatePanelSize() + } + .onChange(of: airPlayManager.devices) { _, _ in + updatePanelSize() + } .onChange(of: enableLyrics) { _, _ in withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) { updatePanelSize() @@ -433,6 +448,12 @@ 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)) + } } if enableLyrics { @@ -443,6 +464,7 @@ 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: shouldShowAirPlay) .animation(.spring(response: 0.45, dampingFraction: 0.82), value: enableLyrics) } @@ -692,6 +714,61 @@ struct LockScreenMusicPanel: View { ) } + private var airPlaySection: some View { + 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) + } + } + } + } + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: isExpanded ? 16 : 12, style: .continuous) + .fill(sliderBackgroundFill) + ) + .background( + GeometryReader { geo in + Color.clear + .onAppear { measuredAirPlayHeight = geo.size.height } + .onChange(of: geo.size.height) { _, newHeight in + measuredAirPlayHeight = newHeight + updatePanelSize() + } + } + ) + } + private var sliderBackgroundFill: Color { if usesLiquidGlass { return Color.white.opacity(0.05) @@ -699,6 +776,14 @@ struct LockScreenMusicPanel: View { return Color.white.opacity(0.08) } + private var isAppleMusicActive: Bool { + musicManager.bundleIdentifier == "com.apple.Music" + } + + private var shouldShowAirPlay: Bool { + shouldShowVolumeSlider && isAppleMusicActive && !airPlayManager.devices.isEmpty + } + private var shouldShowVolumeSlider: Bool { showMediaOutputControl && isVolumeSliderVisible } @@ -711,8 +796,23 @@ struct LockScreenMusicPanel: View { lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics) } + @State private var measuredAirPlayHeight: CGFloat? + + private var airPlayExtraHeight: CGFloat { + guard shouldShowAirPlay else { return 0 } + if let measured = measuredAirPlayHeight { + return measured + } + // Fallback estimate used only until the first GeometryReader measurement arrives + let selectedCount = airPlayManager.devices.filter(\.isSelected).count + let totalCount = airPlayManager.devices.count + let deviceRows: CGFloat = CGFloat(totalCount) * 22 + CGFloat(max(totalCount - 1, 0)) * 6 + let sliders: CGFloat = CGFloat(selectedCount) * 24 + return deviceRows + sliders + 16 + } + private var totalExtraHeight: CGFloat { - sliderExtraHeight + lyricsExtraHeight + sliderExtraHeight + airPlayExtraHeight + lyricsExtraHeight } private func lyricsHeight(forExpanded expanded: Bool, enabled: Bool) -> CGFloat { @@ -722,6 +822,7 @@ struct LockScreenMusicPanel: View { private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat { sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) + + airPlayExtraHeight + lyricsHeight(forExpanded: expanded, enabled: enableLyrics) } diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 462833b..0da7d2e 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -851,7 +851,7 @@ 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() + @ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared @State private var isPopoverPresented = false @State private var isAirPlayPopoverPresented = false @State private var isHoveringPopover = false @@ -1151,7 +1151,7 @@ struct AirPlaySelectorPopover: View { /// 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 { +struct AirPlayVolumeSlider: View { @ObservedObject var airPlayManager: AppleMusicAirPlayManager let deviceID: String @@ -1163,8 +1163,7 @@ private struct AirPlayVolumeSlider: View { .tint(.accentColor) .onAppear { isSyncing = true - let volume = airPlayManager.devices.first { $0.id == deviceID }?.volume ?? 0 - sliderValue = Double(volume) + sliderValue = Double(airPlayManager.currentVolume(for: deviceID)) isSyncing = false } .onChange(of: sliderValue) { _, newValue in diff --git a/DynamicIsland/managers/AppleMusicAirPlayManager.swift b/DynamicIsland/managers/AppleMusicAirPlayManager.swift index cc34b89..75210bf 100644 --- a/DynamicIsland/managers/AppleMusicAirPlayManager.swift +++ b/DynamicIsland/managers/AppleMusicAirPlayManager.swift @@ -47,6 +47,8 @@ struct AirPlayDevice: Identifiable, Equatable { @MainActor final class AppleMusicAirPlayManager: ObservableObject { + static let shared = AppleMusicAirPlayManager() + @Published private(set) var devices: [AirPlayDevice] = [] @Published private(set) var isLoading = false @@ -136,6 +138,14 @@ final class AppleMusicAirPlayManager: ObservableObject { // 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.