mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Update Third Party stuff
This commit is contained in:
parent
94a612f327
commit
27b16a3d15
8 changed files with 636 additions and 99 deletions
|
|
@ -7,7 +7,7 @@
|
|||
"location" : "https://github.com/Ebullioscopic/AtollExtensionKit",
|
||||
"state" : {
|
||||
"branch" : "main",
|
||||
"revision" : "61d9a7be60416f107eb45b470eb3c8a5296b180f"
|
||||
"revision" : "464423a5ffd29c1836f93662a5cb0529e7c1c355"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -404,6 +409,32 @@ private struct ExtensionEntryRow: View {
|
|||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
Text("\(label):")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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)")
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue