mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
feat(media): include airplay for apple music on lock screen widget
This commit is contained in:
parent
75bb8ce9be
commit
5e1e8f613b
3 changed files with 115 additions and 5 deletions
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue