Atoll/DynamicIsland/components/Notch/NotchHomeView.swift

1258 lines
44 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)
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)
.clipShape(
RoundedRectangle(
cornerRadius: Defaults[.cornerRadiusScaling]
? musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.opened/3 : MusicPlayerImageSizes.cornerRadiusInset.opened
: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.closed/3 : MusicPlayerImageSizes.cornerRadiusInset.closed
)
)
)
.clipped()
.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()
.padding(.bottom, -5)
}
.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)
.clipShape(
RoundedRectangle(
cornerRadius: Defaults[.cornerRadiusScaling]
? musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.opened/3 : MusicPlayerImageSizes.cornerRadiusInset.opened
: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.closed/3 : MusicPlayerImageSizes.cornerRadiusInset.closed
)
) // ensures that even the rectagular album cover is rounded just like the MacOS media player
}
.matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)
.clipped()
}
@ViewBuilder
private var appIconOverlay: some View {
if vm.notchState == .open && !musicManager.usingAppIconForArtwork {
AppIcon(for: musicManager.bundleIdentifier ?? "com.apple.Music")
.resizable()
.scaledToFill()
.frame(width: 36, height: 36)
.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: .system(size: 12, weight: .regular),
nsFont: .headline,
textColor: .white.opacity(0.7),
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 isAppleMusicActive: Bool {
musicManager.bundleIdentifier == "com.apple.Music"
}
private var displayedSlots: [MusicControlButton] {
if showCustomControls {
let normalized = slotConfig.normalized(allowingMediaOutput: showMediaOutputControl, isAppleMusicActive: isAppleMusicActive)
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 .airPlay:
AirPlayPickerButton()
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)
.padding(Defaults[.enableMinimalisticUI] ? 0 : 8) //Putting the main padding for home view here for consistency
}
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.6)
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
@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 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
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<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())
}