feat(media): include airplay for apple music on lock screen widget

This commit is contained in:
Amir Zarrinkafsh 2026-03-22 21:26:27 +11:00
commit 5e1e8f613b
No known key found for this signature in database
GPG key ID: ECDB8EF9E77E4EBF
3 changed files with 115 additions and 5 deletions

View file

@ -37,6 +37,7 @@ struct LockScreenMusicPanel: View {
@ObservedObject var musicManager = MusicManager.shared @ObservedObject var musicManager = MusicManager.shared
@ObservedObject private var routeManager = AudioRouteManager.shared @ObservedObject private var routeManager = AudioRouteManager.shared
@StateObject private var volumeModel = MediaOutputVolumeViewModel() @StateObject private var volumeModel = MediaOutputVolumeViewModel()
@ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared
@ObservedObject private var animator: LockScreenPanelAnimator @ObservedObject private var animator: LockScreenPanelAnimator
@State private var sliderValue: Double = 0 @State private var sliderValue: Double = 0
@State private var dragging: Bool = false @State private var dragging: Bool = false
@ -142,6 +143,9 @@ struct LockScreenMusicPanel: View {
logPanelAppearance() logPanelAppearance()
updatePanelSize(animated: false) updatePanelSize(animated: false)
routeManager.refreshDevices() routeManager.refreshDevices()
if isAppleMusicActive {
Task { await airPlayManager.refreshDevices() }
}
logGlassState(reason: "Panel appeared") logGlassState(reason: "Panel appeared")
} }
.onDisappear { .onDisappear {
@ -163,6 +167,17 @@ struct LockScreenMusicPanel: View {
} }
updatePanelSize() 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 .onChange(of: enableLyrics) { _, _ in
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) { withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
updatePanelSize() updatePanelSize()
@ -433,6 +448,12 @@ struct LockScreenMusicPanel: View {
volumeSlider volumeSlider
.frame(maxWidth: .infinity, alignment: alignment) .frame(maxWidth: .infinity, alignment: alignment)
.transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity)) .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 { if enableLyrics {
@ -443,6 +464,7 @@ struct LockScreenMusicPanel: View {
.frame(maxWidth: .infinity, alignment: alignment) .frame(maxWidth: .infinity, alignment: alignment)
.padding(.top, isExpanded ? 6 : 2) .padding(.top, isExpanded ? 6 : 2)
.animation(.spring(response: 0.45, dampingFraction: 0.82), value: shouldShowVolumeSlider) .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) .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 { private var sliderBackgroundFill: Color {
if usesLiquidGlass { if usesLiquidGlass {
return Color.white.opacity(0.05) return Color.white.opacity(0.05)
@ -699,6 +776,14 @@ struct LockScreenMusicPanel: View {
return Color.white.opacity(0.08) 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 { private var shouldShowVolumeSlider: Bool {
showMediaOutputControl && isVolumeSliderVisible showMediaOutputControl && isVolumeSliderVisible
} }
@ -711,8 +796,23 @@ struct LockScreenMusicPanel: View {
lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics) 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 { private var totalExtraHeight: CGFloat {
sliderExtraHeight + lyricsExtraHeight sliderExtraHeight + airPlayExtraHeight + lyricsExtraHeight
} }
private func lyricsHeight(forExpanded expanded: Bool, enabled: Bool) -> CGFloat { private func lyricsHeight(forExpanded expanded: Bool, enabled: Bool) -> CGFloat {
@ -722,6 +822,7 @@ struct LockScreenMusicPanel: View {
private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat { private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat {
sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) + sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) +
airPlayExtraHeight +
lyricsHeight(forExpanded: expanded, enabled: enableLyrics) lyricsHeight(forExpanded: expanded, enabled: enableLyrics)
} }

View file

@ -851,7 +851,7 @@ private struct MediaOutputPickerButton: View {
@ObservedObject private var routeManager = AudioRouteManager.shared @ObservedObject private var routeManager = AudioRouteManager.shared
@ObservedObject private var musicManager = MusicManager.shared @ObservedObject private var musicManager = MusicManager.shared
@StateObject private var volumeModel = MediaOutputVolumeViewModel() @StateObject private var volumeModel = MediaOutputVolumeViewModel()
@StateObject private var airPlayManager = AppleMusicAirPlayManager() @ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared
@State private var isPopoverPresented = false @State private var isPopoverPresented = false
@State private var isAirPlayPopoverPresented = false @State private var isAirPlayPopoverPresented = false
@State private var isHoveringPopover = false @State private var isHoveringPopover = false
@ -1151,7 +1151,7 @@ struct AirPlaySelectorPopover: View {
/// Local @State slider decoupled from the manager's @Published state. /// Local @State slider decoupled from the manager's @Published state.
/// This prevents SwiftUI from resetting the slider position when other /// This prevents SwiftUI from resetting the slider position when other
/// published properties on the manager change during a drag. /// published properties on the manager change during a drag.
private struct AirPlayVolumeSlider: View { struct AirPlayVolumeSlider: View {
@ObservedObject var airPlayManager: AppleMusicAirPlayManager @ObservedObject var airPlayManager: AppleMusicAirPlayManager
let deviceID: String let deviceID: String
@ -1163,8 +1163,7 @@ private struct AirPlayVolumeSlider: View {
.tint(.accentColor) .tint(.accentColor)
.onAppear { .onAppear {
isSyncing = true isSyncing = true
let volume = airPlayManager.devices.first { $0.id == deviceID }?.volume ?? 0 sliderValue = Double(airPlayManager.currentVolume(for: deviceID))
sliderValue = Double(volume)
isSyncing = false isSyncing = false
} }
.onChange(of: sliderValue) { _, newValue in .onChange(of: sliderValue) { _, newValue in

View file

@ -47,6 +47,8 @@ struct AirPlayDevice: Identifiable, Equatable {
@MainActor @MainActor
final class AppleMusicAirPlayManager: ObservableObject { final class AppleMusicAirPlayManager: ObservableObject {
static let shared = AppleMusicAirPlayManager()
@Published private(set) var devices: [AirPlayDevice] = [] @Published private(set) var devices: [AirPlayDevice] = []
@Published private(set) var isLoading = false @Published private(set) var isLoading = false
@ -136,6 +138,14 @@ final class AppleMusicAirPlayManager: ObservableObject {
// MARK: - Volume control // 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. /// Called by the slider on every drag value change.
/// Does NOT mutate any @Published property — this is critical to prevent /// Does NOT mutate any @Published property — this is critical to prevent
/// SwiftUI from recreating the slider view and resetting its @State. /// SwiftUI from recreating the slider view and resetting its @State.