mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Add extension Live Activities support to music pairing system
- Integrate ExtensionLiveActivityManager for third-party activity support - Add extension payloads as secondary activity option in music Live Activity - Implement extension standalone view when music is not playing - Add extension tab experience support with dynamic height calculation - Include extension sneak peek rendering with standard style - Add routing diagnostics for debugging extension display logic
This commit is contained in:
parent
e62ad3533f
commit
5a94300037
1 changed files with 458 additions and 17 deletions
|
|
@ -3,15 +3,18 @@
|
||||||
// DynamicIslandApp
|
// DynamicIslandApp
|
||||||
//
|
//
|
||||||
// Created by Harsh Vardhan Goswami on 02/08/24
|
// Created by Harsh Vardhan Goswami on 02/08/24
|
||||||
|
// Modified by Richard Kunkli on 24/08/2024.
|
||||||
// Modified by A Akhil on 13/01/2026.
|
// Modified by A Akhil on 13/01/2026.
|
||||||
//
|
//
|
||||||
|
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import Combine
|
import Combine
|
||||||
import Defaults
|
import Defaults
|
||||||
|
import Foundation
|
||||||
import KeyboardShortcuts
|
import KeyboardShortcuts
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftUIIntrospect
|
import SwiftUIIntrospect
|
||||||
|
import AtollExtensionKit
|
||||||
#if canImport(AppKit)
|
#if canImport(AppKit)
|
||||||
import AppKit
|
import AppKit
|
||||||
#elseif canImport(UIKit)
|
#elseif canImport(UIKit)
|
||||||
|
|
@ -34,6 +37,8 @@ struct ContentView: View {
|
||||||
@ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared
|
@ObservedObject var doNotDisturbManager = DoNotDisturbManager.shared
|
||||||
@ObservedObject var lockScreenManager = LockScreenManager.shared
|
@ObservedObject var lockScreenManager = LockScreenManager.shared
|
||||||
@ObservedObject var capsLockManager = CapsLockManager.shared
|
@ObservedObject var capsLockManager = CapsLockManager.shared
|
||||||
|
@ObservedObject var extensionLiveActivityManager = ExtensionLiveActivityManager.shared
|
||||||
|
@ObservedObject var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
|
||||||
@State private var downloadManager = DownloadManager.shared
|
@State private var downloadManager = DownloadManager.shared
|
||||||
|
|
||||||
@Default(.enableStatsFeature) var enableStatsFeature
|
@Default(.enableStatsFeature) var enableStatsFeature
|
||||||
|
|
@ -60,6 +65,7 @@ struct ContentView: View {
|
||||||
@Default(.focusIndicatorNonPersistent) var focusIndicatorNonPersistent
|
@Default(.focusIndicatorNonPersistent) var focusIndicatorNonPersistent
|
||||||
@Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection
|
@Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection
|
||||||
@Default(.enableCapsLockIndicator) var enableCapsLockIndicator
|
@Default(.enableCapsLockIndicator) var enableCapsLockIndicator
|
||||||
|
@Default(.enableExtensionLiveActivities) var enableExtensionLiveActivities
|
||||||
|
|
||||||
// Dynamic sizing based on view type and graph count with smooth transitions
|
// Dynamic sizing based on view type and graph count with smooth transitions
|
||||||
var dynamicNotchSize: CGSize {
|
var dynamicNotchSize: CGSize {
|
||||||
|
|
@ -75,6 +81,19 @@ struct ContentView: View {
|
||||||
return CGSize(width: baseSize.width, height: resolvedHeight)
|
return CGSize(width: baseSize.width, height: resolvedHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if coordinator.currentView == .extensionExperience {
|
||||||
|
if let preferredHeight = extensionTabPreferredHeight(baseSize: baseSize) {
|
||||||
|
return CGSize(width: baseSize.width, height: preferredHeight)
|
||||||
|
}
|
||||||
|
return baseSize
|
||||||
|
}
|
||||||
|
|
||||||
|
if enableMinimalisticUI,
|
||||||
|
coordinator.currentView == .home,
|
||||||
|
let preferredHeight = extensionMinimalisticPreferredHeight(baseSize: baseSize) {
|
||||||
|
return CGSize(width: baseSize.width, height: preferredHeight)
|
||||||
|
}
|
||||||
|
|
||||||
guard coordinator.currentView == .stats else {
|
guard coordinator.currentView == .stats else {
|
||||||
return baseSize
|
return baseSize
|
||||||
}
|
}
|
||||||
|
|
@ -478,12 +497,21 @@ struct ContentView: View {
|
||||||
.padding(.top, 40)
|
.padding(.top, 40)
|
||||||
Spacer()
|
Spacer()
|
||||||
} else {
|
} else {
|
||||||
|
let hasMusicMetadata = !musicManager.songTitle.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty
|
||||||
|
|| !musicManager.artistName.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines).isEmpty
|
||||||
|
let hasActiveMusicSnapshot: Bool = {
|
||||||
|
if musicManager.isPlaying { return true }
|
||||||
|
return !musicManager.isPlayerIdle && hasMusicMetadata
|
||||||
|
}()
|
||||||
let musicPairingEligible = vm.notchState == .closed
|
let musicPairingEligible = vm.notchState == .closed
|
||||||
&& (musicManager.isPlaying || !musicManager.isPlayerIdle)
|
&& hasActiveMusicSnapshot
|
||||||
&& coordinator.musicLiveActivityEnabled
|
&& coordinator.musicLiveActivityEnabled
|
||||||
&& !vm.hideOnClosed
|
&& !vm.hideOnClosed
|
||||||
&& !lockScreenManager.isLocked
|
&& !lockScreenManager.isLocked
|
||||||
let musicSecondary = resolveMusicSecondaryLiveActivity()
|
let musicSecondary = resolveMusicSecondaryLiveActivity(isMusicPairingEligible: musicPairingEligible)
|
||||||
|
let extensionSecondaryPayloadID = extensionSecondaryPayloadID(for: musicSecondary)
|
||||||
|
let extensionStandalonePayload = resolvedExtensionStandalonePayload(excluding: extensionSecondaryPayloadID)
|
||||||
|
let activeSneakPeekStyle = resolvedSneakPeekStyle()
|
||||||
let expansionMatchesSecondary: Bool = {
|
let expansionMatchesSecondary: Bool = {
|
||||||
guard let musicSecondary else { return false }
|
guard let musicSecondary else { return false }
|
||||||
switch musicSecondary {
|
switch musicSecondary {
|
||||||
|
|
@ -497,6 +525,8 @@ struct ContentView: View {
|
||||||
return coordinator.expandingView.type == .doNotDisturb
|
return coordinator.expandingView.type == .doNotDisturb
|
||||||
case .capsLock:
|
case .capsLock:
|
||||||
return false
|
return false
|
||||||
|
case .extensionPayload:
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
let canShowMusicDuringExpansion = !coordinator.expandingView.show
|
let canShowMusicDuringExpansion = !coordinator.expandingView.show
|
||||||
|
|
@ -527,16 +557,16 @@ struct ContentView: View {
|
||||||
.frame(width: 76, alignment: .trailing)
|
.frame(width: 76, alignment: .trailing)
|
||||||
}
|
}
|
||||||
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
|
||||||
} 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) {
|
} 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.isExtensionPayload && (coordinator.sneakPeek.type != .volume || vm.notchState == .closed) {
|
||||||
InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress)
|
InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress)
|
||||||
.transition(
|
.transition(
|
||||||
coordinator.sneakPeek.type == .capsLock
|
coordinator.sneakPeek.type == .capsLock
|
||||||
? .move(edge: .trailing).combined(with: .opacity)
|
? AnyTransition.move(edge: .trailing).combined(with: .opacity)
|
||||||
: .opacity
|
: AnyTransition.opacity
|
||||||
)
|
)
|
||||||
} else if vm.notchState == .closed && capsLockManager.isCapsLockActive && Defaults[.enableCapsLockIndicator] && !vm.hideOnClosed && !lockScreenManager.isLocked {
|
} else if vm.notchState == .closed && capsLockManager.isCapsLockActive && Defaults[.enableCapsLockIndicator] && !vm.hideOnClosed && !lockScreenManager.isLocked {
|
||||||
InlineHUD(type: .constant(.capsLock), value: .constant(1.0), icon: .constant(""), hoverAnimation: $isHovering, gestureProgress: $gestureProgress)
|
InlineHUD(type: .constant(.capsLock), value: .constant(1.0), icon: .constant(""), hoverAnimation: $isHovering, gestureProgress: $gestureProgress)
|
||||||
.transition(.move(edge: .trailing).combined(with: .opacity))
|
.transition(AnyTransition.move(edge: .trailing).combined(with: .opacity))
|
||||||
} else if canShowMusicDuringExpansion && musicPairingEligible {
|
} else if canShowMusicDuringExpansion && musicPairingEligible {
|
||||||
MusicLiveActivity(secondary: musicSecondary)
|
MusicLiveActivity(secondary: musicSecondary)
|
||||||
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed {
|
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed {
|
||||||
|
|
@ -554,6 +584,17 @@ struct ContentView: View {
|
||||||
LockScreenLiveActivity()
|
LockScreenLiveActivity()
|
||||||
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed {
|
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed {
|
||||||
PrivacyLiveActivity()
|
PrivacyLiveActivity()
|
||||||
|
} else if let extensionPayload = extensionStandalonePayload {
|
||||||
|
let layout = extensionStandaloneLayout(
|
||||||
|
for: extensionPayload,
|
||||||
|
notchHeight: vm.effectiveClosedNotchHeight,
|
||||||
|
isHovering: isHovering
|
||||||
|
)
|
||||||
|
ExtensionLiveActivityStandaloneView(
|
||||||
|
payload: extensionPayload,
|
||||||
|
layout: layout,
|
||||||
|
isHovering: isHovering
|
||||||
|
)
|
||||||
} else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed {
|
} else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed {
|
||||||
DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle)
|
DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle)
|
||||||
} else if vm.notchState == .open {
|
} else if vm.notchState == .open {
|
||||||
|
|
@ -574,7 +615,7 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
// Old sneak peek music
|
// Old sneak peek music
|
||||||
else if coordinator.sneakPeek.type == .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) {
|
HStack(alignment: .center) {
|
||||||
Image(systemName: "music.note")
|
Image(systemName: "music.note")
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
|
|
@ -587,7 +628,7 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
// Timer sneak peek
|
// Timer sneak peek
|
||||||
else if coordinator.sneakPeek.type == .timer {
|
else if coordinator.sneakPeek.type == .timer {
|
||||||
if !vm.hideOnClosed && Defaults[.sneakPeekStyles] == .standard {
|
if !vm.hideOnClosed && activeSneakPeekStyle == .standard {
|
||||||
HStack(alignment: .center) {
|
HStack(alignment: .center) {
|
||||||
Image(systemName: "timer")
|
Image(systemName: "timer")
|
||||||
GeometryReader { geo in
|
GeometryReader { geo in
|
||||||
|
|
@ -599,7 +640,7 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if coordinator.sneakPeek.type == .reminder {
|
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
|
GeometryReader { geo in
|
||||||
let chipColor = Color(nsColor: reminder.event.calendar.color).ensureMinimumBrightness(factor: 0.7)
|
let chipColor = Color(nsColor: reminder.event.calendar.color).ensureMinimumBrightness(factor: 0.7)
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
|
|
@ -617,10 +658,39 @@ struct ContentView: View {
|
||||||
.padding(.bottom, 10)
|
.padding(.bottom, 10)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Extension live activity sneak peek
|
||||||
|
else if case let .extensionLiveActivity(bundleID, activityID) = coordinator.sneakPeek.type {
|
||||||
|
if !vm.hideOnClosed && activeSneakPeekStyle == .standard {
|
||||||
|
let payload = extensionLiveActivityManager.payload(bundleIdentifier: bundleID, activityID: activityID)
|
||||||
|
let descriptor = payload?.descriptor
|
||||||
|
let accent = (descriptor?.accentColor.swiftUIColor ?? coordinator.sneakPeek.accentColor ?? .gray)
|
||||||
|
.ensureMinimumBrightness(factor: 0.7)
|
||||||
|
GeometryReader { geo in
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
RoundedRectangle(cornerRadius: 2)
|
||||||
|
.fill(accent)
|
||||||
|
.frame(width: 8, height: 12)
|
||||||
|
MarqueeText(
|
||||||
|
.constant(
|
||||||
|
extensionSneakPeekText(
|
||||||
|
preferredTitle: coordinator.sneakPeek.title,
|
||||||
|
preferredSubtitle: coordinator.sneakPeek.subtitle,
|
||||||
|
descriptor: descriptor
|
||||||
|
)
|
||||||
|
),
|
||||||
|
textColor: accent,
|
||||||
|
minDuration: 1,
|
||||||
|
frameWidth: max(0, geo.size.width - 14)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.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
|
view
|
||||||
.fixedSize()
|
.fixedSize()
|
||||||
}
|
}
|
||||||
|
|
@ -644,6 +714,12 @@ struct ContentView: View {
|
||||||
NotchNotesView()
|
NotchNotesView()
|
||||||
case .clipboard:
|
case .clipboard:
|
||||||
NotchNotesView()
|
NotchNotesView()
|
||||||
|
case .extensionExperience:
|
||||||
|
if let payload = currentExtensionTabPayload() {
|
||||||
|
ExtensionNotchExperienceTabView(payload: payload)
|
||||||
|
} else {
|
||||||
|
NotchHomeView(albumArtNamespace: albumArtNamespace)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.transition(.asymmetric(
|
.transition(.asymmetric(
|
||||||
|
|
@ -689,6 +765,19 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func extensionSneakPeekText(preferredTitle: String, preferredSubtitle: String?, descriptor: AtollLiveActivityDescriptor?) -> String {
|
||||||
|
let trimmedPreferredTitle = preferredTitle.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let descriptorTitle = descriptor?.title.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Extension"
|
||||||
|
let title = trimmedPreferredTitle.isEmpty ? descriptorTitle : trimmedPreferredTitle
|
||||||
|
|
||||||
|
let trimmedPreferredSubtitle = preferredSubtitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let descriptorSubtitle = descriptor?.subtitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
|
let subtitle = !trimmedPreferredSubtitle.isEmpty ? trimmedPreferredSubtitle : descriptorSubtitle
|
||||||
|
|
||||||
|
guard !subtitle.isEmpty else { return title }
|
||||||
|
return "\(title) • \(subtitle)"
|
||||||
|
}
|
||||||
|
|
||||||
private let reminderTimeFormatter: DateFormatter = {
|
private let reminderTimeFormatter: DateFormatter = {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateStyle = .none
|
formatter.dateStyle = .none
|
||||||
|
|
@ -732,7 +821,9 @@ struct ContentView: View {
|
||||||
)
|
)
|
||||||
let effectiveCenterWidth = inlineSneakPeekActive ? 380 : centerBaseWidth
|
let effectiveCenterWidth = inlineSneakPeekActive ? 380 : centerBaseWidth
|
||||||
let notchWidth = wingBaseWidth + effectiveCenterWidth + rightWingWidth
|
let notchWidth = wingBaseWidth + effectiveCenterWidth + rightWingWidth
|
||||||
let badgeSize = max(16, notchContentHeight * 0.45)
|
let badgeBaseSize = max(13, notchContentHeight * 0.36)
|
||||||
|
let badgeDisplaySize = badgeDisplaySize(for: secondary, baseSize: badgeBaseSize)
|
||||||
|
let badgeOffset = badgeOverlayOffset(for: secondary, badgeSize: badgeDisplaySize)
|
||||||
|
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
ZStack(alignment: .bottomTrailing) {
|
||||||
|
|
@ -747,8 +838,8 @@ struct ContentView: View {
|
||||||
.clipShape(RoundedRectangle(cornerRadius: MusicPlayerImageSizes.cornerRadiusInset.closed))
|
.clipShape(RoundedRectangle(cornerRadius: MusicPlayerImageSizes.cornerRadiusInset.closed))
|
||||||
.matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)
|
.matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)
|
||||||
.albumArtFlip(angle: musicManager.flipAngle)
|
.albumArtFlip(angle: musicManager.flipAngle)
|
||||||
albumArtBadge(for: secondary, badgeSize: badgeSize)
|
albumArtBadge(for: secondary, badgeSize: badgeDisplaySize)
|
||||||
.offset(x: badgeSize * 0.2, y: badgeSize * 0.25)
|
.offset(x: badgeOffset.width, y: badgeOffset.height)
|
||||||
.id(secondary?.id ?? "music-badge")
|
.id(secondary?.id ?? "music-badge")
|
||||||
.contentTransition(.symbolEffect(.replace))
|
.contentTransition(.symbolEffect(.replace))
|
||||||
}
|
}
|
||||||
|
|
@ -793,7 +884,7 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
musicRightWing(for: secondary, notchHeight: notchContentHeight)
|
musicRightWing(for: secondary, notchHeight: notchContentHeight, trailingWidth: rightWingWidth)
|
||||||
.frame(width: rightWingWidth, height: notchContentHeight, alignment: .center)
|
.frame(width: rightWingWidth, height: notchContentHeight, alignment: .center)
|
||||||
.id(secondary?.id ?? "music-spectrum")
|
.id(secondary?.id ?? "music-spectrum")
|
||||||
.contentTransition(.symbolEffect(.replace))
|
.contentTransition(.symbolEffect(.replace))
|
||||||
|
|
@ -803,7 +894,7 @@ struct ContentView: View {
|
||||||
.animation(.smooth(duration: 0.25), value: secondary?.id)
|
.animation(.smooth(duration: 0.25), value: secondary?.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func resolveMusicSecondaryLiveActivity() -> MusicSecondaryLiveActivity? {
|
private func resolveMusicSecondaryLiveActivity(isMusicPairingEligible: Bool = true) -> MusicSecondaryLiveActivity? {
|
||||||
if coordinator.timerLiveActivityEnabled && timerManager.isTimerActive {
|
if coordinator.timerLiveActivityEnabled && timerManager.isTimerActive {
|
||||||
return .timer
|
return .timer
|
||||||
}
|
}
|
||||||
|
|
@ -825,6 +916,10 @@ struct ContentView: View {
|
||||||
return .capsLock(showLabel: showCapsLockLabel)
|
return .capsLock(showLabel: showCapsLockLabel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if isMusicPairingEligible, let extensionPayload = resolvedExtensionMusicPayload() {
|
||||||
|
return .extensionPayload(extensionPayload)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -842,6 +937,9 @@ struct ContentView: View {
|
||||||
return focusRightWingWidth(baseWidth: baseWidth)
|
return focusRightWingWidth(baseWidth: baseWidth)
|
||||||
case .recording:
|
case .recording:
|
||||||
return recordingRightWingWidth(baseWidth: baseWidth)
|
return recordingRightWingWidth(baseWidth: baseWidth)
|
||||||
|
case .extensionPayload(let payload):
|
||||||
|
let maxWidth = baseWidth + centerBaseWidth * 0.6
|
||||||
|
return ExtensionLayoutMetrics.trailingWidth(for: payload, baseWidth: baseWidth, maxWidth: maxWidth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -938,6 +1036,17 @@ struct ContentView: View {
|
||||||
Image(systemName: "capslock.fill")
|
Image(systemName: "capslock.fill")
|
||||||
.font(.system(size: badgeSize * 0.5, weight: .semibold))
|
.font(.system(size: badgeSize * 0.5, weight: .semibold))
|
||||||
.foregroundStyle(capsLockTintMode.color)
|
.foregroundStyle(capsLockTintMode.color)
|
||||||
|
case .extensionPayload(let payload):
|
||||||
|
ExtensionCompositeIconView(
|
||||||
|
leading: payload.descriptor.leadingIcon,
|
||||||
|
badge: payload.descriptor.badgeIcon,
|
||||||
|
accent: payload.descriptor.accentColor.swiftUIColor,
|
||||||
|
size: badgeSize
|
||||||
|
)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(Color.black.opacity(0.85))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(width: badgeSize, height: badgeSize)
|
.frame(width: badgeSize, height: badgeSize)
|
||||||
|
|
@ -948,8 +1057,28 @@ struct ContentView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func badgeDisplaySize(for secondary: MusicSecondaryLiveActivity?, baseSize: CGFloat) -> CGFloat {
|
||||||
|
guard let secondary else { return baseSize }
|
||||||
|
switch secondary {
|
||||||
|
case .extensionPayload:
|
||||||
|
return max(11, baseSize * 0.86)
|
||||||
|
default:
|
||||||
|
return baseSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func badgeOverlayOffset(for secondary: MusicSecondaryLiveActivity?, badgeSize: CGFloat) -> CGSize {
|
||||||
|
guard let secondary else { return CGSize(width: badgeSize * 0.2, height: badgeSize * 0.25) }
|
||||||
|
switch secondary {
|
||||||
|
case .extensionPayload:
|
||||||
|
return CGSize(width: badgeSize * 0.14, height: badgeSize * 0.12)
|
||||||
|
default:
|
||||||
|
return CGSize(width: badgeSize * 0.2, height: badgeSize * 0.25)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func musicRightWing(for secondary: MusicSecondaryLiveActivity?, notchHeight: CGFloat) -> some View {
|
private func musicRightWing(for secondary: MusicSecondaryLiveActivity?, notchHeight: CGFloat, trailingWidth: CGFloat) -> some View {
|
||||||
switch secondary {
|
switch secondary {
|
||||||
case .timer:
|
case .timer:
|
||||||
MusicTimerSupplementView(
|
MusicTimerSupplementView(
|
||||||
|
|
@ -974,10 +1103,12 @@ struct ContentView: View {
|
||||||
} else {
|
} else {
|
||||||
spectrumView(forceSpectrum: true)
|
spectrumView(forceSpectrum: true)
|
||||||
}
|
}
|
||||||
case .focus(let mode):
|
case .focus:
|
||||||
spectrumView(forceSpectrum: true)
|
spectrumView(forceSpectrum: true)
|
||||||
case .recording:
|
case .recording:
|
||||||
spectrumView(forceSpectrum: true, trailingInset: 6)
|
spectrumView(forceSpectrum: true, trailingInset: 6)
|
||||||
|
case .extensionPayload(let payload):
|
||||||
|
ExtensionMusicWingView(payload: payload, notchHeight: notchHeight, trailingWidth: trailingWidth)
|
||||||
case .none:
|
case .none:
|
||||||
spectrumView(forceSpectrum: false)
|
spectrumView(forceSpectrum: false)
|
||||||
}
|
}
|
||||||
|
|
@ -1026,6 +1157,222 @@ struct ContentView: View {
|
||||||
return remaining > 0 && remaining <= window
|
return remaining > 0 && remaining <= window
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func extensionSecondaryPayloadID(for secondary: MusicSecondaryLiveActivity?) -> String? {
|
||||||
|
guard case let .extensionPayload(payload) = secondary else { return nil }
|
||||||
|
return payload.id
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedExtensionMusicPayload() -> ExtensionLiveActivityPayload? {
|
||||||
|
let candidates = extensionLiveActivityManager.sortedActivities(for: true)
|
||||||
|
guard let payload = candidates.first else {
|
||||||
|
ExtensionRoutingDiagnostics.shared.logSuppression(
|
||||||
|
.music,
|
||||||
|
reason: "no eligible coexistence payloads",
|
||||||
|
pendingCount: candidates.count
|
||||||
|
)
|
||||||
|
ExtensionRoutingDiagnostics.shared.reset(.music)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard enableExtensionLiveActivities else {
|
||||||
|
ExtensionRoutingDiagnostics.shared.logSuppression(
|
||||||
|
.music,
|
||||||
|
reason: "feature toggle disabled",
|
||||||
|
pendingCount: candidates.count
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard vm.notchState == .closed else {
|
||||||
|
ExtensionRoutingDiagnostics.shared.logSuppression(
|
||||||
|
.music,
|
||||||
|
reason: "notch is \(vm.notchState)",
|
||||||
|
pendingCount: candidates.count
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !vm.hideOnClosed else {
|
||||||
|
ExtensionRoutingDiagnostics.shared.logSuppression(
|
||||||
|
.music,
|
||||||
|
reason: "hideOnClosed engaged (fullscreen)",
|
||||||
|
pendingCount: candidates.count
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !lockScreenManager.isLocked else {
|
||||||
|
ExtensionRoutingDiagnostics.shared.logSuppression(
|
||||||
|
.music,
|
||||||
|
reason: "lock screen currently active",
|
||||||
|
pendingCount: candidates.count
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard coordinator.musicLiveActivityEnabled else {
|
||||||
|
ExtensionRoutingDiagnostics.shared.logSuppression(
|
||||||
|
.music,
|
||||||
|
reason: "music live activity disabled in settings",
|
||||||
|
pendingCount: candidates.count
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ExtensionRoutingDiagnostics.shared.logDisplay(.music, payload: payload)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedExtensionStandalonePayload(excluding musicPayloadID: String?) -> ExtensionLiveActivityPayload? {
|
||||||
|
let baseCandidates = extensionLiveActivityManager.sortedActivities()
|
||||||
|
guard !baseCandidates.isEmpty else {
|
||||||
|
ExtensionRoutingDiagnostics.shared.logSuppression(
|
||||||
|
.standalone,
|
||||||
|
reason: "no active extension payloads",
|
||||||
|
pendingCount: 0
|
||||||
|
)
|
||||||
|
ExtensionRoutingDiagnostics.shared.reset(.standalone)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidates = baseCandidates.filter { $0.id != musicPayloadID }
|
||||||
|
guard let payload = candidates.first else {
|
||||||
|
if let musicPayloadID {
|
||||||
|
ExtensionRoutingDiagnostics.shared.logSuppression(
|
||||||
|
.standalone,
|
||||||
|
reason: "all pending payloads are paired with music (\(musicPayloadID))",
|
||||||
|
pendingCount: baseCandidates.count
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ExtensionRoutingDiagnostics.shared.logSuppression(
|
||||||
|
.standalone,
|
||||||
|
reason: "no standalone payloads after filtering",
|
||||||
|
pendingCount: baseCandidates.count
|
||||||
|
)
|
||||||
|
ExtensionRoutingDiagnostics.shared.reset(.standalone)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard enableExtensionLiveActivities else {
|
||||||
|
ExtensionRoutingDiagnostics.shared.logSuppression(
|
||||||
|
.standalone,
|
||||||
|
reason: "feature toggle disabled",
|
||||||
|
pendingCount: candidates.count
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard vm.notchState == .closed else {
|
||||||
|
ExtensionRoutingDiagnostics.shared.logSuppression(
|
||||||
|
.standalone,
|
||||||
|
reason: "notch is \(vm.notchState)",
|
||||||
|
pendingCount: candidates.count
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !vm.hideOnClosed else {
|
||||||
|
ExtensionRoutingDiagnostics.shared.logSuppression(
|
||||||
|
.standalone,
|
||||||
|
reason: "hideOnClosed engaged (fullscreen)",
|
||||||
|
pendingCount: candidates.count
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !lockScreenManager.isLocked else {
|
||||||
|
ExtensionRoutingDiagnostics.shared.logSuppression(
|
||||||
|
.standalone,
|
||||||
|
reason: "lock screen currently active",
|
||||||
|
pendingCount: candidates.count
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard vm.effectiveClosedNotchHeight > 0 else {
|
||||||
|
ExtensionRoutingDiagnostics.shared.logSuppression(
|
||||||
|
.standalone,
|
||||||
|
reason: "effective notch height is \(vm.effectiveClosedNotchHeight)",
|
||||||
|
pendingCount: candidates.count
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !coordinator.expandingView.show else {
|
||||||
|
ExtensionRoutingDiagnostics.shared.logSuppression(
|
||||||
|
.standalone,
|
||||||
|
reason: "expanding view \(coordinator.expandingView.type) visible",
|
||||||
|
pendingCount: candidates.count
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ExtensionRoutingDiagnostics.shared.logDisplay(.standalone, payload: payload)
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extensionStandaloneLayout(for payload: ExtensionLiveActivityPayload, notchHeight: CGFloat, isHovering: Bool) -> ExtensionStandaloneLayout {
|
||||||
|
let outerHeight = notchHeight
|
||||||
|
let contentHeight = max(0, notchHeight - (isHovering ? 0 : 12))
|
||||||
|
let leadingWidth = max(contentHeight, 44)
|
||||||
|
let centerWidth: CGFloat = max(vm.closedNotchSize.width + (isHovering ? 8 : 0), 96)
|
||||||
|
let trailingWidth = ExtensionLayoutMetrics.trailingWidth(
|
||||||
|
for: payload,
|
||||||
|
baseWidth: leadingWidth,
|
||||||
|
maxWidth: leadingWidth + centerWidth * 0.6
|
||||||
|
)
|
||||||
|
let totalWidth = leadingWidth + centerWidth + trailingWidth
|
||||||
|
return ExtensionStandaloneLayout(
|
||||||
|
totalWidth: totalWidth,
|
||||||
|
outerHeight: outerHeight,
|
||||||
|
contentHeight: contentHeight,
|
||||||
|
leadingWidth: leadingWidth,
|
||||||
|
centerWidth: centerWidth,
|
||||||
|
trailingWidth: trailingWidth
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class ExtensionRoutingDiagnostics {
|
||||||
|
static let shared = ExtensionRoutingDiagnostics()
|
||||||
|
|
||||||
|
enum Channel: Hashable {
|
||||||
|
case music
|
||||||
|
case standalone
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .music:
|
||||||
|
return "music pairing"
|
||||||
|
case .standalone:
|
||||||
|
return "standalone notch"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lastMessages: [Channel: String] = [:]
|
||||||
|
|
||||||
|
func logSuppression(_ channel: Channel, reason: String, pendingCount: Int) {
|
||||||
|
log("Extension \(channel.label) suppressed: \(reason) (pending: \(pendingCount))", channel: channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func logDisplay(_ channel: Channel, payload: ExtensionLiveActivityPayload) {
|
||||||
|
log("Extension \(channel.label) showing \(payload.descriptor.id) from \(payload.bundleIdentifier)", channel: channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset(_ channel: Channel) {
|
||||||
|
lastMessages.removeValue(forKey: channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func log(_ message: String, channel: Channel) {
|
||||||
|
guard Defaults[.extensionDiagnosticsLoggingEnabled] else { return }
|
||||||
|
guard lastMessages[channel] != message else { return }
|
||||||
|
lastMessages[channel] = message
|
||||||
|
Logger.log(message, category: .extensions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
var dragDetector: some View {
|
var dragDetector: some View {
|
||||||
if lockScreenManager.isLocked {
|
if lockScreenManager.isLocked {
|
||||||
|
|
@ -1161,6 +1508,72 @@ struct ContentView: View {
|
||||||
return count <= 3 ? 1 : 2
|
return count <= 3 ? 1 : 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func currentExtensionTabPayload() -> ExtensionNotchExperiencePayload? {
|
||||||
|
guard Defaults[.enableThirdPartyExtensions],
|
||||||
|
Defaults[.enableExtensionNotchExperiences],
|
||||||
|
Defaults[.enableExtensionNotchTabs] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if let selectedID = coordinator.selectedExtensionExperienceID,
|
||||||
|
let payload = extensionNotchExperienceManager.payload(experienceID: selectedID) {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
return extensionNotchExperienceManager.highestPriorityTabPayload()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extensionTabPreferredHeight(baseSize: CGSize) -> CGFloat? {
|
||||||
|
guard let preferred = currentExtensionTabPayload()?.descriptor.tab?.preferredHeight else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let minHeight = baseSize.height
|
||||||
|
let maxHeight = baseSize.height + statsAdditionalRowHeight
|
||||||
|
return min(max(preferred, minHeight), maxHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimate the height required for minimalistic overrides (notably web content) and clamp it to the notch bounds.
|
||||||
|
private func extensionMinimalisticPreferredHeight(baseSize: CGSize) -> CGFloat? {
|
||||||
|
guard let configuration = extensionNotchExperienceManager.minimalisticReplacementPayload()?.descriptor.minimalistic else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let minHeight = baseSize.height
|
||||||
|
let maxHeight = baseSize.height + statsAdditionalRowHeight
|
||||||
|
|
||||||
|
var contentHeight: CGFloat = 0
|
||||||
|
var blockCount = 0
|
||||||
|
|
||||||
|
if configuration.headline != nil {
|
||||||
|
contentHeight += 24
|
||||||
|
blockCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if configuration.subtitle != nil {
|
||||||
|
contentHeight += 20
|
||||||
|
blockCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if !configuration.sections.isEmpty {
|
||||||
|
let sectionEstimate: CGFloat = 98
|
||||||
|
contentHeight += CGFloat(configuration.sections.count) * sectionEstimate
|
||||||
|
blockCount += configuration.sections.count
|
||||||
|
}
|
||||||
|
|
||||||
|
if let webDescriptor = configuration.webContent {
|
||||||
|
contentHeight += webDescriptor.preferredHeight
|
||||||
|
blockCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
guard blockCount > 0 else { return nil }
|
||||||
|
|
||||||
|
let spacingAllowance = CGFloat(max(blockCount - 1, 0)) * 16
|
||||||
|
let topPadding: CGFloat = 10
|
||||||
|
let bottomPadding: CGFloat = configuration.webContent == nil ? 10 : 0
|
||||||
|
let estimatedHeight = contentHeight + spacingAllowance + topPadding + bottomPadding
|
||||||
|
|
||||||
|
let clampedHeight = min(max(estimatedHeight, minHeight), maxHeight)
|
||||||
|
return clampedHeight > minHeight ? clampedHeight : nil
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Gesture Handling
|
// MARK: - Gesture Handling
|
||||||
|
|
||||||
private func handleDownGesture(translation: CGFloat, phase: NSEvent.Phase) {
|
private func handleDownGesture(translation: CGFloat, phase: NSEvent.Phase) {
|
||||||
|
|
@ -1512,6 +1925,31 @@ struct ContentView: View {
|
||||||
|
|
||||||
private func hideMusicControlWindow() {}
|
private func hideMusicControlWindow() {}
|
||||||
#endif
|
#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 {
|
||||||
|
if case .extensionLiveActivity = coordinator.sneakPeek.type {
|
||||||
|
return .standard
|
||||||
|
}
|
||||||
|
return coordinator.sneakPeek.styleOverride ?? Defaults[.sneakPeekStyles]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum MusicSecondaryLiveActivity: Equatable {
|
private enum MusicSecondaryLiveActivity: Equatable {
|
||||||
|
|
@ -1520,6 +1958,7 @@ private enum MusicSecondaryLiveActivity: Equatable {
|
||||||
case recording
|
case recording
|
||||||
case focus(FocusModeType)
|
case focus(FocusModeType)
|
||||||
case capsLock(showLabel: Bool)
|
case capsLock(showLabel: Bool)
|
||||||
|
case extensionPayload(ExtensionLiveActivityPayload)
|
||||||
|
|
||||||
var id: String {
|
var id: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
|
@ -1533,6 +1972,8 @@ private enum MusicSecondaryLiveActivity: Equatable {
|
||||||
return "focus-\(mode.rawValue)"
|
return "focus-\(mode.rawValue)"
|
||||||
case .capsLock(let showLabel):
|
case .capsLock(let showLabel):
|
||||||
return showLabel ? "caps-lock-label" : "caps-lock-icon"
|
return showLabel ? "caps-lock-label" : "caps-lock-icon"
|
||||||
|
case .extensionPayload(let payload):
|
||||||
|
return "extension-\(payload.id)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue