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 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue