diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 37001be..198a45f 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -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 { 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 0da7d2e..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", @@ -849,11 +855,60 @@ struct CustomSlider: View { private struct MediaOutputPickerButton: View { @ObservedObject private var routeManager = AudioRouteManager.shared - @ObservedObject private var musicManager = MusicManager.shared @StateObject private var volumeModel = MediaOutputVolumeViewModel() + @State private var isPopoverPresented = false + @State private var isHoveringPopover = false + @EnvironmentObject private var vm: DynamicIslandViewModel + + var body: some View { + HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) { + isPopoverPresented.toggle() + if isPopoverPresented { + routeManager.refreshDevices() + } + } + .accessibilityLabel("Media output") + .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { + MediaOutputSelectorPopover( + routeManager: routeManager, + volumeModel: volumeModel, + onHoverChanged: { hovering in + isHoveringPopover = hovering + updatePopoverActivity() + } + ) { + isPopoverPresented = false + isHoveringPopover = false + updatePopoverActivity() + } + } + .onAppear { + routeManager.refreshDevices() + } + .onChange(of: isPopoverPresented) { _, presented in + if !presented { + isHoveringPopover = false + } + updatePopoverActivity() + } + .onDisappear { + vm.isMediaOutputPopoverActive = false + } + } + + private var buttonIcon: String { + routeManager.activeDevice?.iconName ?? "speaker.wave.2" + } + + private func updatePopoverActivity() { + vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover + } +} + +private struct AirPlayPickerButton: View { + @ObservedObject private var musicManager = MusicManager.shared @ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared @State private var isPopoverPresented = false - @State private var isAirPlayPopoverPresented = false @State private var isHoveringPopover = false @EnvironmentObject private var vm: DynamicIslandViewModel @@ -862,68 +917,33 @@ private struct MediaOutputPickerButton: View { } var body: some View { - HStack(spacing: 4) { - HoverButton(icon: buttonIcon, iconColor: .white, scale: .medium) { - isPopoverPresented.toggle() - if isPopoverPresented { - routeManager.refreshDevices() - } + HoverButton(icon: "airplayaudio", iconColor: .white, scale: .medium) { + isPopoverPresented.toggle() + if isPopoverPresented { + Task { await airPlayManager.refreshDevices() } } - .accessibilityLabel("Media output") - .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { - MediaOutputSelectorPopover( - routeManager: routeManager, - volumeModel: volumeModel, - onHoverChanged: { hovering in - isHoveringPopover = hovering - updatePopoverActivity() - } - ) { - isPopoverPresented = false - isHoveringPopover = false + } + .accessibilityLabel("AirPlay") + .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { + AirPlaySelectorPopover( + airPlayManager: airPlayManager, + onHoverChanged: { hovering in + isHoveringPopover = hovering updatePopoverActivity() } - } - - if isAppleMusicActive && !airPlayManager.devices.isEmpty { - HoverButton(icon: "airplayaudio", iconColor: .white, scale: .medium) { - isAirPlayPopoverPresented.toggle() - if isAirPlayPopoverPresented { - Task { await airPlayManager.refreshDevices() } - } - } - .accessibilityLabel("AirPlay") - .popover(isPresented: $isAirPlayPopoverPresented, arrowEdge: .bottom) { - AirPlaySelectorPopover( - airPlayManager: airPlayManager, - onHoverChanged: { hovering in - isHoveringPopover = hovering - updatePopoverActivity() - } - ) { - isAirPlayPopoverPresented = false - isHoveringPopover = false - updatePopoverActivity() - } - } + ) { + isPopoverPresented = false + isHoveringPopover = false + updatePopoverActivity() } } .onAppear { - routeManager.refreshDevices() if isAppleMusicActive { Task { await airPlayManager.refreshDevices() } } } .onChange(of: isPopoverPresented) { _, presented in - if !presented { - isHoveringPopover = false - } - updatePopoverActivity() - } - .onChange(of: isAirPlayPopoverPresented) { _, presented in - if !presented { - isHoveringPopover = false - } + if !presented { isHoveringPopover = false } updatePopoverActivity() } .onChange(of: musicManager.bundleIdentifier) { _, newBundle in @@ -936,12 +956,8 @@ private struct MediaOutputPickerButton: View { } } - private var buttonIcon: String { - routeManager.activeDevice?.iconName ?? "speaker.wave.2" - } - private func updatePopoverActivity() { - vm.isMediaOutputPopoverActive = (isPopoverPresented || isAirPlayPopoverPresented) && isHoveringPopover + vm.isMediaOutputPopoverActive = isPopoverPresented && isHoveringPopover } } 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 ae7a5ec..471b6e1 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -2373,6 +2373,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) @@ -2532,6 +2537,11 @@ struct Media: View { Defaults.Toggle("Show lock screen media panel", key: .enableLockScreenMediaWidget) Defaults.Toggle("Show media app icon", key: .lockScreenShowAppIcon) .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("Show panel border", key: .lockScreenPanelShowsBorder) .disabled(!enableLockScreenMediaWidget) if lockScreenGlassCustomizationMode == .customLiquid { @@ -4282,6 +4292,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) @@ -4437,6 +4453,11 @@ struct LockScreenSettings: View { Defaults.Toggle("Show media app icon", key: .lockScreenShowAppIcon) .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("Show panel border", key: .lockScreenPanelShowsBorder) .disabled(!enableLockScreenMediaWidget) .settingsHighlight(id: highlightID("Show panel border")) 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 }