diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index f4707a2..0d20fd6 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -12,6 +12,11 @@ import Defaults import KeyboardShortcuts import SwiftUI import SwiftUIIntrospect +#if canImport(AppKit) +import AppKit +#elseif canImport(UIKit) +import UIKit +#endif @MainActor struct ContentView: View { @@ -520,17 +525,15 @@ struct ContentView: View { .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .doNotDisturb) && vm.notchState == .closed && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && (doNotDisturbManager.isDoNotDisturbActive || Defaults[.focusIndicatorNonPersistent]) && !vm.hideOnClosed && !lockScreenManager.isLocked { DoNotDisturbLiveActivity() - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { - LockScreenLiveActivity() - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed { - PrivacyLiveActivity() + } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { + LockScreenLiveActivity() + } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed { + PrivacyLiveActivity() } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle) } else if vm.notchState == .open { DynamicIslandHeader() .frame(height: max(24, vm.effectiveClosedNotchHeight)) - .blur(radius: abs(gestureProgress) > 0.3 ? min(abs(gestureProgress), 8) : 0) - .animation(.spring(response: 1, dampingFraction: 1, blendDuration: 0.8), value: vm.notchState) } else { Rectangle().fill(.clear).frame(width: vm.closedNotchSize.width - 20, height: vm.effectiveClosedNotchHeight) } @@ -688,12 +691,23 @@ struct ContentView: View { let secondary = resolveMusicSecondaryLiveActivity() let notchContentHeight = max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) let wingBaseWidth = max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) - let centerBaseWidth = vm.closedNotchSize.width + (isHovering ? 8 : 0) - let inlineSneakPeekActive = (coordinator.expandingView.show && (coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer) && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) - let rightWingWidth = resolvedRightWingWidth(for: secondary, baseWidth: wingBaseWidth, centerBaseWidth: centerBaseWidth) - let centerWidth = resolvedCenterWidth(baseWidth: centerBaseWidth, baseRightWidth: wingBaseWidth, desiredRightWidth: rightWingWidth) + let rawCenterBaseWidth = vm.closedNotchSize.width + (isHovering ? 8 : 0) + let centerBaseWidth = max(rawCenterBaseWidth, 96) + let inlineSneakPeekActive = ( + coordinator.expandingView.show && + (coordinator.expandingView.type == .music || coordinator.expandingView.type == .timer) && + Defaults[.enableSneakPeek] && + Defaults[.sneakPeekStyles] == .inline + ) + let rightWingWidth = resolvedRightWingWidth( + for: secondary, + baseWidth: wingBaseWidth, + centerBaseWidth: centerBaseWidth, + notchHeight: notchContentHeight + ) + let effectiveCenterWidth = inlineSneakPeekActive ? 380 : centerBaseWidth + let notchWidth = wingBaseWidth + effectiveCenterWidth + rightWingWidth let badgeSize = max(16, notchContentHeight * 0.45) - let effectiveCenterWidth = inlineSneakPeekActive ? 380 : centerWidth HStack(spacing: 0) { ZStack(alignment: .bottomTrailing) { @@ -729,7 +743,6 @@ struct ContentView: View { ) .opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) Spacer(minLength: vm.closedNotchSize.width) - // Song Artist Text(musicManager.artistName) .lineLimit(1) .truncationMode(.tail) @@ -744,7 +757,6 @@ struct ContentView: View { ) .opacity((coordinator.expandingView.show && Defaults[.enableSneakPeek] && Defaults[.sneakPeekStyles] == .inline) ? 1 : 0) Spacer(minLength: vm.closedNotchSize.width) - // Timer Status Text(timerManager.formattedRemainingTime()) .lineLimit(1) .truncationMode(.tail) @@ -754,11 +766,12 @@ struct ContentView: View { } ) - musicRightWing(for: secondary) + musicRightWing(for: secondary, notchHeight: notchContentHeight) .frame(width: rightWingWidth, height: notchContentHeight, alignment: .center) .id(secondary?.id ?? "music-spectrum") .contentTransition(.symbolEffect(.replace)) } + .frame(width: notchWidth, height: notchContentHeight) .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) .animation(.smooth(duration: 0.25), value: secondary?.id) } @@ -792,32 +805,81 @@ struct ContentView: View { return nil } - private func resolvedRightWingWidth(for secondary: MusicSecondaryLiveActivity?, baseWidth: CGFloat, centerBaseWidth: CGFloat) -> CGFloat { + private func resolvedRightWingWidth(for secondary: MusicSecondaryLiveActivity?, baseWidth: CGFloat, centerBaseWidth: CGFloat, notchHeight: CGFloat) -> CGFloat { guard let secondary else { return baseWidth } - func scaledWidth(factor: CGFloat, extra: CGFloat = 0) -> CGFloat { - max(baseWidth, max(centerBaseWidth * factor, baseWidth + extra)) - } - switch secondary { case .timer: - return scaledWidth(factor: 0.58, extra: 28) - case .reminder: - return scaledWidth(factor: 0.52, extra: 22) + return timerRightWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth) + case .reminder(let entry): + return reminderRightWingWidth(for: entry, baseWidth: baseWidth, notchHeight: notchHeight, now: reminderManager.currentDate) case .capsLock(let showLabel): - return showLabel ? scaledWidth(factor: 0.4, extra: 12) : baseWidth + return showLabel ? scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.4, extra: 12) : baseWidth case .coolFace: - return scaledWidth(factor: 0.38, extra: 8) + return scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.36, extra: 8) case .focus: - return scaledWidth(factor: 0.42, extra: 10) + return focusRightWingWidth(baseWidth: baseWidth) case .recording: - return scaledWidth(factor: 0.45, extra: 14) + return recordingRightWingWidth(baseWidth: baseWidth) } } - private func resolvedCenterWidth(baseWidth: CGFloat, baseRightWidth: CGFloat, desiredRightWidth: CGFloat) -> CGFloat { - let delta = max(0, desiredRightWidth - baseRightWidth) - return max(96, baseWidth - delta) + private func timerRightWingWidth(baseWidth: CGFloat, centerBaseWidth: CGFloat) -> CGFloat { + if timerShowsCountdown { + return timerCountdownWingWidth(baseWidth: baseWidth) + } + + let showsProgress = timerShowsProgress + let usesRingProgress = timerProgressStyle == .ring + + switch (showsProgress, usesRingProgress) { + case (true, true): + return scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.46, extra: 18) + case (true, false): + return scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.52, extra: 24) + case (false, _): + return scaledWingWidth(baseWidth: baseWidth, centerBaseWidth: centerBaseWidth, factor: 0.38, extra: 12) + } + } + + private func timerCountdownWingWidth(baseWidth: CGFloat) -> CGFloat { + let padding: CGFloat = 18 + let ringWidth: CGFloat = (timerShowsProgress && timerProgressStyle == .ring) ? 30 : 0 + let spacing: CGFloat = (ringWidth > 0) ? 8 : 0 + let countdownText = timerManager.formattedRemainingTime() + let countdownWidth = TimerSupplementMetrics.countdownFrameWidth(for: countdownText) + return max(baseWidth, padding + ringWidth + spacing + countdownWidth) + } + + private func reminderRightWingWidth(for entry: ReminderLiveActivityManager.ReminderEntry, baseWidth: CGFloat, notchHeight: CGFloat, now: Date) -> CGFloat { + let padding: CGFloat = 16 + switch reminderPresentationStyle { + case .ringCountdown: + let diameter = ReminderSupplementMetrics.ringDiameter(for: notchHeight) + return max(baseWidth, padding + diameter) + case .digital: + let countdownText = ReminderSupplementMetrics.digitalCountdownText(for: entry, now: now) + let width = ReminderSupplementMetrics.digitalFrameWidth(for: countdownText) + return max(baseWidth, padding + width) + case .minutes: + let minutesText = ReminderSupplementMetrics.minutesCountdownText(for: entry, now: now) + let width = ReminderSupplementMetrics.minutesFrameWidth(for: minutesText) + return max(baseWidth, padding + width) + } + } + + private func focusRightWingWidth(baseWidth: CGFloat) -> CGFloat { + // Focus pairings now mirror the default music spectrum width to keep the notch compact. + return baseWidth + } + + private func recordingRightWingWidth(baseWidth: CGFloat) -> CGFloat { + // Recording keeps the spectrum but should stay near the default width with a small inset. + return max(baseWidth + 18, 54) + } + + private func scaledWingWidth(baseWidth: CGFloat, centerBaseWidth: CGFloat, factor: CGFloat, extra: CGFloat) -> CGFloat { + max(baseWidth, max(centerBaseWidth * factor, baseWidth + extra)) } @ViewBuilder @@ -834,7 +896,7 @@ struct ContentView: View { .foregroundStyle(timerAccentColor) case .reminder(let entry): let accent = reminderColor(for: entry, now: reminderManager.currentDate) - Image(systemName: "calendar.badge.clock") + Image(systemName: "clock") .font(.system(size: badgeSize * 0.55, weight: .semibold)) .foregroundStyle(accent) case .focus(let mode): @@ -866,7 +928,7 @@ struct ContentView: View { } @ViewBuilder - private func musicRightWing(for secondary: MusicSecondaryLiveActivity?) -> some View { + private func musicRightWing(for secondary: MusicSecondaryLiveActivity?, notchHeight: CGFloat) -> some View { switch secondary { case .timer: MusicTimerSupplementView( @@ -874,14 +936,16 @@ struct ContentView: View { accentColor: timerAccentColor, showsCountdown: timerShowsCountdown, showsProgress: timerShowsProgress, - progressStyle: timerProgressStyle + progressStyle: timerProgressStyle, + notchHeight: notchHeight ) case .reminder(let entry): MusicReminderSupplementView( entry: entry, now: reminderManager.currentDate, style: reminderPresentationStyle, - accent: reminderColor(for: entry, now: reminderManager.currentDate) + accent: reminderColor(for: entry, now: reminderManager.currentDate), + notchHeight: notchHeight ) case .capsLock(let showLabel): if showLabel { @@ -893,15 +957,17 @@ struct ContentView: View { IdleAnimationView() .frame(maxWidth: .infinity, maxHeight: .infinity) .padding(.horizontal, 4) - case .focus, .recording: + case .focus(let mode): spectrumView(forceSpectrum: true) + case .recording: + spectrumView(forceSpectrum: true, trailingInset: 6) case .none: spectrumView(forceSpectrum: false) } } @ViewBuilder - private func spectrumView(forceSpectrum: Bool) -> some View { + private func spectrumView(forceSpectrum: Bool, trailingInset: CGFloat = 0) -> some View { if useMusicVisualizer || forceSpectrum { Rectangle() .fill(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor).gradient : Color.gray.gradient) @@ -912,6 +978,7 @@ struct ContentView: View { .frame(width: 16, height: 12) } .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.trailing, trailingInset) } else { LottieAnimationView() .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -1462,65 +1529,113 @@ private struct MusicTimerSupplementView: View { let showsCountdown: Bool let showsProgress: Bool let progressStyle: TimerProgressStyle + let notchHeight: CGFloat private var clampedProgress: Double { min(max(timerManager.progress, 0), 1) } + private var showsRingProgress: Bool { + showsProgress && progressStyle == .ring + } + + private var showsBarProgress: Bool { + showsProgress && progressStyle == .bar + } + + private var countdownText: String { + timerManager.formattedRemainingTime() + } + + private var countdownTextWidth: CGFloat { + max(1, TimerSupplementMetrics.countdownTextWidth(for: countdownText)) + } + + private var countdownFrameWidth: CGFloat { + TimerSupplementMetrics.countdownFrameWidth(for: countdownText) + } + + private var timerNameFrameWidth: CGFloat { + TimerSupplementMetrics.timerNameFrameWidth(for: timerManager.timerName) + } + + private var ringDiameter: CGFloat { + max(min(notchHeight - 4, 26), 20) + } + var body: some View { - HStack(spacing: 10) { - if showsProgress { - if progressStyle == .ring { - ZStack { - Circle() - .stroke(Color.white.opacity(0.18), lineWidth: 3) - Circle() - .trim(from: 0, to: clampedProgress) - .stroke(accentColor, style: StrokeStyle(lineWidth: 3, lineCap: .round)) - .rotationEffect(.degrees(-90)) - .animation(.smooth(duration: 0.25), value: clampedProgress) - } - .frame(width: 24, height: 24) - } else if !showsCountdown { - barView() - } + HStack(spacing: showsRingProgress && showsCountdown ? 8 : 0) { + if showsRingProgress { + ringView } if showsCountdown { - VStack(alignment: .trailing, spacing: 2) { - Text(timerManager.formattedRemainingTime()) - .font(.system(size: 13, weight: .semibold, design: .monospaced)) - .foregroundColor(timerManager.isOvertime ? .red : .white) - .contentTransition(.numericText()) - .animation(.smooth(duration: 0.25), value: timerManager.remainingTime) - - if showsProgress && progressStyle == .bar { - barView() - } - } - .frame(maxWidth: .infinity, alignment: .trailing) - } else if !showsProgress { - Text(timerManager.timerName) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.white) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .trailing) + countdownStack + } else if showsBarProgress { + standaloneBarView + } else { + timerNameView } } .frame(maxWidth: .infinity, alignment: .trailing) } - private func barView() -> some View { + private var countdownStack: some View { + VStack(alignment: .trailing, spacing: showsBarProgress ? 4 : 0) { + Text(countdownText) + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(timerManager.isOvertime ? .red : .white) + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: timerManager.remainingTime) + .frame(width: countdownFrameWidth, alignment: .trailing) + + if showsBarProgress { + barView(width: countdownTextWidth) + } + } + .padding(.trailing, 8) + .frame(maxWidth: .infinity, alignment: .trailing) + } + + private var ringView: some View { + ZStack { + Circle() + .stroke(Color.white.opacity(0.18), lineWidth: 3) + Circle() + .trim(from: 0, to: clampedProgress) + .stroke(accentColor, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.smooth(duration: 0.25), value: clampedProgress) + } + .frame(width: ringDiameter, height: ringDiameter) + .frame(width: max(ringDiameter + 4, 30), height: notchHeight, alignment: .center) + } + + private var standaloneBarView: some View { + barView(width: 68) + .frame(maxWidth: .infinity, alignment: .trailing) + } + + private var timerNameView: some View { + Text(timerManager.timerName) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + .lineLimit(1) + .frame(width: timerNameFrameWidth, alignment: .trailing) + } + + private func barView(width: CGFloat) -> some View { Capsule() .fill(Color.white.opacity(0.15)) - .frame(width: 68, height: 4) + .frame(width: width, height: 4) .overlay(alignment: .leading) { Capsule() .fill(accentColor) - .frame(width: 68 * max(0, CGFloat(clampedProgress)), height: 4) + .frame(width: width * max(0, CGFloat(clampedProgress)), height: 4) .animation(.smooth(duration: 0.25), value: clampedProgress) } } + } private struct MusicReminderSupplementView: View { @@ -1528,6 +1643,53 @@ private struct MusicReminderSupplementView: View { let now: Date let style: ReminderPresentationStyle let accent: Color + let notchHeight: CGFloat + + var body: some View { + Group { + switch style { + case .ringCountdown: + ringCountdownView + case .digital: + digitalCountdownView + case .minutes: + minutesCountdownView + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } + + private var ringCountdownView: some View { + ZStack { + Circle() + .stroke(Color.white.opacity(0.15), lineWidth: 3) + Circle() + .trim(from: 0, to: progressValue) + .stroke(accent, style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(.smooth(duration: 0.25), value: progressValue) + } + .frame(width: ringDiameter, height: ringDiameter) + .frame(width: max(ringDiameter + 4, 26), height: notchHeight, alignment: .center) + } + + private var digitalCountdownView: some View { + Text(digitalCountdownText) + .font(.system(size: 15, weight: .semibold, design: .monospaced)) + .foregroundColor(accent) + .contentTransition(.numericText()) + .animation(.smooth(duration: 0.25), value: digitalCountdownText) + .frame(width: digitalFrameWidth, alignment: .trailing) + .frame(height: notchHeight, alignment: .center) + } + + private var minutesCountdownView: some View { + Text(minutesCountdownText) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(accent) + .frame(width: minutesFrameWidth, alignment: .trailing) + .frame(height: notchHeight, alignment: .center) + } private var progressValue: Double { guard entry.leadTime > 0 else { return 1 } @@ -1537,48 +1699,23 @@ private struct MusicReminderSupplementView: View { } private var digitalCountdownText: String { - let remaining = max(entry.event.start.timeIntervalSince(now), 0) - let totalSeconds = Int(remaining.rounded(.down)) - let minutes = totalSeconds / 60 - let seconds = totalSeconds % 60 - return String(format: "%02d:%02d", minutes, seconds) + ReminderSupplementMetrics.digitalCountdownText(for: entry, now: now) } private var minutesCountdownText: String { - let remaining = max(entry.event.start.timeIntervalSince(now), 0) - let minutes = max(1, Int(ceil(remaining / 60))) - return minutes == 1 ? "in 1 min" : "in \(minutes) min" + ReminderSupplementMetrics.minutesCountdownText(for: entry, now: now) } - var body: some View { - switch style { - case .ringCountdown: - ZStack { - Circle() - .stroke(Color.white.opacity(0.15), lineWidth: 3) - Circle() - .trim(from: 0, to: progressValue) - .stroke(accent, style: StrokeStyle(lineWidth: 3, lineCap: .round)) - .rotationEffect(.degrees(-90)) - .animation(.smooth(duration: 0.25), value: progressValue) - } - .frame(width: 24, height: 24) - .frame(maxWidth: .infinity, alignment: .trailing) + private var ringDiameter: CGFloat { + ReminderSupplementMetrics.ringDiameter(for: notchHeight) + } - case .digital: - Text(digitalCountdownText) - .font(.system(size: 15, weight: .semibold, design: .monospaced)) - .foregroundColor(accent) - .contentTransition(.numericText()) - .animation(.smooth(duration: 0.25), value: digitalCountdownText) - .frame(maxWidth: .infinity, alignment: .trailing) + private var digitalFrameWidth: CGFloat { + ReminderSupplementMetrics.digitalFrameWidth(for: digitalCountdownText) + } - case .minutes: - Text(minutesCountdownText) - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(accent) - .frame(maxWidth: .infinity, alignment: .trailing) - } + private var minutesFrameWidth: CGFloat { + ReminderSupplementMetrics.minutesFrameWidth(for: minutesCountdownText) } } @@ -1595,6 +1732,64 @@ private struct MusicCapsLockLabelView: View { } } +#if canImport(AppKit) +private typealias MusicSupplementFont = NSFont +#elseif canImport(UIKit) +private typealias MusicSupplementFont = UIFont +#endif + +private enum TimerSupplementMetrics { + static func countdownTextWidth(for text: String) -> CGFloat { + musicMeasureText(text, font: MusicSupplementFont.monospacedDigitSystemFont(ofSize: 13, weight: .semibold)) + } + + static func countdownFrameWidth(for text: String) -> CGFloat { + max(countdownTextWidth(for: text) + 16, 72) + } + + static func timerNameFrameWidth(for text: String) -> CGFloat { + guard !text.isEmpty else { return 64 } + let width = musicMeasureText(text, font: MusicSupplementFont.systemFont(ofSize: 12, weight: .medium)) + return max(width + 14, 64) + } +} + +private enum ReminderSupplementMetrics { + static func digitalCountdownText(for entry: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String { + let remaining = max(entry.event.start.timeIntervalSince(now), 0) + let totalSeconds = Int(remaining.rounded(.down)) + let minutes = totalSeconds / 60 + let seconds = totalSeconds % 60 + return String(format: "%02d:%02d", minutes, seconds) + } + + static func minutesCountdownText(for entry: ReminderLiveActivityManager.ReminderEntry, now: Date) -> String { + let remaining = max(entry.event.start.timeIntervalSince(now), 0) + let minutes = max(1, Int(ceil(remaining / 60))) + return minutes == 1 ? "in 1 min" : "in \(minutes) min" + } + + static func digitalFrameWidth(for text: String) -> CGFloat { + let width = musicMeasureText(text, font: MusicSupplementFont.monospacedDigitSystemFont(ofSize: 15, weight: .semibold)) + return max(width + 18, 76) + } + + static func minutesFrameWidth(for text: String) -> CGFloat { + let width = musicMeasureText(text, font: MusicSupplementFont.systemFont(ofSize: 13, weight: .semibold)) + return max(width + 18, 88) + } + + static func ringDiameter(for notchHeight: CGFloat) -> CGFloat { + max(min(notchHeight - 12, 22), 16) + } +} + +private func musicMeasureText(_ text: String, font: MusicSupplementFont) -> CGFloat { + guard !text.isEmpty else { return 0 } + let attributes: [NSAttributedString.Key: Any] = [.font: font] + return CGFloat(ceil(NSAttributedString(string: text, attributes: attributes).size().width)) +} + struct FullScreenDropDelegate: DropDelegate { @Binding var isTargeted: Bool let onDrop: () -> Void