feat(media): decouple airplay and output, merged output for lock screen

This commit is contained in:
Amir Zarrinkafsh 2026-03-27 13:03:59 +11:00
commit 9d17e4aafb
No known key found for this signature in database
GPG key ID: ECDB8EF9E77E4EBF
7 changed files with 296 additions and 84 deletions

View file

@ -45,6 +45,9 @@ struct LockScreenMusicPanel: View {
@State private var isActive = true
@State private var isExpanded = false
@State private var isVolumeSliderVisible = false
@State private var isMediaOutputPopoverPresented = false
@State private var isAirPlayPopoverPresented = false
@State private var measuredAirPlayHeight: CGFloat?
@State private var collapseWorkItem: DispatchWorkItem?
@State private var parallaxResumeWorkItem: DispatchWorkItem?
@State private var isParallaxSuspended = false
@ -59,6 +62,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
@ -168,15 +172,19 @@ struct LockScreenMusicPanel: View {
updatePanelSize()
}
.onChange(of: isVolumeSliderVisible) { _, visible in
if visible && isAppleMusicActive {
Task { await airPlayManager.refreshDevices() }
} else {
measuredAirPlayHeight = nil
if useMergedAirPlayOutput {
if visible && isAppleMusicActive {
Task { await airPlayManager.refreshDevices() }
} else {
measuredAirPlayHeight = nil
}
}
updatePanelSize()
}
.onChange(of: airPlayManager.devices) { _, _ in
updatePanelSize()
if useMergedAirPlayOutput {
updatePanelSize()
}
}
.onChange(of: enableLyrics) { _, _ in
withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) {
@ -484,7 +492,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
}
@ -562,7 +570,17 @@ struct LockScreenMusicPanel: View {
musicManager.toggleRepeat()
}
case .mediaOutput:
mediaOutputControlButton
if useMergedAirPlayOutput {
mediaOutputControlButton
} else {
standaloneMediaOutputButton
}
case .airPlay:
if useMergedAirPlayOutput {
mediaOutputControlButton
} else {
standaloneAirPlayButton
}
case .lyrics:
controlButton(
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
@ -640,6 +658,65 @@ struct LockScreenMusicPanel: View {
.accessibilityLabel("Media output")
}
private var standaloneMediaOutputButton: some View {
let frameSize: CGFloat = isExpanded ? 56 : 32
let iconSize: CGFloat = isExpanded ? 26 : 18
return PanelControlButton(
icon: mediaOutputIcon,
frameSize: frameSize,
iconSize: iconSize,
iconColor: isMediaOutputPopoverPresented ? .accentColor : .white.opacity(0.8),
backgroundOpacity: isMediaOutputPopoverPresented ? 0.22 : 0.0,
interaction: .none,
symbolEffect: .replace
) {
registerInteraction()
isMediaOutputPopoverPresented.toggle()
if isMediaOutputPopoverPresented {
routeManager.refreshDevices()
}
}
.accessibilityLabel("Media output")
.popover(isPresented: $isMediaOutputPopoverPresented, arrowEdge: .bottom) {
MediaOutputSelectorPopover(
routeManager: routeManager,
volumeModel: volumeModel,
onHoverChanged: { _ in },
dismiss: { isMediaOutputPopoverPresented = false }
)
}
}
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(
@ -780,8 +857,12 @@ struct LockScreenMusicPanel: View {
musicManager.bundleIdentifier == "com.apple.Music"
}
private var useMergedAirPlayOutput: Bool {
mergedAirPlayOutput && isAppleMusicActive && slotConfig.contains(where: { $0 == .mediaOutput || $0 == .airPlay })
}
private var shouldShowAirPlay: Bool {
shouldShowVolumeSlider && isAppleMusicActive && !airPlayManager.devices.isEmpty
useMergedAirPlayOutput && shouldShowVolumeSlider && !airPlayManager.devices.isEmpty
}
private var shouldShowVolumeSlider: Bool {
@ -796,8 +877,6 @@ 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 {