From 27b16a3d15a3f351a43d12c38f712844fdc824f5 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Thu, 15 Jan 2026 23:31:07 +0530 Subject: [PATCH] Update Third Party stuff --- .../xcshareddata/swiftpm/Package.resolved | 2 +- DynamicIsland/ContentView.swift | 103 +++++++- .../DynamicIslandViewCoordinator.swift | 63 ++++- DynamicIsland/Localizable.xcstrings | 8 + .../ExtensionLiveActivityViews.swift | 232 +++++++++++++++--- .../ExtensionRenderingHelpers.swift | 207 +++++++++++++--- .../Settings/ExtensionsSettings.swift | 31 +++ .../ExtensionLiveActivityManager.swift | 89 +++++-- 8 files changed, 636 insertions(+), 99 deletions(-) diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 2cf39bc..d232e97 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -7,7 +7,7 @@ "location" : "https://github.com/Ebullioscopic/AtollExtensionKit", "state" : { "branch" : "main", - "revision" : "61d9a7be60416f107eb45b470eb3c8a5296b180f" + "revision" : "464423a5ffd29c1836f93662a5cb0529e7c1c355" } }, { diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index f320769..fd633d8 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -495,6 +495,7 @@ struct ContentView: View { let musicSecondary = resolveMusicSecondaryLiveActivity(isMusicPairingEligible: musicPairingEligible) let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary) let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID) + let activeSneakPeekStyle = resolvedSneakPeekStyle() let expansionMatchesSecondary: Bool = { guard let musicSecondary else { return false } switch musicSecondary { @@ -540,6 +541,23 @@ struct ContentView: View { .frame(width: 76, alignment: .trailing) } .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) + } else if coordinator.sneakPeek.show, + case let .extensionLiveActivity(bundleID, activityID) = coordinator.sneakPeek.type, + activeSneakPeekStyle == .inline, + vm.notchState == .closed, + !vm.hideOnClosed { + let inlinePayload = extensionLiveActivityManager.payload(bundleIdentifier: bundleID, activityID: activityID) + ExtensionInlineSneakPeekView( + payload: inlinePayload, + title: coordinator.sneakPeek.title, + subtitle: coordinator.sneakPeek.subtitle, + accentColor: coordinator.sneakPeek.accentColor ?? .gray, + notchHeight: vm.effectiveClosedNotchHeight, + closedNotchWidth: vm.closedNotchSize.width, + isHovering: isHovering, + gestureProgress: gestureProgress + ) + .transition(.move(edge: .trailing).combined(with: .opacity)) } else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) && (coordinator.sneakPeek.type != .timer) && (coordinator.sneakPeek.type != .reminder) && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) { InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress) .transition( @@ -568,17 +586,19 @@ struct ContentView: View { } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed { PrivacyLiveActivity() } else if let extensionPayload = extensionStandalonePayload { + let suppressingCenterText = shouldSuppressExtensionCenter(for: extensionPayload) let layout = extensionStandaloneLayout( for: extensionPayload, notchHeight: vm.effectiveClosedNotchHeight, - isHovering: isHovering + isHovering: isHovering, + suppressingCenterText: suppressingCenterText ) ExtensionLiveActivityStandaloneView( payload: extensionPayload, layout: layout, - isHovering: isHovering + isHovering: isHovering, + notchState: vm.notchState ) - .transition(.opacity.combined(with: .scale)) } 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 { @@ -599,7 +619,7 @@ struct ContentView: View { } // Old sneak peek music else if coordinator.sneakPeek.type == .music { - if vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard { + if vm.notchState == .closed && !vm.hideOnClosed && activeSneakPeekStyle == .standard { HStack(alignment: .center) { Image(systemName: "music.note") GeometryReader { geo in @@ -612,7 +632,7 @@ struct ContentView: View { } // Timer sneak peek else if coordinator.sneakPeek.type == .timer { - if !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard { + if !vm.hideOnClosed && activeSneakPeekStyle == .standard { HStack(alignment: .center) { Image(systemName: "timer") GeometryReader { geo in @@ -624,7 +644,7 @@ struct ContentView: View { } } else if coordinator.sneakPeek.type == .reminder { - if !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard, let reminder = reminderManager.activeReminder { + if !vm.hideOnClosed && activeSneakPeekStyle == .standard, let reminder = reminderManager.activeReminder { GeometryReader { geo in let chipColor = Color(nsColor: reminder.event.calendar.color).ensureMinimumBrightness(factor: 0.7) HStack(spacing: 6) { @@ -642,10 +662,33 @@ struct ContentView: View { .padding(.bottom, 10) } } + // Extension live activity sneak peek + else if case .extensionLiveActivity = coordinator.sneakPeek.type { + if !vm.hideOnClosed && activeSneakPeekStyle == .standard { + HStack(alignment: .center, spacing: 6) { + if !coordinator.sneakPeek.icon.isEmpty { + Image(systemName: coordinator.sneakPeek.icon) + } + GeometryReader { geo in + let combinedText = coordinator.sneakPeek.subtitle.isEmpty ? + coordinator.sneakPeek.title : + "\(coordinator.sneakPeek.title) - \(coordinator.sneakPeek.subtitle)" + MarqueeText( + .constant(combinedText), + textColor: coordinator.sneakPeek.accentColor ?? .gray, + minDuration: 1, + frameWidth: geo.size.width + ) + } + } + .foregroundStyle(coordinator.sneakPeek.accentColor ?? .gray) + .padding(.bottom, 10) + } + } } } } - .conditionalModifier((coordinator.sneakPeek.show && coordinator.sneakPeek.type == .music && vm.notchState == .closed && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.sneakPeek.show && coordinator.sneakPeek.type == .timer && !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard) || (coordinator.sneakPeek.show && (coordinator.sneakPeek.type != .music && coordinator.sneakPeek.type != .timer) && (vm.notchState == .closed))) { view in + .conditionalModifier(shouldFixSizeForSneakPeek()) { view in view .fixedSize() } @@ -1224,11 +1267,17 @@ struct ContentView: View { return payload } - private func extensionStandaloneLayout(for payload: ExtensionLiveActivityPayload, notchHeight: CGFloat, isHovering: Bool) -> ExtensionStandaloneLayout { + private func extensionStandaloneLayout(for payload: ExtensionLiveActivityPayload, notchHeight: CGFloat, isHovering: Bool, suppressingCenterText: Bool) -> ExtensionStandaloneLayout { let outerHeight = notchHeight let contentHeight = max(0, notchHeight - (isHovering ? 0 : 12)) let leadingWidth = max(contentHeight, 44) - let centerWidth = max(vm.closedNotchSize.width + (isHovering ? 8 : 0), 96) + let centerWidth: CGFloat = { + if suppressingCenterText { + let base = vm.closedNotchSize.width * 0.35 + return max(64, min(base, 96)) + } + return max(vm.closedNotchSize.width + (isHovering ? 8 : 0), 96) + }() let trailingWidth = ExtensionLayoutMetrics.trailingWidth( for: payload, baseWidth: leadingWidth, @@ -1241,10 +1290,22 @@ struct ContentView: View { contentHeight: contentHeight, leadingWidth: leadingWidth, centerWidth: centerWidth, - trailingWidth: trailingWidth + trailingWidth: trailingWidth, + suppressingCenterText: suppressingCenterText ) } + private func shouldSuppressExtensionCenter(for payload: ExtensionLiveActivityPayload) -> Bool { + guard vm.notchState == .closed else { return false } + let descriptor = payload.descriptor + guard descriptor.sneakPeekConfig?.enabled ?? true else { return false } + guard coordinator.sneakPeek.show else { return false } + guard case let .extensionLiveActivity(bundleID, activityID) = coordinator.sneakPeek.type else { return false } + guard bundleID == payload.bundleIdentifier && activityID == descriptor.id else { return false } + let style = coordinator.sneakPeek.styleOverride ?? Defaults[.sneakPeekStyles] + return style == .inline + } + @MainActor private final class ExtensionRoutingDiagnostics { static let shared = ExtensionRoutingDiagnostics() @@ -1771,6 +1832,28 @@ struct ContentView: View { private func hideMusicControlWindow() {} #endif + + private func shouldFixSizeForSneakPeek() -> Bool { + guard coordinator.sneakPeek.show else { return false } + let style = resolvedSneakPeekStyle() + + // Check for extension sneak peek + if case .extensionLiveActivity = coordinator.sneakPeek.type { + return vm.notchState == .closed && style == .standard + } + + // Original logic for other types + let isMusicSneak = coordinator.sneakPeek.type == .music && vm.notchState == .closed && !vm.hideOnClosed && style == .standard + let isTimerSneak = coordinator.sneakPeek.type == .timer && !vm.hideOnClosed && style == .standard + let isReminderSneak = coordinator.sneakPeek.type == .reminder && !vm.hideOnClosed && style == .standard + let isOtherSneak = coordinator.sneakPeek.type != .music && coordinator.sneakPeek.type != .timer && coordinator.sneakPeek.type != .reminder && vm.notchState == .closed + + return isMusicSneak || isTimerSneak || isReminderSneak || isOtherSneak + } + + private func resolvedSneakPeekStyle() -> SneakPeekStyle { + coordinator.sneakPeek.styleOverride ?? Defaults[.sneakPeekStyles] + } } private enum MusicSecondaryLiveActivity: Equatable { diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index 66aec7e..873e996 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -9,7 +9,7 @@ import Combine import Defaults import SwiftUI -enum SneakContentType { +enum SneakContentType: Equatable { case brightness case volume case backlight @@ -25,6 +25,34 @@ enum SneakContentType { case privacy case lockScreen case capsLock + case extensionLiveActivity(bundleID: String, activityID: String) +} + +extension SneakContentType { + static func == (lhs: SneakContentType, rhs: SneakContentType) -> Bool { + switch (lhs, rhs) { + case (.brightness, .brightness), + (.volume, .volume), + (.backlight, .backlight), + (.music, .music), + (.mic, .mic), + (.battery, .battery), + (.download, .download), + (.timer, .timer), + (.reminder, .reminder), + (.recording, .recording), + (.doNotDisturb, .doNotDisturb), + (.bluetoothAudio, .bluetoothAudio), + (.privacy, .privacy), + (.lockScreen, .lockScreen), + (.capsLock, .capsLock): + return true + case let (.extensionLiveActivity(lb, la), .extensionLiveActivity(rb, ra)): + return lb == rb && la == ra + default: + return false + } + } } struct sneakPeek { @@ -32,6 +60,10 @@ struct sneakPeek { var type: SneakContentType = .music var value: CGFloat = 0 var icon: String = "" + var title: String = "" + var subtitle: String = "" + var accentColor: Color? + var styleOverride: SneakPeekStyle? = nil } enum BrowserType { @@ -175,19 +207,40 @@ class DynamicIslandViewCoordinator: ObservableObject { } } - func toggleSneakPeek(status: Bool, type: SneakContentType, duration: TimeInterval = 1.5, value: CGFloat = 0, icon: String = "") { + func toggleSneakPeek( + status: Bool, + type: SneakContentType, + duration: TimeInterval = 1.5, + value: CGFloat = 0, + icon: String = "", + title: String = "", + subtitle: String = "", + accentColor: Color? = nil, + styleOverride: SneakPeekStyle? = nil + ) { let resolvedDuration: TimeInterval switch type { case .timer: resolvedDuration = 10 case .reminder: resolvedDuration = Defaults[.reminderSneakPeekDuration] + case .extensionLiveActivity: + resolvedDuration = duration default: resolvedDuration = duration } sneakPeekDuration = resolvedDuration let bypassedTypes: [SneakContentType] = [.music, .timer, .reminder, .bluetoothAudio] - if !bypassedTypes.contains(type) && !Defaults[.enableSystemHUD] { + + // Check if it's an extension type + let isExtensionType: Bool + if case .extensionLiveActivity = type { + isExtensionType = true + } else { + isExtensionType = false + } + + if !isExtensionType && !bypassedTypes.contains(type) && !Defaults[.enableSystemHUD] { return } DispatchQueue.main.async { @@ -196,6 +249,10 @@ class DynamicIslandViewCoordinator: ObservableObject { self.sneakPeek.type = type self.sneakPeek.value = value self.sneakPeek.icon = icon + self.sneakPeek.title = title + self.sneakPeek.subtitle = subtitle + self.sneakPeek.accentColor = accentColor + self.sneakPeek.styleOverride = styleOverride } } } diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index b1c9c9c..83d66c5 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -16013,6 +16013,14 @@ } } }, + "Reset Live Activities" : { + "comment" : "A menu item that resets live activities for a particular app.", + "isCommentAutoGenerated" : true + }, + "Reset Lock Screen Widgets" : { + "comment" : "A menu item that resets all lock screen widgets for a particular app.", + "isCommentAutoGenerated" : true + }, "Reset Rate Limits" : { "comment" : "A button that resets the rate limits for a particular extension.", "isCommentAutoGenerated" : true diff --git a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift index 641e11f..ffd6a7f 100644 --- a/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift +++ b/DynamicIsland/components/Extensions/ExtensionLiveActivityViews.swift @@ -9,18 +9,20 @@ struct ExtensionStandaloneLayout { let leadingWidth: CGFloat let centerWidth: CGFloat let trailingWidth: CGFloat + let suppressingCenterText: Bool } struct ExtensionLiveActivityStandaloneView: View { let payload: ExtensionLiveActivityPayload let layout: ExtensionStandaloneLayout let isHovering: Bool + let notchState: NotchState private var descriptor: AtollLiveActivityDescriptor { payload.descriptor } private var contentHeight: CGFloat { layout.contentHeight } private var accentColor: Color { descriptor.accentColor.swiftUIColor } private var resolvedLeadingContent: AtollTrailingContent { - descriptor.leadingContent ?? .icon(descriptor.leadingIcon) + resolvedExtensionLeadingContent(for: descriptor) } private var resolvedCenterStyle: ExtensionCenterContentView.Style { switch descriptor.centerTextStyle { @@ -33,6 +35,10 @@ struct ExtensionLiveActivityStandaloneView: View { } } + private var shouldSuppressCenterText: Bool { + layout.suppressingCenterText + } + var body: some View { HStack(spacing: 0) { ExtensionLeadingContentView( @@ -40,7 +46,8 @@ struct ExtensionLiveActivityStandaloneView: View { badge: descriptor.badgeIcon, accent: accentColor, frameWidth: layout.leadingWidth, - frameHeight: contentHeight + frameHeight: contentHeight, + defaultIcon: descriptor.leadingIcon ) .frame(width: layout.leadingWidth, height: contentHeight) @@ -48,12 +55,16 @@ struct ExtensionLiveActivityStandaloneView: View { .fill(Color.black) .frame(width: layout.centerWidth, height: contentHeight) .overlay( - ExtensionCenterContentView( - descriptor: descriptor, - accent: accentColor, - width: layout.centerWidth, - style: resolvedCenterStyle - ) + Group { + if !shouldSuppressCenterText { + ExtensionCenterContentView( + descriptor: descriptor, + accent: accentColor, + width: layout.centerWidth, + style: resolvedCenterStyle + ) + } + } ) ExtensionMusicWingView( @@ -64,6 +75,10 @@ struct ExtensionLiveActivityStandaloneView: View { .frame(width: layout.trailingWidth, height: contentHeight) } .frame(width: layout.totalWidth, height: layout.outerHeight + (isHovering ? 8 : 0)) + .transition(.asymmetric( + insertion: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.4, dampingFraction: 0.8)), + removal: .scale(scale: 0.95).combined(with: .opacity).animation(.spring(response: 0.3, dampingFraction: 0.9)) + )) .animation(.smooth(duration: 0.25), value: payload.id) .onAppear { logExtensionDiagnostics("Displaying extension live activity \(payload.descriptor.id) for \(payload.bundleIdentifier) as standalone view") @@ -82,32 +97,37 @@ struct ExtensionMusicWingView: View { private var descriptor: AtollLiveActivityDescriptor { payload.descriptor } private var accentColor: Color { descriptor.accentColor.swiftUIColor } + private var trailingRenderable: ExtensionTrailingRenderable { + resolvedExtensionTrailingRenderable(for: descriptor) + } var body: some View { VStack(alignment: .trailing, spacing: 6) { - if case .none = descriptor.trailingContent { - EmptyView() - } else { - ExtensionEdgeContentView( - content: descriptor.trailingContent, - accent: accentColor, - availableWidth: trailingWidth, - alignment: .trailing - ) + switch trailingRenderable { + case let .content(content): + if case .none = content { + EmptyView() + } else { + ExtensionEdgeContentView( + content: content, + accent: accentColor, + availableWidth: trailingWidth, + alignment: .trailing + ) .frame(maxWidth: .infinity, alignment: .trailing) - } - - if let indicator = descriptor.progressIndicator, indicator != .none { + } + case let .indicator(indicator): ExtensionProgressIndicatorView( indicator: indicator, progress: descriptor.progress, accent: accentColor, - estimatedDuration: descriptor.estimatedDuration + estimatedDuration: descriptor.estimatedDuration, + maxVisualHeight: notchHeight ) .frame(maxWidth: .infinity, alignment: .trailing) - } else { - Spacer(minLength: 0) } + + Spacer(minLength: 0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .padding(.trailing, 8) @@ -127,23 +147,39 @@ struct ExtensionLeadingContentView: View { let accent: Color let frameWidth: CGFloat let frameHeight: CGFloat + let defaultIcon: AtollIconDescriptor var body: some View { - if case let .icon(iconDescriptor) = content { - ExtensionCompositeIconView( - leading: iconDescriptor, - badge: badge, - accent: accent, - size: frameHeight - ) - } else { - ExtensionEdgeContentView( - content: content, - accent: accent, - availableWidth: frameWidth, - alignment: .leading - ) + Group { + switch content { + case let .icon(iconDescriptor): + ExtensionCompositeIconView( + leading: iconDescriptor, + badge: badge, + accent: accent, + size: frameHeight + ) + case let .animation(data, size): + let resolvedSize = CGSize( + width: min(size.width, frameHeight), + height: min(size.height, frameHeight) + ) + ExtensionLottieView(data: data, size: resolvedSize) + .frame(width: frameHeight, height: frameHeight) + .background( + RoundedRectangle(cornerRadius: frameHeight * 0.18, style: .continuous) + .fill(Color.white.opacity(0.08)) + ) + default: + ExtensionCompositeIconView( + leading: defaultIcon, + badge: badge, + accent: accent, + size: frameHeight + ) + } } + .frame(width: frameWidth, height: frameHeight) } } @@ -202,3 +238,125 @@ private func logExtensionDiagnostics(_ message: String) { guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } Logger.log(message, category: .extensions) } + +struct ExtensionInlineSneakPeekView: View { + let payload: ExtensionLiveActivityPayload? + let title: String + let subtitle: String + let accentColor: Color + let notchHeight: CGFloat + let closedNotchWidth: CGFloat + let isHovering: Bool + let gestureProgress: CGFloat + + private var descriptor: AtollLiveActivityDescriptor? { payload?.descriptor } + private var resolvedAccent: Color { descriptor?.accentColor.swiftUIColor ?? accentColor } + + private var contentHeight: CGFloat { + max(0, notchHeight - (isHovering ? 0 : 12)) + } + + private var leadingWidth: CGFloat { + let base = max(contentHeight, 44) + return max(base * 0.85, base + gestureProgress / 2) + } + + private var centerWidth: CGFloat { + max(closedNotchWidth + (isHovering ? 8 : 0), 120) + } + + private var trailingWidth: CGFloat { + guard let payload else { return max(leadingWidth, 120) } + return ExtensionLayoutMetrics.trailingWidth( + for: payload, + baseWidth: leadingWidth, + maxWidth: leadingWidth + centerWidth * 0.6 + ) + } + + private var marqueeFrameWidth: CGFloat { + max(48, centerWidth - 24) + } + + private var resolvedTitle: String { + title.isEmpty ? "Extension" : title + } + + private var resolvedSubtitle: String { + subtitle.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var body: some View { + HStack(spacing: 0) { + leadingWing + .frame(width: leadingWidth, height: contentHeight) + + Rectangle() + .fill(Color.black) + .frame(width: centerWidth, height: contentHeight) + .overlay(alignment: .bottomLeading) { + VStack(alignment: .leading, spacing: 2) { + if !resolvedTitle.isEmpty { + MarqueeText( + .constant(resolvedTitle), + font: .system(size: 12, weight: .semibold), + nsFont: .subheadline, + textColor: .white, + minDuration: 0.4, + frameWidth: marqueeFrameWidth + ) + } + if !resolvedSubtitle.isEmpty { + Text(resolvedSubtitle) + .font(.system(size: 11, weight: .regular)) + .foregroundStyle(Color.white.opacity(0.75)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(.horizontal, 12) + .padding(.bottom, 6) + } + + trailingWing + .frame(width: trailingWidth, height: contentHeight) + } + .frame(height: notchHeight + (isHovering ? 8 : 0), alignment: .center) + } + + @ViewBuilder + private var leadingWing: some View { + if let descriptor { + ExtensionLeadingContentView( + content: resolvedExtensionLeadingContent(for: descriptor), + badge: descriptor.badgeIcon, + accent: resolvedAccent, + frameWidth: leadingWidth, + frameHeight: contentHeight, + defaultIcon: descriptor.leadingIcon + ) + } else { + RoundedRectangle(cornerRadius: contentHeight * 0.25, style: .continuous) + .fill(resolvedAccent.gradient) + } + } + + @ViewBuilder + private var trailingWing: some View { + if let payload { + ExtensionMusicWingView( + payload: payload, + notchHeight: contentHeight, + trailingWidth: trailingWidth + ) + } else { + Rectangle() + .fill(resolvedAccent.gradient) + .mask { + AudioSpectrumView(isPlaying: .constant(true)) + .frame(width: 18, height: 12) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } +} diff --git a/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift index 9bf6c80..e759128 100644 --- a/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift +++ b/DynamicIsland/components/Extensions/ExtensionRenderingHelpers.swift @@ -1,6 +1,9 @@ import SwiftUI import AppKit import AtollExtensionKit +import Lottie +import LottieUI +import CryptoKit // MARK: - Color Conversion @@ -130,7 +133,15 @@ struct ExtensionIconView: View { } else { fallbackSymbol } - case .lottie, .none: + case let .lottie(animationData, targetSize): + let resolvedSize = CGSize(width: min(targetSize.width, size.width), height: min(targetSize.height, size.height)) + ExtensionLottieView(data: animationData, size: resolvedSize) + .frame(width: size.width, height: size.height) + .background( + RoundedRectangle(cornerRadius: cornerRadius(for: descriptor), style: .continuous) + .fill(Color.white.opacity(0.08)) + ) + case .none: fallbackSymbol } } @@ -187,6 +198,69 @@ struct ExtensionCompositeIconView: View { } } +// MARK: - Lottie Rendering + +struct ExtensionLottieView: View { + let data: Data + let size: CGSize + var loopMode: LottieLoopMode = .loop + @State private var cachedURL: URL? + + var body: some View { + Group { + if let cachedURL { + LottieView( + state: LUStateData( + type: .loadedFrom(cachedURL), + speed: 1.0, + loopMode: loopMode + ) + ) + } else { + ProgressView() + .controlSize(.small) + .tint(.white.opacity(0.7)) + } + } + .frame(width: size.width, height: size.height) + .task { + if cachedURL == nil { + cachedURL = ExtensionLottieCache.shared.url(for: data) + } + } + } +} + +private final class ExtensionLottieCache { + static let shared = ExtensionLottieCache() + private let queue = DispatchQueue(label: "com.atoll.extension-lottie-cache") + private var cache: [String: URL] = [:] + + func url(for data: Data) -> URL? { + queue.sync { + let key = Self.hash(data: data) + if let existing = cache[key], FileManager.default.fileExists(atPath: existing.path) { + return existing + } + + let url = FileManager.default.temporaryDirectory.appendingPathComponent("extension-lottie-\(key).json", conformingTo: .json) + do { + try data.write(to: url, options: .atomic) + cache[key] = url + return url + } catch { + cache.removeValue(forKey: key) + return nil + } + } + } + + private static func hash(data: Data) -> String { + let digest = SHA256.hash(data: data) + return digest.map { String(format: "%02x", $0) }.joined() + } +} + // MARK: - Progress Rendering struct ExtensionProgressIndicatorView: View { @@ -194,39 +268,59 @@ struct ExtensionProgressIndicatorView: View { let progress: Double let accent: Color let estimatedDuration: TimeInterval? + let maxVisualHeight: CGFloat? + + init( + indicator: AtollProgressIndicator, + progress: Double, + accent: Color, + estimatedDuration: TimeInterval?, + maxVisualHeight: CGFloat? = nil + ) { + self.indicator = indicator + self.progress = progress + self.accent = accent + self.estimatedDuration = estimatedDuration + self.maxVisualHeight = maxVisualHeight + } var body: some View { switch indicator { - case let .ring(diameter, strokeWidth): + case let .ring(diameter, strokeWidth, color): + let ringColor = color?.swiftUIColor ?? accent + let resolvedDiameter = resolvedRingDiameter(requested: diameter) ZStack { Circle() .stroke(Color.white.opacity(0.15), lineWidth: strokeWidth) Circle() .trim(from: 0, to: CGFloat(max(0, min(progress, 1)))) - .stroke(accent, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round)) + .stroke(ringColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round)) .rotationEffect(.degrees(-90)) .animation(.smooth(duration: 0.25), value: progress) } - .frame(width: diameter, height: diameter) - case let .bar(width, height, cornerRadius): + .frame(width: resolvedDiameter, height: resolvedDiameter) + case let .bar(width, height, cornerRadius, color): + let barColor = color?.swiftUIColor ?? accent Capsule() .fill(Color.white.opacity(0.18)) .frame(width: width ?? 80, height: height) .overlay(alignment: .leading) { Capsule() - .fill(accent) + .fill(barColor) .frame(width: (width ?? 80) * CGFloat(max(0, min(progress, 1))), height: height) .animation(.smooth(duration: 0.25), value: progress) } - case let .percentage(font): + case let .percentage(font, color): + let textColor = color?.swiftUIColor ?? accent Text("\(Int(progress * 100))%") .font(font.swiftUIFont()) - .foregroundStyle(accent) + .foregroundStyle(textColor) .monospacedDigit() - case let .countdown(font): + case let .countdown(font, color): + let textColor = color?.swiftUIColor ?? accent Text(countdownText) .font(font.swiftUIFont()) - .foregroundStyle(accent) + .foregroundStyle(textColor) .monospacedDigit() case .lottie: Image(systemName: "sparkles") @@ -252,6 +346,12 @@ struct ExtensionProgressIndicatorView: View { private var formatPercent: String { "\(Int(progress * 100))%" } + + private func resolvedRingDiameter(requested: CGFloat) -> CGFloat { + guard let maxVisualHeight else { return requested } + let clamped = min(requested, max(maxVisualHeight - 6, 18)) + return max(18, clamped) + } } // MARK: - Edge Content Rendering @@ -264,23 +364,23 @@ struct ExtensionEdgeContentView: View { var body: some View { switch content { - case let .text(value, font: font): + case let .text(value, font: font, color: color): Text(value) .font(font.swiftUIFont()) - .foregroundStyle(accent) + .foregroundStyle(resolvedColor(color)) .lineLimit(1) .frame(maxWidth: .infinity, alignment: alignment) - case let .marquee(value, font: font, minDuration: duration): + case let .marquee(value, font: font, minDuration: duration, color: color): MarqueeText( .constant(value), font: font.swiftUIFont(), nsFont: .body, - textColor: accent, + textColor: resolvedColor(color), minDuration: duration, frameWidth: max(24, availableWidth - 12) ) - case let .countdownText(targetDate, font: font): - ExtensionCountdownTextView(targetDate: targetDate, font: font, accent: accent) + case let .countdownText(targetDate, font: font, color: color): + ExtensionCountdownTextView(targetDate: targetDate, font: font, accent: accent, customColor: color?.swiftUIColor) .frame(maxWidth: .infinity, alignment: alignment) case let .icon(descriptor): ExtensionIconView( @@ -297,26 +397,30 @@ struct ExtensionEdgeContentView: View { AudioSpectrumView(isPlaying: .constant(true)) .frame(width: 16, height: 12) } - case .animation: - Image(systemName: "sparkles") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(accent) + case let .animation(data, size): + let resolvedWidth = min(size.width, availableWidth) + ExtensionLottieView(data: data, size: CGSize(width: resolvedWidth, height: size.height)) case .none: EmptyView() } } + + private func resolvedColor(_ descriptorColor: AtollColorDescriptor?) -> Color { + descriptorColor?.swiftUIColor ?? accent + } } struct ExtensionCountdownTextView: View { let targetDate: Date let font: AtollFontDescriptor let accent: Color + let customColor: Color? var body: some View { TimelineView(.periodic(from: .now, by: 1)) { context in Text(formattedRemaining(since: context.date)) .font(font.swiftUIFont()) - .foregroundStyle(accent) + .foregroundStyle(customColor ?? accent) .monospacedDigit() } } @@ -337,8 +441,13 @@ struct ExtensionCountdownTextView: View { enum ExtensionLayoutMetrics { static func trailingWidth(for payload: ExtensionLiveActivityPayload, baseWidth: CGFloat, maxWidth: CGFloat? = nil) -> CGFloat { - var width = edgeWidth(for: payload.descriptor.trailingContent, baseWidth: baseWidth, maxWidth: maxWidth) - if let indicator = payload.descriptor.progressIndicator { + let renderable = resolvedExtensionTrailingRenderable(for: payload.descriptor) + var width: CGFloat + switch renderable { + case let .content(content): + width = edgeWidth(for: content, baseWidth: baseWidth, maxWidth: maxWidth) + case let .indicator(indicator): + width = edgeWidth(for: .none, baseWidth: baseWidth, maxWidth: maxWidth) width = max(width, widthForProgress(indicator)) } if let maxWidth { @@ -357,13 +466,13 @@ enum ExtensionLayoutMetrics { private static func widthForContent(_ content: AtollTrailingContent, baseWidth: CGFloat) -> CGFloat { switch content { - case let .text(text, font: font): + case let .text(text, font: font, color: _): let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont()) return max(baseWidth, measured + 32) - case let .marquee(text, font: font, _): + case let .marquee(text, font: font, minDuration: _, color: _): let measured = ExtensionTextMeasurer.width(for: text, font: font.nsFont()) return max(baseWidth, min(measured + 40, baseWidth * 2)) - case let .countdownText(targetDate, font: font): + case let .countdownText(targetDate, font: font, color: _): let sample = targetDate.timeIntervalSinceNow >= 3600 ? "00:00:00" : "00:00" let measured = ExtensionTextMeasurer.width(for: sample, font: font.nsFont()) return max(baseWidth, measured + 24) @@ -380,13 +489,13 @@ enum ExtensionLayoutMetrics { private static func widthForProgress(_ indicator: AtollProgressIndicator) -> CGFloat { switch indicator { - case let .ring(diameter, _): + case let .ring(diameter, _, _): return diameter + 20 - case let .bar(width, _, _): + case let .bar(width, _, _, _): return (width ?? 72) + 12 - case .percentage: + case .percentage(_, _): return 60 - case .countdown: + case .countdown(_, _): return 74 case let .lottie(_, size): return size.width + 16 @@ -402,3 +511,41 @@ enum ExtensionTextMeasurer { return max(1, text.size(withAttributes: attributes).width) } } + +enum ExtensionTrailingRenderable { + case content(AtollTrailingContent) + case indicator(AtollProgressIndicator) +} + +func resolvedExtensionLeadingContent(for descriptor: AtollLiveActivityDescriptor) -> AtollTrailingContent { + guard let override = descriptor.leadingContent else { + return .icon(descriptor.leadingIcon) + } + + switch override { + case .icon, .animation: + return override + default: + return .icon(descriptor.leadingIcon) + } +} + +func resolvedExtensionTrailingRenderable(for descriptor: AtollLiveActivityDescriptor) -> ExtensionTrailingRenderable { + if let indicator = descriptor.progressIndicator, + indicator.isRenderable, + descriptor.trailingContent == .none { + return .indicator(indicator) + } + return .content(descriptor.trailingContent) +} + +private extension AtollProgressIndicator { + var isRenderable: Bool { + switch self { + case .none: + return false + default: + return true + } + } +} diff --git a/DynamicIsland/components/Settings/ExtensionsSettings.swift b/DynamicIsland/components/Settings/ExtensionsSettings.swift index 4a051a1..3a8d2bc 100644 --- a/DynamicIsland/components/Settings/ExtensionsSettings.swift +++ b/DynamicIsland/components/Settings/ExtensionsSettings.swift @@ -143,8 +143,11 @@ struct ExtensionsSettingsView: View { } } +@MainActor private struct ExtensionEntryRow: View { @ObservedObject private var authManager = ExtensionAuthorizationManager.shared + @ObservedObject private var liveActivityManager = ExtensionLiveActivityManager.shared + @ObservedObject private var widgetManager = ExtensionLockScreenWidgetManager.shared let entry: ExtensionAuthorizationEntry let onRemove: () -> Void @@ -395,6 +398,8 @@ private struct ExtensionEntryRow: View { Spacer() + resetMenu + Button("Remove") { onRemove() } @@ -403,6 +408,32 @@ private struct ExtensionEntryRow: View { .tint(.red) } } + + private var resetMenu: some View { + Menu { + Button("Reset Live Activities") { + liveActivityManager.dismissAll(for: entry.bundleIdentifier) + } + .disabled(!hasLiveActivities) + + Button("Reset Lock Screen Widgets") { + widgetManager.dismissAll(for: entry.bundleIdentifier) + } + .disabled(!hasWidgets) + } label: { + Label("Reset", systemImage: "arrow.counterclockwise.circle") + } + .menuStyle(.borderlessButton) + .controlSize(.small) + } + + private var hasLiveActivities: Bool { + liveActivityManager.activeActivities.contains { $0.bundleIdentifier == entry.bundleIdentifier } + } + + private var hasWidgets: Bool { + widgetManager.activeWidgets.contains { $0.bundleIdentifier == entry.bundleIdentifier } + } private func infoRow(label: String, value: String) -> some View { HStack { diff --git a/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift b/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift index a51bcdb..6a71d7e 100644 --- a/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift +++ b/DynamicIsland/managers/Extensions/ExtensionLiveActivityManager.swift @@ -1,6 +1,7 @@ import Foundation import Defaults import AtollExtensionKit +import SwiftUI @MainActor final class ExtensionLiveActivityManager: ObservableObject { @@ -39,6 +40,7 @@ final class ExtensionLiveActivityManager: ObservableObject { throw ExtensionValidationError.invalidDescriptor("Structure validation failed") } + let isUpdate: Bool if let index = activeActivities.firstIndex(where: { $0.descriptor.id == descriptor.id && $0.bundleIdentifier == bundleIdentifier }) { let payload = ExtensionLiveActivityPayload( bundleIdentifier: bundleIdentifier, @@ -49,25 +51,35 @@ final class ExtensionLiveActivityManager: ObservableObject { sortActivities() authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities) Logger.log("Replaced extension live activity \(descriptor.id) for \(bundleIdentifier)", category: .extensions) - broadcastSnapshot() - return - } + isUpdate = true + } else { + guard activeActivities.count < Defaults[maxCapacityKey] else { + logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): capacity limit \(Defaults[maxCapacityKey]) reached") + throw ExtensionValidationError.exceedsCapacity + } - guard activeActivities.count < Defaults[maxCapacityKey] else { - logDiagnostics("Rejected live activity \(descriptor.id) from \(bundleIdentifier): capacity limit \(Defaults[maxCapacityKey]) reached") - throw ExtensionValidationError.exceedsCapacity + let payload = ExtensionLiveActivityPayload( + bundleIdentifier: bundleIdentifier, + descriptor: descriptor, + receivedAt: .now + ) + activeActivities.append(payload) + sortActivities() + authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities) + logDiagnostics("Queued live activity \(descriptor.id) for \(bundleIdentifier); total activities: \(activeActivities.count)") + isUpdate = false } - - let payload = ExtensionLiveActivityPayload( - bundleIdentifier: bundleIdentifier, - descriptor: descriptor, - receivedAt: .now - ) - activeActivities.append(payload) - sortActivities() - authorizationManager.recordActivity(for: bundleIdentifier, scope: .liveActivities) - logDiagnostics("Queued live activity \(descriptor.id) for \(bundleIdentifier); total activities: \(activeActivities.count)") + broadcastSnapshot() + + // Trigger sneak peek (defaulting to enabled for legacy descriptors) + let resolvedConfig = descriptor.sneakPeekConfig ?? .default + if resolvedConfig.enabled { + let shouldShow = !isUpdate || resolvedConfig.showOnUpdate + if shouldShow { + triggerSneakPeek(for: descriptor, bundleIdentifier: bundleIdentifier, config: resolvedConfig) + } + } } func update(descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String) throws { @@ -92,7 +104,9 @@ final class ExtensionLiveActivityManager: ObservableObject { func dismiss(activityID: String, bundleIdentifier: String) { let previousCount = activeActivities.count - activeActivities.removeAll { $0.descriptor.id == activityID && $0.bundleIdentifier == bundleIdentifier } + withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { + activeActivities.removeAll { $0.descriptor.id == activityID && $0.bundleIdentifier == bundleIdentifier } + } if previousCount != activeActivities.count { Logger.log("Dismissed extension live activity \(activityID) from \(bundleIdentifier)", category: .extensions) ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: activityID) @@ -105,7 +119,9 @@ final class ExtensionLiveActivityManager: ObservableObject { let ids = activeActivities .filter { $0.bundleIdentifier == bundleIdentifier } .map { $0.descriptor.id } - activeActivities.removeAll { $0.bundleIdentifier == bundleIdentifier } + withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { + activeActivities.removeAll { $0.bundleIdentifier == bundleIdentifier } + } ids.forEach { ExtensionXPCServiceHost.shared.notifyActivityDismiss(bundleIdentifier: bundleIdentifier, activityID: $0) } if !ids.isEmpty { logDiagnostics("Removed all live activities for \(bundleIdentifier); ids: \(ids.joined(separator: ", "))") @@ -119,6 +135,10 @@ final class ExtensionLiveActivityManager: ObservableObject { .sorted(by: descriptorComparator) } + func payload(bundleIdentifier: String, activityID: String) -> ExtensionLiveActivityPayload? { + activeActivities.first { $0.bundleIdentifier == bundleIdentifier && $0.descriptor.id == activityID } + } + private func descriptorComparator(lhs: ExtensionLiveActivityPayload, rhs: ExtensionLiveActivityPayload) -> Bool { if lhs.descriptor.priority == rhs.descriptor.priority { return lhs.receivedAt < rhs.receivedAt @@ -144,6 +164,39 @@ final class ExtensionLiveActivityManager: ObservableObject { logDiagnostics("Applied external live activity snapshot from PID \(sourcePID) (count: \(payloads.count))") } + private func triggerSneakPeek(for descriptor: AtollLiveActivityDescriptor, bundleIdentifier: String, config: AtollSneakPeekConfig) { + let coordinator = DynamicIslandViewCoordinator.shared + let duration = config.duration ?? 2.5 + let accentColor = descriptor.accentColor.swiftUIColor + let styleOverride: SneakPeekStyle? = { + guard let requestedStyle = config.style else { return nil } + switch requestedStyle { + case .inline: + return .inline + case .standard: + return .standard + } + }() + + let resolvedTitle = descriptor.sneakPeekTitle?.isEmpty == false ? descriptor.sneakPeekTitle! : descriptor.title + let resolvedSubtitle = descriptor.sneakPeekSubtitle ?? descriptor.subtitle ?? "" + + // Pass the activity's id and bundleID to the sneak peek so the specialized view can look up details if needed + coordinator.toggleSneakPeek( + status: true, + type: .extensionLiveActivity(bundleID: bundleIdentifier, activityID: descriptor.id), + duration: duration, + value: 0, + icon: "", + title: resolvedTitle, + subtitle: resolvedSubtitle, + accentColor: accentColor, + styleOverride: styleOverride + ) + + logDiagnostics("Triggered sneak peek for \(descriptor.id) from \(bundleIdentifier) with duration \(duration)s") + } + private func logDiagnostics(_ message: String) { guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return } Logger.log(message, category: .extensions)