diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 857d9af..a6ab283 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -3714,6 +3714,10 @@ } } }, + "AirPlay" : { + "comment" : "A label for the AirPlay button.", + "isCommentAutoGenerated" : true + }, "Alignment" : { "localizations" : { "ko" : { @@ -20355,6 +20359,14 @@ } } }, + "No AirPlay devices found" : { + "comment" : "A message displayed when no AirPlay devices are found.", + "isCommentAutoGenerated" : true + }, + "No AirPlay outputs available" : { + "comment" : "A message displayed when there are no available AirPlay outputs.", + "isCommentAutoGenerated" : true + }, "No audio outputs available" : { "comment" : "Displayed when no audio output devices can be listed in the media output selector.", "isCommentAutoGenerated" : true, diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 2b32047..4dc6eae 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 @@ -44,6 +45,8 @@ struct LockScreenMusicPanel: View { @State private var isActive = true @State private var isExpanded = false @State private var isVolumeSliderVisible = false + @State private var isAirPlayPopoverPresented = false + @State private var collapseWorkItem: DispatchWorkItem? @State private var parallaxResumeWorkItem: DispatchWorkItem? @State private var isParallaxSuspended = false @@ -58,6 +61,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 @@ -134,14 +138,17 @@ struct LockScreenMusicPanel: View { } .shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10) .contentShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) - .animation(.spring(response: 0.48, dampingFraction: 0.82, blendDuration: 0.18), value: isExpanded) - .animation(.spring(response: 0.45, dampingFraction: 0.85, blendDuration: 0.18), value: shouldShowVolumeSlider) + .animation(.easeInOut(duration: 0.28), value: isExpanded) + .animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider) .onAppear { sliderValue = musicManager.elapsedTime isActive = true logPanelAppearance() updatePanelSize(animated: false) routeManager.refreshDevices() + if isAppleMusicActive { + Task { await airPlayManager.refreshDevices() } + } logGlassState(reason: "Panel appeared") } .onDisappear { @@ -163,6 +170,24 @@ struct LockScreenMusicPanel: View { } updatePanelSize() } + .onChange(of: isVolumeSliderVisible) { _, visible in + if useMergedAirPlayOutput { + if visible && isAppleMusicActive { + Task { await airPlayManager.refreshDevices() } + } + } + updatePanelSize() + } + .onChange(of: airPlayManager.devices) { _, _ in + if useMergedAirPlayOutput { + updatePanelSize() + } + } + .onChange(of: routeManager.devices) { _, _ in + if !useMergedAirPlayOutput { + updatePanelSize() + } + } .onChange(of: enableLyrics) { _, _ in withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) { updatePanelSize() @@ -311,7 +336,7 @@ struct LockScreenMusicPanel: View { private func toggleExpanded() { let newState = !isExpanded suspendParallaxInteraction() - withAnimation(.spring(response: 0.5, dampingFraction: 0.82)) { + withAnimation(.easeInOut(duration: 0.28)) { isExpanded = newState } @@ -330,7 +355,7 @@ struct LockScreenMusicPanel: View { let workItem = DispatchWorkItem { suspendParallaxInteraction() - withAnimation(.spring(response: 0.5, dampingFraction: 0.85)) { + withAnimation(.easeInOut(duration: 0.28)) { isExpanded = false } logPanelAppearance(event: "⏱️ Auto-collapsed") @@ -433,6 +458,16 @@ 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)) + } else if shouldShowRouteSelector { + mediaOutputDevicesSection + .frame(maxWidth: .infinity, alignment: alignment) + .transition(.scale(scale: 0.98, anchor: .top).combined(with: .opacity)) + } } if enableLyrics { @@ -442,8 +477,10 @@ 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: enableLyrics) + .animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider) + .animation(.easeInOut(duration: 0.24), value: shouldShowAirPlay) + .animation(.easeInOut(duration: 0.24), value: shouldShowRouteSelector) + .animation(.easeInOut(duration: 0.24), value: enableLyrics) } private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View { @@ -462,7 +499,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 } @@ -541,6 +578,12 @@ struct LockScreenMusicPanel: View { } case .mediaOutput: mediaOutputControlButton + case .airPlay: + if useMergedAirPlayOutput { + mediaOutputControlButton + } else { + standaloneAirPlayButton + } case .lyrics: controlButton( icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble", @@ -618,6 +661,35 @@ struct LockScreenMusicPanel: View { .accessibilityLabel("Media output") } + 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( @@ -692,6 +764,116 @@ struct LockScreenMusicPanel: View { ) } + private var airPlaySection: some View { + accessorySectionContainer { + if airPlayManager.devices.isEmpty { + Text("No AirPlay outputs available") + .font(.system(size: isExpanded ? 13 : 11, weight: .medium)) + .foregroundColor(.white.opacity(0.68)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + .padding(.horizontal, 12) + } else { + ScrollView(.vertical, showsIndicators: true) { + 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) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: accessorySectionScrollMaxHeight) + } + } + .padding(.vertical, 8) + } + + private var mediaOutputDevicesSection: some View { + accessorySectionContainer { + if routeManager.devices.isEmpty { + Text("No audio outputs available") + .font(.system(size: isExpanded ? 13 : 11, weight: .medium)) + .foregroundColor(.white.opacity(0.68)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 8) + .padding(.horizontal, 12) + } else { + ScrollView(.vertical, showsIndicators: true) { + VStack(alignment: .leading, spacing: 6) { + ForEach(routeManager.devices) { device in + Button { + registerInteraction() + routeManager.select(device: device) + } label: { + HStack(spacing: 8) { + Image(systemName: device.iconName) + .font(.system(size: isExpanded ? 14 : 12, weight: .medium)) + .foregroundColor(.white.opacity(0.82)) + Text(device.name) + .font(.system(size: isExpanded ? 13 : 11, weight: .medium)) + .foregroundColor(.white) + .lineLimit(1) + Spacer() + if device.id == routeManager.activeDeviceID { + Image(systemName: "checkmark") + .font(.system(size: isExpanded ? 12 : 10, weight: .bold)) + .foregroundColor(.accentColor) + } + } + .padding(.vertical, 5) + .padding(.horizontal, 12) + } + .buttonStyle(.plain) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: accessorySectionScrollMaxHeight) + } + } + .padding(.vertical, 8) + } + + private func accessorySectionContainer(@ViewBuilder content: () -> Content) -> some View { + content() + .background( + RoundedRectangle(cornerRadius: isExpanded ? 16 : 12, style: .continuous) + .fill(sliderBackgroundFill) + ) + } + private var sliderBackgroundFill: Color { if usesLiquidGlass { return Color.white.opacity(0.05) @@ -699,6 +881,26 @@ struct LockScreenMusicPanel: View { return Color.white.opacity(0.08) } + private var isAppleMusicActive: Bool { + musicManager.bundleIdentifier == "com.apple.Music" + } + + private var useMergedAirPlayOutput: Bool { + mergedAirPlayOutput && isAppleMusicActive && slotConfig.contains(where: { $0 == .mediaOutput || $0 == .airPlay }) + } + + private var shouldShowAirPlay: Bool { + useMergedAirPlayOutput && shouldShowVolumeSlider && !airPlayManager.devices.isEmpty + } + + private var shouldShowRouteSelector: Bool { + !useMergedAirPlayOutput && shouldShowVolumeSlider + } + + private var shouldShowAccessorySection: Bool { + shouldShowAirPlay || shouldShowRouteSelector + } + private var shouldShowVolumeSlider: Bool { showMediaOutputControl && isVolumeSliderVisible } @@ -707,12 +909,31 @@ struct LockScreenMusicPanel: View { sliderHeight(forExpanded: isExpanded, visible: shouldShowVolumeSlider) } + private var accessorySectionScrollMaxHeight: CGFloat { + isExpanded ? 170 : 130 + } + private var lyricsExtraHeight: CGFloat { lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics) } + private var accessorySectionExtraHeight: CGFloat { + guard shouldShowAccessorySection else { return 0 } + if shouldShowAirPlay { + let selectedCount = airPlayManager.devices.filter(\.isSelected).count + let totalCount = airPlayManager.devices.count + let deviceRows: CGFloat = CGFloat(totalCount) * 30 + CGFloat(max(totalCount - 1, 0)) * 6 + let sliders: CGFloat = CGFloat(selectedCount) * 34 + return min(deviceRows + sliders + 24, accessorySectionScrollMaxHeight + 16) + } + + let totalCount = max(routeManager.devices.count, 1) + let deviceRows: CGFloat = CGFloat(totalCount) * 30 + CGFloat(max(totalCount - 1, 0)) * 6 + return min(deviceRows + 24, accessorySectionScrollMaxHeight + 16) + } + private var totalExtraHeight: CGFloat { - sliderExtraHeight + lyricsExtraHeight + sliderExtraHeight + accessorySectionExtraHeight + lyricsExtraHeight } private func lyricsHeight(forExpanded expanded: Bool, enabled: Bool) -> CGFloat { @@ -722,6 +943,7 @@ struct LockScreenMusicPanel: View { private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat { sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) + + accessorySectionExtraHeight + lyricsHeight(forExpanded: expanded, enabled: enableLyrics) } @@ -758,9 +980,12 @@ struct LockScreenMusicPanel: View { let newState = !isVolumeSliderVisible if newState { routeManager.refreshDevices() + if useMergedAirPlayOutput { + Task { await airPlayManager.refreshDevices() } + } } - withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) { + withAnimation(.easeInOut(duration: 0.24)) { isVolumeSliderVisible = newState } diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index c306ce3..fc57c91 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -876,9 +876,13 @@ private struct MinimalisticReminderDetailsView: View { ) } + private var isAppleMusicActive: Bool { + musicManager.bundleIdentifier == "com.apple.Music" + } + private var displayedSlots: [MusicControlButton] { if showCustomControls { - let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl) + let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl, isAppleMusicActive: isAppleMusicActive) return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout } @@ -943,6 +947,8 @@ private struct MinimalisticReminderDetailsView: View { } case .mediaOutput: MinimalisticMediaOutputButton() + case .airPlay: + MinimalisticAirPlayButton() case .lyrics: controlButton( icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble", @@ -1027,6 +1033,70 @@ private struct MinimalisticReminderDetailsView: View { } } + private struct MinimalisticAirPlayButton: View { + @ObservedObject private var musicManager = MusicManager.shared + @ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared + @EnvironmentObject private var vm: DynamicIslandViewModel + @State private var isPopoverPresented = false + @State private var isHoveringPopover = false + + private var isAppleMusicActive: Bool { + musicManager.bundleIdentifier == "com.apple.Music" + } + + var body: some View { + MinimalisticSquircircleButton( + icon: "airplayaudio", + fontSize: 18, + fontWeight: .medium, + frameSize: CGSize(width: 40, height: 40), + cornerRadius: 16, + foregroundColor: .white.opacity(0.85), + symbolEffectStyle: .replace + ) { + isPopoverPresented.toggle() + if isPopoverPresented { + Task { await airPlayManager.refreshDevices() } + } + } + .accessibilityLabel("AirPlay") + .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { + AirPlaySelectorPopover( + airPlayManager: airPlayManager, + onHoverChanged: { hovering in + isHoveringPopover = hovering + updateActivity() + } + ) { + isPopoverPresented = false + isHoveringPopover = false + updateActivity() + } + } + .onChange(of: isPopoverPresented) { _, presented in + if !presented { isHoveringPopover = false } + updateActivity() + } + .onAppear { + if isAppleMusicActive { + Task { await airPlayManager.refreshDevices() } + } + } + .onChange(of: musicManager.bundleIdentifier) { _, newBundle in + if newBundle == "com.apple.Music" { + Task { await airPlayManager.refreshDevices() } + } + } + .onDisappear { + vm.isMediaOutputPopoverActive = false + } + } + + private func updateActivity() { + vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover + } + } + private var repeatIcon: String { switch musicManager.repeatMode { case .off: return "repeat" diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 37deed7..09d60e5 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -398,9 +398,13 @@ struct MusicControlsView: View { } } + private var isAppleMusicActive: Bool { + musicManager.bundleIdentifier == "com.apple.Music" + } + private var displayedSlots: [MusicControlButton] { if showCustomControls { - let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl) + let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl, isAppleMusicActive: isAppleMusicActive) return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout } @@ -474,6 +478,8 @@ struct MusicControlsView: View { } case .mediaOutput: MediaOutputPickerButton() + case .airPlay: + AirPlayPickerButton() case .lyrics: HoverButton( icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble", @@ -899,6 +905,62 @@ private struct MediaOutputPickerButton: View { } } +private struct AirPlayPickerButton: View { + @ObservedObject private var musicManager = MusicManager.shared + @ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared + @State private var isPopoverPresented = false + @State private var isHoveringPopover = false + @EnvironmentObject private var vm: DynamicIslandViewModel + + private var isAppleMusicActive: Bool { + musicManager.bundleIdentifier == "com.apple.Music" + } + + var body: some View { + HoverButton(icon: "airplayaudio", iconColor: .white, scale: .medium) { + isPopoverPresented.toggle() + if isPopoverPresented { + Task { await airPlayManager.refreshDevices() } + } + } + .accessibilityLabel("AirPlay") + .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { + AirPlaySelectorPopover( + airPlayManager: airPlayManager, + onHoverChanged: { hovering in + isHoveringPopover = hovering + updatePopoverActivity() + } + ) { + isPopoverPresented = false + isHoveringPopover = false + updatePopoverActivity() + } + } + .onAppear { + if isAppleMusicActive { + Task { await airPlayManager.refreshDevices() } + } + } + .onChange(of: isPopoverPresented) { _, presented in + if !presented { isHoveringPopover = false } + updatePopoverActivity() + } + .onChange(of: musicManager.bundleIdentifier) { _, newBundle in + if newBundle == "com.apple.Music" { + Task { await airPlayManager.refreshDevices() } + } + } + .onDisappear { + vm.isMediaOutputPopoverActive = false + } + } + + private func updatePopoverActivity() { + vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover + } +} + struct MediaOutputSelectorPopover: View { @ObservedObject var routeManager: AudioRouteManager @ObservedObject var volumeModel: MediaOutputVolumeViewModel @@ -1008,7 +1070,7 @@ struct MediaOutputSelectorPopover: View { } .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxHeight: 180) + .frame(maxHeight: 240) } } } @@ -1029,6 +1091,104 @@ struct MediaOutputSelectorPopover: View { } } +struct AirPlaySelectorPopover: View { + @ObservedObject var airPlayManager: AppleMusicAirPlayManager + var onHoverChanged: (Bool) -> Void + var dismiss: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("AirPlay") + .font(.caption) + .foregroundColor(.secondary) + + if airPlayManager.devices.isEmpty { + Text("No AirPlay devices found") + .font(.callout) + .foregroundColor(.secondary) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + ScrollView { + VStack(spacing: 6) { + ForEach(airPlayManager.devices) { device in + VStack(spacing: 4) { + Button { + Task { await airPlayManager.toggleDevice(device) } + } label: { + HStack(spacing: 8) { + Image(systemName: device.iconName) + .font(.system(size: 14, weight: .medium)) + Text(device.name) + .foregroundColor(.primary) + .lineLimit(1) + Spacer() + if device.isSelected { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + } + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(device.isSelected ? Color.primary.opacity(0.12) : .clear) + ) + } + .buttonStyle(.plain) + + if device.isSelected { + AirPlayVolumeSlider( + airPlayManager: airPlayManager, + deviceID: device.id + ) + .padding(.horizontal, 8) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: 300) + } + } + .frame(width: 240) + .padding(16) + .onHover { hovering in + onHoverChanged(hovering) + } + .onDisappear { + onHoverChanged(false) + } + } +} + +/// 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. +struct AirPlayVolumeSlider: View { + @ObservedObject var airPlayManager: AppleMusicAirPlayManager + let deviceID: String + + @State private var sliderValue: Double = 0 + @State private var isSyncing = false + + var body: some View { + Slider(value: $sliderValue, in: 0...100) + .tint(.accentColor) + .onAppear { + isSyncing = true + sliderValue = Double(airPlayManager.currentVolume(for: deviceID)) + isSyncing = false + } + .onChange(of: sliderValue) { _, newValue in + guard !isSyncing else { return } + airPlayManager.setVolume(Int(newValue), for: deviceID) + } + } +} + final class MediaOutputVolumeViewModel: ObservableObject { @Published var level: Float @Published var isMuted: Bool diff --git a/DynamicIsland/components/Settings/MusicSlotConfigurationView.swift b/DynamicIsland/components/Settings/MusicSlotConfigurationView.swift index 663f42a..3457604 100644 --- a/DynamicIsland/components/Settings/MusicSlotConfigurationView.swift +++ b/DynamicIsland/components/Settings/MusicSlotConfigurationView.swift @@ -34,6 +34,10 @@ struct MusicSlotConfigurationView: View { private let slotCount = MusicControlButton.slotCount + private var isAppleMusicActive: Bool { + musicManager.bundleIdentifier == "com.apple.Music" + } + var body: some View { VStack(alignment: .leading, spacing: 12) { header @@ -195,15 +199,23 @@ private struct ScrollHintIndicator: View { } } + private func isControlDisabled(_ control: MusicControlButton) -> Bool { + if control == .mediaOutput && !showMediaOutputControl { return true } + if control.isAppleMusicExclusive && !isAppleMusicActive { return true } + return false + } + private func paletteItem(for control: MusicControlButton) -> some View { - VStack(spacing: 6) { + let disabled = isControlDisabled(control) + + return VStack(spacing: 6) { RoundedRectangle(cornerRadius: 8) .fill(Color(nsColor: .controlBackgroundColor)) .frame(width: 44, height: 44) .overlay { Image(systemName: control.iconName) .font(.system(size: control.prefersLargeScale ? 18 : 15, weight: .medium)) - .foregroundStyle(control == .mediaOutput && !showMediaOutputControl ? .secondary : .primary) + .foregroundStyle(disabled ? .secondary : .primary) } .onDrag { NSItemProvider(object: NSString(string: "control:\(control.rawValue)")) @@ -220,8 +232,8 @@ private struct ScrollHintIndicator: View { .minimumScaleFactor(0.8) .multilineTextAlignment(.center) } - .opacity(control == .mediaOutput && !showMediaOutputControl ? 0.4 : 1) - .disabled(control == .mediaOutput && !showMediaOutputControl) + .opacity(disabled ? 0.4 : 1) + .disabled(disabled) } private func place(_ control: MusicControlButton) { @@ -233,17 +245,20 @@ private struct ScrollHintIndicator: View { } private func slotValue(at index: Int) -> MusicControlButton { - let normalized = musicControlSlots.normalized(allowingMediaOutput: showMediaOutputControl) + let normalized = musicControlSlots.normalized(allowingMediaOutput: showMediaOutputControl, isAppleMusicActive: isAppleMusicActive) guard normalized.indices.contains(index) else { return .none } return normalized[index] } private var pickerOptions: [MusicControlButton] { - let base = MusicControlButton.pickerOptions - if showMediaOutputControl { - return base + var base = MusicControlButton.pickerOptions + if !showMediaOutputControl { + base = base.filter { $0 != .mediaOutput } } - return base.filter { $0 != .mediaOutput } + if !isAppleMusicActive { + base = base.filter { !$0.isAppleMusicExclusive } + } + return base } private func previewIconColor(for slot: MusicControlButton) -> Color { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 498fc63..624d355 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -2443,6 +2443,11 @@ struct Media: View { @Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenMusicAlbumParallaxEnabled @Default(.showStandardMediaControls) private var showStandardMediaControls @Default(.autoHideInactiveNotchMediaPlayer) private var autoHideInactiveNotchMediaPlayer + @ObservedObject private var musicManager = MusicManager.shared + + private var isAppleMusicActive: Bool { + musicManager.bundleIdentifier == "com.apple.Music" + } private func highlightID(_ title: String) -> String { SettingsTab.media.highlightID(for: title) @@ -2616,6 +2621,11 @@ struct Media: View { Text("Show media app icon") } .disabled(!enableLockScreenMediaWidget) + if isAppleMusicActive { + Defaults.Toggle("Show merged AirPlay and output devices", key: .lockScreenMusicMergedAirPlayOutput) + .disabled(!enableLockScreenMediaWidget) + .settingsHighlight(id: highlightID("Show merged AirPlay and output devices")) + } Defaults.Toggle(key: .lockScreenPanelShowsBorder) { Text("Show panel border") } @@ -4458,6 +4468,12 @@ struct LockScreenSettings: View { @Default(.lockScreenCalendarSelectionMode) private var lockScreenCalendarSelectionMode @Default(.lockScreenSelectedCalendarIDs) private var lockScreenSelectedCalendarIDs @Default(.lockScreenShowCalendarEventAfterStartEnabled) private var lockScreenShowCalendarEventAfterStartEnabled + @Default(.lockScreenMusicMergedAirPlayOutput) private var lockScreenMusicMergedAirPlayOutput + @ObservedObject private var musicManager = MusicManager.shared + + private var isAppleMusicActive: Bool { + musicManager.bundleIdentifier == "com.apple.Music" + } private func highlightID(_ title: String) -> String { SettingsTab.lockScreen.highlightID(for: title) @@ -4621,6 +4637,11 @@ struct LockScreenSettings: View { } .disabled(!enableLockScreenMediaWidget) .settingsHighlight(id: highlightID("Show media app icon")) + if isAppleMusicActive { + Defaults.Toggle("Show merged AirPlay and output devices", key: .lockScreenMusicMergedAirPlayOutput) + .disabled(!enableLockScreenMediaWidget) + .settingsHighlight(id: highlightID("Show merged AirPlay and output devices")) + } Defaults.Toggle(key: .lockScreenPanelShowsBorder) { Text("Show panel border") } diff --git a/DynamicIsland/managers/AppleMusicAirPlayManager.swift b/DynamicIsland/managers/AppleMusicAirPlayManager.swift new file mode 100644 index 0000000..75210bf --- /dev/null +++ b/DynamicIsland/managers/AppleMusicAirPlayManager.swift @@ -0,0 +1,245 @@ +/* + * Atoll (DynamicIsland) + * Copyright (C) 2024-2026 Atoll Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import Foundation + +struct AirPlayDevice: Identifiable, Equatable { + let id: String // network address or name as fallback + let name: String + let kind: String + var isSelected: Bool + var volume: Int // 0-100 + + var iconName: String { + switch kind { + case "HomePod": + return "homepodmini" + case "AirPort Express": + return "airport.express" + case "computer": + return "laptopcomputer" + case "AirPlay device": + let normalizedName = name.lowercased() + if normalizedName.contains("tv") || normalizedName.contains("theatre") || normalizedName.contains("theater") { + return "appletv" + } + return "airplayaudio" + default: + return "airplayaudio" + } + } +} + +@MainActor +final class AppleMusicAirPlayManager: ObservableObject { + static let shared = AppleMusicAirPlayManager() + + @Published private(set) var devices: [AirPlayDevice] = [] + @Published private(set) var isLoading = false + + private var isBusy = false + + // MARK: - Volume commit pipeline + // Slider values are written to userSetVolume (non-@Published) and drained + // at throttled intervals into a serialised AppleScript commit queue. + // This avoids triggering SwiftUI re-renders that would reset the Slider. + + private var userSetVolume: [String: Int] = [:] + private var throttleActive: [String: Bool] = [:] + private var lastCommittedVolume: [String: Int] = [:] + private var isCommittingVolume = false + private var pendingVolumeCommits: [(volume: Int, deviceName: String, deviceID: String)] = [] + + // MARK: - Device management + + func refreshDevices() async { + guard !isBusy else { return } + isBusy = true + defer { isBusy = false } + + isLoading = true + defer { isLoading = false } + + let script = """ + tell application "Music" + try + set deviceList to {} + set allDevices to AirPlay devices + repeat with d in allDevices + set deviceName to name of d + set deviceKind to kind of d as string + set deviceActive to selected of d + set deviceAddr to network address of d + set deviceVol to sound volume of d + set end of deviceList to {deviceName, deviceKind, deviceActive, deviceAddr, deviceVol} + end repeat + return deviceList + on error + return {} + end try + end tell + """ + + guard let result = try? await AppleScriptHelper.execute(script) else { return } + + var fetched: [AirPlayDevice] = [] + for i in 1...result.numberOfItems { + guard let item = result.atIndex(i) else { continue } + let name = item.atIndex(1)?.stringValue ?? "Unknown" + let kind = item.atIndex(2)?.stringValue ?? "AirPlay device" + let selected = item.atIndex(3)?.booleanValue ?? false + let addr = item.atIndex(4)?.stringValue ?? "" + let volume = Int(item.atIndex(5)?.int32Value ?? 50) + let id = addr.isEmpty ? name : addr + fetched.append(AirPlayDevice(id: id, name: name, kind: kind, isSelected: selected, volume: volume)) + } + devices = fetched + for device in fetched { + lastCommittedVolume[device.id] = device.volume + } + } + + func toggleDevice(_ device: AirPlayDevice) async { + guard !isBusy else { return } + isBusy = true + defer { isBusy = false } + + let newState = !device.isSelected + let escapedName = device.name.replacingOccurrences(of: "\"", with: "\\\"") + let script = """ + tell application "Music" + try + set targetDevice to first AirPlay device whose name is "\(escapedName)" + set selected of targetDevice to \(newState) + end try + end tell + """ + try? await AppleScriptHelper.executeVoid(script) + try? await Task.sleep(for: .milliseconds(200)) + + isBusy = false + await refreshDevices() + } + + // 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. + func setVolume(_ volume: Int, for deviceID: String) { + let clamped = min(max(volume, 0), 100) + guard let device = devices.first(where: { $0.id == deviceID }) else { return } + + userSetVolume[deviceID] = clamped + guard throttleActive[deviceID] != true else { return } + + throttleActive[deviceID] = true + Task { [weak self] in + guard let self else { return } + + // Commit immediately, then drain at intervals while the user keeps dragging + if let first = self.userSetVolume.removeValue(forKey: deviceID) { + await self.enqueueVolumeCommit(volume: first, deviceName: device.name, deviceID: deviceID) + } + + var alive = true + while alive { + try? await Task.sleep(for: .milliseconds(150)) + guard let pending = self.userSetVolume.removeValue(forKey: deviceID) else { + alive = false + break + } + await self.enqueueVolumeCommit(volume: pending, deviceName: device.name, deviceID: deviceID) + } + self.throttleActive[deviceID] = false + } + } + + // MARK: - Commit queue + + /// Serialises AppleScript calls — only one runs at a time. + /// Queued entries are deduplicated per-device (only the latest value is kept). + private func enqueueVolumeCommit(volume: Int, deviceName: String, deviceID: String) async { + if isCommittingVolume { + pendingVolumeCommits.removeAll { $0.deviceID == deviceID } + pendingVolumeCommits.append((volume: volume, deviceName: deviceName, deviceID: deviceID)) + return + } + + isCommittingVolume = true + await commitVolume(volume: volume, deviceName: deviceName, deviceID: deviceID) + + while !pendingVolumeCommits.isEmpty { + var latestByDevice: [String: (volume: Int, deviceName: String, deviceID: String)] = [:] + for entry in pendingVolumeCommits { + latestByDevice[entry.deviceID] = entry + } + pendingVolumeCommits.removeAll() + + for (_, entry) in latestByDevice { + await commitVolume(volume: entry.volume, deviceName: entry.deviceName, deviceID: entry.deviceID) + } + } + isCommittingVolume = false + } + + /// Commits a volume change via AppleScript. + /// + /// Apple Music internally rebalances other AirPlay devices when one device's + /// volume drops past another device's volume in a single call. To prevent this, + /// when moving DOWN past the highest other selected device's volume, we first + /// set to that device's volume (waypoint), then to the actual target — at most + /// two AppleScript calls. + private func commitVolume(volume: Int, deviceName: String, deviceID: String) async { + let escapedName = deviceName.replacingOccurrences(of: "\"", with: "\\\"") + let current = lastCommittedVolume[deviceID] ?? volume + + let otherMaxVolume = devices + .filter { $0.id != deviceID && $0.isSelected } + .map { lastCommittedVolume[$0.id] ?? $0.volume } + .max() ?? 0 + + // Waypoint needed when crossing below another device's volume + if current > otherMaxVolume && volume < otherMaxVolume && otherMaxVolume > 0 { + await sendVolumeAppleScript(escapedName: escapedName, volume: otherMaxVolume) + } + + await sendVolumeAppleScript(escapedName: escapedName, volume: volume) + lastCommittedVolume[deviceID] = volume + } + + private func sendVolumeAppleScript(escapedName: String, volume: Int) async { + let script = """ + tell application "Music" + try + set targetDevice to first AirPlay device whose name is "\(escapedName)" + set sound volume of targetDevice to \(volume) + end try + end tell + """ + try? await AppleScriptHelper.executeVoid(script) + } +} diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift index 5c5dfa7..c259971 100644 --- a/DynamicIsland/managers/LockScreenPanelManager.swift +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -166,6 +166,8 @@ class LockScreenPanelManager { return } + let resizeDuration: CFTimeInterval = 0.28 + let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize let targetWidth = baseSize.width let targetHeight = baseSize.height + additionalHeight @@ -175,7 +177,7 @@ class LockScreenPanelManager { if animated { NSAnimationContext.runAnimationGroup { context in - context.duration = 0.45 + context.duration = resizeDuration context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) window.animator().setFrame(targetFrame, display: true) } @@ -191,7 +193,8 @@ class LockScreenPanelManager { let targetRadius = expanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius if animated { CATransaction.begin() - CATransaction.setAnimationDuration(0.28) + CATransaction.setAnimationDuration(resizeDuration) + CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(name: .easeInEaseOut)) window.contentView?.layer?.cornerRadius = targetRadius CATransaction.commit() } else { diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 8ae20b6..628253f 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -834,6 +834,7 @@ extension Defaults.Keys { static let lockScreenShowAppIcon = Key("lockScreenShowAppIcon", default: false) static let lockScreenPanelShowsBorder = Key("lockScreenPanelShowsBorder", default: false) static let lockScreenPanelUsesBlur = Key("lockScreenPanelUsesBlur", default: true) + static let lockScreenMusicMergedAirPlayOutput = Key("lockScreenMusicMergedAirPlayOutput", default: true) static let lockScreenTimerWidgetUsesBlur = Key("lockScreenTimerWidgetUsesBlur", default: false) static let lockScreenReminderChipStyle = Key("lockScreenReminderChipStyle", default: .eventColor) static let lockScreenReminderWidgetHorizontalAlignment = Key("lockScreenReminderWidgetHorizontalAlignment", default: "center") diff --git a/DynamicIsland/models/MusicControlButton.swift b/DynamicIsland/models/MusicControlButton.swift index c4023a9..3d2bd23 100644 --- a/DynamicIsland/models/MusicControlButton.swift +++ b/DynamicIsland/models/MusicControlButton.swift @@ -29,6 +29,7 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S case trackForward case repeatMode case mediaOutput + case airPlay case lyrics case seekBackward case seekForward @@ -61,9 +62,15 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S .shuffle, .repeatMode, .lyrics, - .mediaOutput + .mediaOutput, + .airPlay ] + /// Controls that are only available when Apple Music is the active media source. + var isAppleMusicExclusive: Bool { + self == .airPlay + } + var id: String { rawValue } var label: String { @@ -80,6 +87,8 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S return "Repeat" case .mediaOutput: return "Change Media Output" + case .airPlay: + return "AirPlay" case .lyrics: return "Lyrics" case .seekBackward: @@ -105,6 +114,8 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S return "repeat" case .mediaOutput: return "speaker.wave.2" + case .airPlay: + return "airplayaudio" case .lyrics: return "quote.bubble" case .seekBackward: @@ -122,11 +133,10 @@ enum MusicControlButton: String, CaseIterable, Identifiable, Codable, Defaults.S } extension Array where Element == MusicControlButton { - func normalized(allowingMediaOutput: Bool) -> [MusicControlButton] { + func normalized(allowingMediaOutput: Bool, isAppleMusicActive: Bool = true) -> [MusicControlButton] { var sanitized = map { button -> MusicControlButton in - if button == .mediaOutput && !allowingMediaOutput { - return .none - } + if button == .mediaOutput && !allowingMediaOutput { return .none } + if button.isAppleMusicExclusive && !isAppleMusicActive { return .none } return button }