// // NotchHomeView.swift // DynamicIsland // // Created by Hugo Persson on 2024-08-18. // Modified by Harsh Vardhan Goswami & Richard Kunkli & Mustafa Ramadan // 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) .drawingGroup() .compositingGroup() } .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 { Image(nsImage: musicManager.albumArt) .resizable() .clipped() .clipShape( RoundedRectangle( cornerRadius: Defaults[.cornerRadiusScaling] ? MusicPlayerImageSizes.cornerRadiusInset.opened : MusicPlayerImageSizes.cornerRadiusInset.closed) ) .aspectRatio(1, contentMode: .fit) .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) } .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 { Image(nsImage: musicManager.albumArt) .resizable() .aspectRatio(1, 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 { @ObservedObject var musicManager = MusicManager.shared @State private var sliderValue: Double = 0 @State private var dragging: Bool = false @State private var lastDragged: 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 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) } } } } private var musicSlider: some View { TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { 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 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 .red } } 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)) { musicManager.previousTrack() } case .trackForward: playbackButton(icon: "forward.fill", press: .nudge(skipMagnitude)) { musicManager.nextTrack() } case .seekBackward: playbackButton(icon: "gobackward.10", press: .wiggle(.counterClockwise)) { musicManager.seek(by: -seekInterval) } case .seekForward: playbackButton(icon: "goforward.10", press: .wiggle(.clockwise)) { musicManager.seek(by: seekInterval) } case .shuffle: HoverButton( icon: "shuffle", iconColor: musicManager.isShuffled ? .red : .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 ? Color(nsColor: MusicManager.shared.avgColor) : .white, scale: .medium ) { enableLyrics.toggle() } } } private func playbackButton( icon: String, press: HoverButton.PressEffect?, action: @escaping () -> Void ) -> some View { HoverButton( icon: icon, scale: .medium, pressEffect: press ) { action() } } } // 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 let albumArtNamespace: Namespace.ID 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] { // Minimalistic mode: Only show compact music player MinimalisticMusicPlayerView(albumArtNamespace: albumArtNamespace) } else { // Normal mode: Show full music player with optional calendar and webcam MusicPlayerView(albumArtNamespace: albumArtNamespace) .frame(maxWidth: .infinity, alignment: .leading) if Defaults[.showCalendar] { CalendarView() .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) } } 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: 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 ) } 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 @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 } } 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: 180) } } } 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)))%" } } 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()) }