Atoll/DynamicIsland/components/Notch/NotchHomeView.swift
Amir Zarrinkafsh 75bb8ce9be
feat(media): add airplay device selector with volume control for apple music
Adds an AirPlay device picker popover to the music player controls, allowing users to select and control volume for individual AirPlay devices (HomePod, Apple TV, AirPort Express, etc.) when Apple Music is the active player.
2026-03-22 17:27:00 +11:00

1233 lines
43 KiB
Swift

/*
* 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 <https://www.gnu.org/licenses/>.
*/
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<String>(
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<Double>
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()
@StateObject private var airPlayManager = AppleMusicAirPlayManager()
@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.
private 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
let volume = airPlayManager.devices.first { $0.id == deviceID }?.volume ?? 0
sliderValue = Double(volume)
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<AnyCancellable> = []
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())
}