/* * Atoll (DynamicIsland) * Copyright (C) 2024-2026 Atoll Contributors * * Originally from boring.notch project * Modified and adapted for Atoll (DynamicIsland) * See NOTICE for details. * * 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 Combine import Defaults import SwiftUI // MARK: - Music Player Components struct MusicPlayerView: View { @EnvironmentObject var vm: DynamicIslandViewModel let albumArtNamespace: Namespace.ID var body: some View { HStack(alignment: .top, spacing: 12) { AlbumArtView(vm: vm, albumArtNamespace: albumArtNamespace) .padding(.all, 5) MusicControlsView() .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, alignment: .leading) } } struct AlbumArtView: View { @ObservedObject var musicManager = MusicManager.shared @ObservedObject var vm: DynamicIslandViewModel let albumArtNamespace: Namespace.ID var body: some View { ZStack(alignment: .bottomTrailing) { if Defaults[.lightingEffect] { albumArtBackground } albumArtButton } } private var albumArtBackground: some View { Color.clear .aspectRatio(1, contentMode: .fit) .background( Image(nsImage: musicManager.albumArt) .resizable() .aspectRatio(contentMode: .fit) ) .clipped() .clipShape( RoundedRectangle( cornerRadius: Defaults[.cornerRadiusScaling] ? MusicPlayerImageSizes.cornerRadiusInset.opened : MusicPlayerImageSizes.cornerRadiusInset.closed) ) .scaleEffect(x: 1.3, y: 1.4) .rotationEffect(.degrees(92)) .blur(radius: 40) .opacity(musicManager.isPlaying ? 0.5 : 0) } private var albumArtButton: some View { ZStack { Button { musicManager.openMusicApp() } label: { ZStack(alignment:.bottomTrailing) { albumArtImage appIconOverlay } .albumArtFlip(angle: musicManager.flipAngle) .parallax3D(magnitude: 12) } .buttonStyle(PlainButtonStyle()) .scaleEffect(musicManager.isPlaying ? 1 : 0.85) albumArtDarkOverlay } } private var albumArtDarkOverlay: some View { Rectangle() .aspectRatio(1, contentMode: .fit) .foregroundColor(Color.black) .opacity(musicManager.isPlaying ? 0 : 0.8) .blur(radius: 50) } private var albumArtImage: some View { Color.clear .aspectRatio(1, contentMode: .fit) .overlay { Image(nsImage: musicManager.albumArt) .resizable() .aspectRatio(contentMode: .fit) } .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) .clipped() .clipShape( RoundedRectangle( cornerRadius: Defaults[.cornerRadiusScaling] ? MusicPlayerImageSizes.cornerRadiusInset.opened : MusicPlayerImageSizes.cornerRadiusInset.closed) ) } @ViewBuilder private var appIconOverlay: some View { if vm.notchState == .open && !musicManager.usingAppIconForArtwork { AppIcon(for: musicManager.bundleIdentifier ?? "com.apple.Music") .resizable() .aspectRatio(contentMode: .fill) .frame(width: 30, height: 30) .offset(x: 10, y: 10) .transition(.scale.combined(with: .opacity).animation(.bouncy.delay(0.3))) .zIndex(2) } } } struct MusicControlsView: View { @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject var musicManager = MusicManager.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @State private var sliderValue: Double = MusicManager.shared.estimatedPlaybackPosition() @State private var dragging: Bool = false @State private var lastDragged: Date = .distantPast @State private var hudValue: Double = 0 @State private var hudDragging: Bool = false @State private var hudLastDragged: Date = .distantPast @Default(.showShuffleAndRepeat) private var showCustomControls @Default(.musicControlSlots) private var slotConfig @Default(.showMediaOutputControl) private var showMediaOutputControl @Default(.musicSkipBehavior) private var musicSkipBehavior @Default(.enableLyrics) private var enableLyrics private let seekInterval: TimeInterval = 10 private let skipMagnitude: CGFloat = 6 var body: some View { VStack(alignment: .leading) { songInfoAndSlider if shouldShowControlHUDRow { controlHUDRow } else { playbackControls } } .buttonStyle(PlainButtonStyle()) .frame(maxWidth: .infinity, alignment: .leading) } private var songInfoAndSlider: some View { GeometryReader { geo in VStack(alignment: .leading, spacing: 4) { songInfo(width: geo.size.width) musicSlider } } .padding(.top, 10) .padding(.leading, 5) .frame(maxWidth: .infinity, alignment: .leading) } private func songInfo(width: CGFloat) -> some View { VStack(alignment: .leading, spacing: 0) { MarqueeText($musicManager.songTitle, font: .headline, nsFont: .headline, textColor: .white, frameWidth: width) MarqueeText( $musicManager.artistName, font: .headline, nsFont: .headline, textColor: Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor) .ensureMinimumBrightness(factor: 0.6) : .gray, frameWidth: width ) .fontWeight(.medium) // Lyrics shown under the author name (same font size as author) when enabled in settings if enableLyrics { let transition = AnyTransition.asymmetric( insertion: .move(edge: .bottom).combined(with: .opacity), removal: .move(edge: .top).combined(with: .opacity) ) let line = musicManager.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines) if !line.isEmpty { let lyricsBinding = Binding( get: { musicManager.currentLyrics }, set: { _ in } ) MarqueeText( lyricsBinding, font: .headline, nsFont: .headline, textColor: .white.opacity(0.8), minDuration: 0.35, frameWidth: width ) .padding(.top, 2) .id(line) .transition(transition) .animation(.easeInOut(duration: 0.32), value: line) } } } } /// Whether the progress timeline should be paused (no ticks). private var isProgressTimelinePaused: Bool { !musicManager.isPlaying || musicManager.isLiveStream || musicManager.playbackRate <= 0 } private var musicSlider: some View { TimelineView(.animation(minimumInterval: 1.0, paused: isProgressTimelinePaused)) { timeline in MusicSliderView( sliderValue: $sliderValue, duration: $musicManager.songDuration, lastDragged: $lastDragged, color: musicManager.avgColor, dragging: $dragging, currentDate: timeline.date, timestampDate: musicManager.timestampDate, elapsedTime: musicManager.elapsedTime, playbackRate: musicManager.playbackRate, isPlaying: musicManager.isPlaying, isLiveStream: musicManager.isLiveStream ) { newValue in guard !musicManager.isLiveStream else { return } MusicManager.shared.seek(to: newValue) } .padding(.top, 5) .frame(height: 36) } } private var playbackControls: some View { HStack(spacing: 8) { ForEach(Array(displayedSlots.enumerated()), id: \.offset) { _, slot in slotView(for: slot) } } .frame(maxWidth: .infinity, alignment: .center) } private var shouldShowControlHUDRow: Bool { guard vm.notchState == .open else { return false } guard coordinator.sneakPeek.show else { return false } guard Defaults[.enableSystemHUD] else { return false } guard !Defaults[.enableCustomOSD] && !Defaults[.enableVerticalHUD] && !Defaults[.enableCircularHUD] else { return false } switch coordinator.sneakPeek.type { case .volume: return Defaults[.enableVolumeHUD] case .brightness: return Defaults[.enableBrightnessHUD] case .backlight: return Defaults[.enableKeyboardBacklightHUD] default: return false } } private var controlHUDRow: some View { HStack(alignment: .center, spacing: 10) { if !controlLeftIconName.isEmpty { Image(systemName: controlLeftIconName) .font(.system(size: 16, weight: .semibold)) .frame(width: 22, height: 22, alignment: .center) } controlHUDSlider if !controlRightIconName.isEmpty { Image(systemName: controlRightIconName) .font(.system(size: 16, weight: .semibold)) .frame(width: 22, height: 22, alignment: .center) } } .frame(maxWidth: .infinity, alignment: .center) .onAppear { syncHUDValueIfNeeded(force: true) } .onChange(of: coordinator.sneakPeek.value) { _, _ in syncHUDValueIfNeeded(force: false) } } private var controlHUDSlider: some View { VStack(spacing: 0) { Spacer(minLength: 0) CustomSlider( value: Binding( get: { hudValue }, set: { newValue in hudValue = newValue updateControlHUDValue(newValue) } ), range: 0...1, color: .white, dragging: $hudDragging, lastDragged: $hudLastDragged, onValueChange: { newValue in updateControlHUDValue(newValue) }, thumbSize: 10, restingTrackHeight: 4, draggingTrackHeight: 7 ) .frame(height: 7) Spacer(minLength: 0) } .frame(height: 22) } private func syncHUDValueIfNeeded(force: Bool) { guard shouldShowControlHUDRow else { return } guard force || !hudDragging else { return } hudValue = Double(coordinator.sneakPeek.value) } private func updateControlHUDValue(_ newValue: Double) { let clamped = max(0, min(1, newValue)) switch coordinator.sneakPeek.type { case .volume: SystemVolumeController.shared.setVolume(Float(clamped)) case .brightness: SystemBrightnessController.shared.setBrightness(Float(clamped)) case .backlight: SystemKeyboardBacklightController.shared.setLevel(Float(clamped)) default: break } } private var controlLeftIconName: String { switch coordinator.sneakPeek.type { case .volume: return SystemVolumeController.shared.isMuted ? "speaker.slash" : "speaker.wave.1" case .brightness: return "sun.min.fill" case .backlight: return "light.min" default: return "" } } private var controlRightIconName: String { switch coordinator.sneakPeek.type { case .volume: return SystemVolumeController.shared.isMuted ? "" : "speaker.wave.3" case .brightness: return "sun.max.fill" case .backlight: return "light.max" default: return "" } } private var brandAccentColor: Color { musicManager.brandAccentColor } private var repeatIcon: String { switch musicManager.repeatMode { case .off: return "repeat" case .all: return "repeat" case .one: return "repeat.1" } } private var repeatIconColor: Color { switch musicManager.repeatMode { case .off: return .white case .all, .one: return brandAccentColor } } private var displayedSlots: [MusicControlButton] { if showCustomControls { let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl) return normalized.contains(where: { $0 != .none }) ? normalized : MusicControlButton.defaultLayout } switch musicSkipBehavior { case .track: return MusicControlButton.minimalLayout case .tenSecond: return [.none, .seekBackward, .playPause, .seekForward, .none] } } @ViewBuilder private func slotView(for control: MusicControlButton) -> some View { switch control { case .none: Spacer(minLength: 0) case .playPause: HoverButton( icon: musicManager.isPlaying ? (musicManager.isLiveStream ? "stop.fill" : "pause.fill") : "play.fill", scale: .large ) { MusicManager.shared.togglePlay() } case .trackBackward: playbackButton( icon: "backward.fill", press: .nudge(-skipMagnitude), trigger: skipGestureTrigger(for: .trackBackward) ) { musicManager.previousTrack() } case .trackForward: playbackButton( icon: "forward.fill", press: .nudge(skipMagnitude), trigger: skipGestureTrigger(for: .trackForward) ) { musicManager.nextTrack() } case .seekBackward: playbackButton( icon: "gobackward.10", press: .wiggle(.counterClockwise), trigger: skipGestureTrigger(for: .seekBackward) ) { musicManager.seek(by: -seekInterval) } case .seekForward: playbackButton( icon: "goforward.10", press: .wiggle(.clockwise), trigger: skipGestureTrigger(for: .seekForward) ) { musicManager.seek(by: seekInterval) } case .shuffle: HoverButton( icon: "shuffle", iconColor: musicManager.isShuffled ? brandAccentColor : .white, scale: .medium ) { MusicManager.shared.toggleShuffle() } case .repeatMode: HoverButton( icon: repeatIcon, iconColor: repeatIconColor, scale: .medium ) { MusicManager.shared.toggleRepeat() } case .mediaOutput: MediaOutputPickerButton() case .lyrics: HoverButton( icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble", iconColor: enableLyrics ? brandAccentColor : .white, scale: .medium ) { enableLyrics.toggle() } } } private struct SkipTrigger { let token: Int let pressEffect: HoverButton.PressEffect } private func playbackButton( icon: String, press: HoverButton.PressEffect?, trigger: SkipTrigger?, action: @escaping () -> Void ) -> some View { HoverButton( icon: icon, scale: .medium, pressEffect: press, externalTriggerToken: trigger?.token, externalTriggerEffect: trigger?.pressEffect ) { action() } } private func skipGestureTrigger(for control: MusicControlButton) -> SkipTrigger? { guard let pulse = musicManager.skipGesturePulse else { return nil } switch control { case .trackBackward where pulse.behavior == .track && pulse.direction == .backward: return SkipTrigger(token: pulse.token, pressEffect: .nudge(-skipMagnitude)) case .trackForward where pulse.behavior == .track && pulse.direction == .forward: return SkipTrigger(token: pulse.token, pressEffect: .nudge(skipMagnitude)) case .seekBackward where pulse.behavior == .tenSecond && pulse.direction == .backward: return SkipTrigger(token: pulse.token, pressEffect: .wiggle(.counterClockwise)) case .seekForward where pulse.behavior == .tenSecond && pulse.direction == .forward: return SkipTrigger(token: pulse.token, pressEffect: .wiggle(.clockwise)) default: return nil } } } // MARK: - Main View struct NotchHomeView: View { @EnvironmentObject var vm: DynamicIslandViewModel @ObservedObject var webcamManager = WebcamManager.shared @ObservedObject var batteryModel = BatteryStatusViewModel.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject private var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared @ObservedObject private var musicManager = MusicManager.shared @Default(.showStandardMediaControls) private var showStandardMediaControls @Default(.autoHideInactiveNotchMediaPlayer) private var autoHideInactiveNotchMediaPlayer let albumArtNamespace: Namespace.ID /// Whether the music player should actively display (enabled AND has real content). private var shouldShowMusicPlayer: Bool { showStandardMediaControls && (!autoHideInactiveNotchMediaPlayer || musicManager.hasActiveSession) } var body: some View { Group { if !coordinator.firstLaunch { mainContent } } .transition(.opacity.combined(with: .blurReplace)) } private var mainContent: some View { HStack(alignment: .top, spacing: 20) { if Defaults[.enableMinimalisticUI] { if let overridePayload = minimalisticOverridePayload { ExtensionMinimalisticExperienceView( payload: overridePayload, albumArtNamespace: albumArtNamespace ) } else { MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace) } } else { // Normal mode: Show full music player with optional calendar and webcam if shouldShowMusicPlayer { MusicPlayerView(albumArtNamespace: albumArtNamespace) .frame(maxWidth: .infinity, alignment: .leading) } if Defaults[.showCalendar] { Group { if shouldShowMusicPlayer { CalendarView() } else { StandaloneCalendarView() } } .frame(maxWidth: .infinity, alignment: .leading) .onHover { isHovering in vm.isHoveringCalendar = isHovering } .environmentObject(vm) } if Defaults[.showMirror], webcamManager.cameraAvailable, vm.notchState == .open { CameraPreviewView(webcamManager: webcamManager) .scaledToFit() .opacity(vm.notchState == .closed ? 0 : 1) .blur(radius: vm.notchState == .closed ? 20 : 0) } } } .transition(.opacity.animation(.smooth.speed(0.9)) .combined(with: .blurReplace.animation(.smooth.speed(0.9))) .combined(with: .move(edge: .top))) .blur(radius: vm.notchState == .closed ? 30 : 0) } private var minimalisticOverridePayload: ExtensionNotchExperiencePayload? { extensionNotchExperienceManager.minimalisticReplacementPayload() } } struct MusicSliderView: View { @Binding var sliderValue: Double @Binding var duration: Double @Binding var lastDragged: Date var color: NSColor @Binding var dragging: Bool let currentDate: Date let timestampDate: Date let elapsedTime: Double let playbackRate: Double let isPlaying: Bool let isLiveStream: Bool var onValueChange: (Double) -> Void var labelLayout: TimeLabelLayout = .stacked var trailingLabel: TrailingLabel = .duration var restingTrackHeight: CGFloat = 5 var draggingTrackHeight: CGFloat = 9 enum TimeLabelLayout { case stacked case inline } enum TrailingLabel { case duration case remaining } var body: some View { Group { if isLiveStream { liveStreamView } else { switch labelLayout { case .stacked: stackedContent case .inline: inlineContent } } } .onChange(of: currentDate) { newDate in guard !isLiveStream else { return } guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -1 else { return } sliderValue = MusicManager.shared.estimatedPlaybackPosition(at: newDate) } .onChange(of: isPlaying) { _, playing in // Snap slider to the exact position when music pauses so // the in-flight animation doesn't coast past the true value. if !playing { sliderValue = MusicManager.shared.estimatedPlaybackPosition() } } .onChange(of: isLiveStream) { isLive in if isLive { sliderValue = 0 } } } private var stackedContent: some View { VStack(spacing: 6) { sliderCore .frame(height: sliderFrameHeight) HStack { Text(timeString(from: sliderValue)) Spacer() Text(trailingTimeText) } .fontWeight(.medium) .foregroundColor(timeLabelColor) .font(.caption) } } private var inlineContent: some View { HStack(spacing: 10) { Text(timeString(from: sliderValue)) .font(inlineLabelFont) .foregroundColor(timeLabelColor) .frame(width: 42, alignment: .leading) sliderCore .frame(height: sliderFrameHeight) .frame(maxWidth: .infinity) Text(trailingTimeText) .font(inlineLabelFont) .foregroundColor(timeLabelColor) .frame(width: 48, alignment: .trailing) } } @ViewBuilder private var liveStreamView: some View { switch labelLayout { case .stacked: LiveStreamProgressIndicator(tint: sliderTint) .frame(maxWidth: .infinity) .frame(height: sliderFrameHeight) case .inline: HStack(spacing: 10) { Spacer() .frame(width: 42) LiveStreamProgressIndicator(tint: sliderTint) .frame(maxWidth: .infinity) .frame(height: sliderFrameHeight) Spacer() .frame(width: 48) } } } private var sliderCore: some View { CustomSlider( value: $sliderValue, range: 0 ... duration, color: sliderTint, dragging: $dragging, lastDragged: $lastDragged, onValueChange: onValueChange, restingTrackHeight: restingTrackHeight, draggingTrackHeight: draggingTrackHeight ) // Smoothly interpolate the filled track between 1-second ticks using // Core Animation — runs on the GPU with zero CPU polling cost. // Disabled while dragging or paused so the bar responds instantly. .animation( !dragging && isPlaying && !isLiveStream ? .linear(duration: 1.0) : nil, value: sliderValue ) } private var sliderTint: Color { switch Defaults[.sliderColor] { case .albumArt: return Color(nsColor: color).ensureMinimumBrightness(factor: 0.8) case .accent: return .accentColor case .white: return .white } } private var timeLabelColor: Color { Defaults[.playerColorTinting] ? Color(nsColor: color).ensureMinimumBrightness(factor: 0.6) : .gray } private var trailingTimeText: String { switch trailingLabel { case .duration: return timeString(from: duration) case .remaining: let remaining = max(duration - sliderValue, 0) return "-" + timeString(from: remaining) } } private var inlineLabelFont: Font { .system(size: 11, weight: .medium, design: .monospaced) } private var sliderFrameHeight: CGFloat { max(restingTrackHeight, draggingTrackHeight) } func timeString(from seconds: Double) -> String { let totalMinutes = Int(seconds) / 60 let remainingSeconds = Int(seconds) % 60 let hours = totalMinutes / 60 let minutes = totalMinutes % 60 if hours > 0 { return String(format: "%d:%02d:%02d", hours, minutes, remainingSeconds) } else { return String(format: "%d:%02d", minutes, remainingSeconds) } } } struct CustomSlider: View { @Binding var value: Double var range: ClosedRange var color: Color = .white @Binding var dragging: Bool @Binding var lastDragged: Date var onValueChange: ((Double) -> Void)? var thumbSize: CGFloat = 12 var restingTrackHeight: CGFloat = 5 var draggingTrackHeight: CGFloat = 9 var body: some View { GeometryReader { geometry in let width = geometry.size.width let trackHeight = CGFloat(dragging ? draggingTrackHeight : restingTrackHeight) let rangeSpan = range.upperBound - range.lowerBound let progress = rangeSpan == .zero ? 0 : (value - range.lowerBound) / rangeSpan let filledTrackWidth = min(max(progress, 0), 1) * width ZStack(alignment: .leading) { // Background track Rectangle() .fill(.gray.opacity(0.3)) .frame(height: trackHeight) // Filled track Rectangle() .fill(color) .frame(width: filledTrackWidth, height: trackHeight) } .cornerRadius(trackHeight / 2) .frame(height: max(restingTrackHeight, draggingTrackHeight)) .contentShape(Rectangle()) .highPriorityGesture( DragGesture(minimumDistance: 0) .onChanged { gesture in withAnimation { dragging = true } let newValue = range.lowerBound + Double(gesture.location.x / width) * rangeSpan value = min(max(newValue, range.lowerBound), range.upperBound) } .onEnded { _ in onValueChange?(value) dragging = false lastDragged = Date() } ) .animation(.bouncy.speed(1.4), value: dragging) } } } private struct MediaOutputPickerButton: View { @ObservedObject private var routeManager = AudioRouteManager.shared @ObservedObject private var musicManager = MusicManager.shared @StateObject private var volumeModel = MediaOutputVolumeViewModel() @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 private var isAppleMusicActive: Bool { musicManager.bundleIdentifier == "com.apple.Music" } var body: some View { HStack(spacing: 4) { 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() } } 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() } } } } .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 } updatePopoverActivity() } .onChange(of: musicManager.bundleIdentifier) { _, newBundle in if newBundle == "com.apple.Music" { Task { await airPlayManager.refreshDevices() } } } .onDisappear { vm.isMediaOutputPopoverActive = false } } private var buttonIcon: String { routeManager.activeDevice?.iconName ?? "speaker.wave.2" } private func updatePopoverActivity() { vm.isMediaOutputPopoverActive = (isPopoverPresented || isAirPlayPopoverPresented) && isHoveringPopover } } struct MediaOutputSelectorPopover: View { @ObservedObject var routeManager: AudioRouteManager @ObservedObject var volumeModel: MediaOutputVolumeViewModel var onHoverChanged: (Bool) -> Void var dismiss: () -> Void var body: some View { VStack(spacing: 16) { volumeSection Divider() devicesSection } .frame(width: 240) .padding(16) .onHover { hovering in onHoverChanged(hovering) } .onDisappear { onHoverChanged(false) } } private var volumeSection: some View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 12) { Button { volumeModel.toggleMute() } label: { Image(systemName: volumeIconName) .font(.system(size: 18, weight: .semibold)) .foregroundColor(.primary) .frame(width: 28, height: 28) .background( Circle() .fill(Color.secondary.opacity(0.18)) ) .contentShape(Circle()) } .buttonStyle(.plain) Slider( value: Binding( get: { Double(volumeModel.level) }, set: { newValue in volumeModel.setVolume(Float(newValue)) } ), in: 0 ... 1 ) .tint(.accentColor) } HStack { Text("Output volume") .font(.caption) .foregroundColor(.secondary) Spacer() Text(volumePercentage) .font(.caption) .foregroundColor(.secondary) } } } private var devicesSection: some View { VStack(alignment: .leading, spacing: 8) { Text("Output devices") .font(.caption) .foregroundColor(.secondary) if routeManager.devices.isEmpty { Text("No audio outputs available") .font(.callout) .foregroundColor(.secondary) .padding(.vertical, 12) .frame(maxWidth: .infinity, alignment: .leading) } else { ScrollView { VStack(spacing: 6) { ForEach(routeManager.devices) { device in Button { routeManager.select(device: device) dismiss() } label: { HStack(spacing: 8) { Image(systemName: device.iconName) .font(.system(size: 14, weight: .medium)) Text(device.name) .foregroundColor(.primary) .lineLimit(1) Spacer() if device.id == routeManager.activeDeviceID { 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.id == routeManager.activeDeviceID ? Color.primary.opacity(0.12) : .clear) ) } .buttonStyle(.plain) } } .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxHeight: 240) } } } private var volumeIconName: String { if volumeModel.isMuted || volumeModel.level <= 0.001 { return "speaker.slash.fill" } else if volumeModel.level < 0.33 { return "speaker.wave.1.fill" } else if volumeModel.level < 0.66 { return "speaker.wave.2.fill" } return "speaker.wave.3.fill" } private var volumePercentage: String { "\(Int(round(volumeModel.level * 100)))%" } } 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 private let controller: SystemVolumeController private var cancellables: Set = [] init(controller: SystemVolumeController = .shared) { self.controller = controller controller.start() level = controller.currentVolume isMuted = controller.isMuted NotificationCenter.default.publisher(for: .systemVolumeDidChange) .receive(on: RunLoop.main) .sink { [weak self] notification in guard let self, let value = notification.userInfo?["value"] as? Float, let muted = notification.userInfo?["muted"] as? Bool else { return } self.level = value self.isMuted = muted } .store(in: &cancellables) NotificationCenter.default.publisher(for: .systemAudioRouteDidChange) .receive(on: RunLoop.main) .sink { [weak self] _ in self?.syncFromController() } .store(in: &cancellables) } func setVolume(_ value: Float) { level = value if value > 0 { isMuted = false } controller.setVolume(value) } func toggleMute() { isMuted.toggle() controller.toggleMute() } private func syncFromController() { level = controller.currentVolume isMuted = controller.isMuted } } #Preview { NotchHomeView( albumArtNamespace: Namespace().wrappedValue ) .environmentObject(DynamicIslandViewModel()) }