Merge main into PR #439

This commit is contained in:
Hariharan Mudaliar 2026-05-20 13:19:46 +05:30
commit 2b43513db0
No known key found for this signature in database
GPG key ID: 025583EB0BE5640E
37 changed files with 12872 additions and 574 deletions

View file

@ -247,6 +247,7 @@
hasScannedForEncodings = 0; hasScannedForEncodings = 0;
knownRegions = ( knownRegions = (
en, en,
tr,
Base, Base,
hi, hi,
ta, ta,

View file

@ -54,6 +54,7 @@ struct ContentView: View {
@ObservedObject var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared @ObservedObject var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared
@ObservedObject var localSendService = LocalSendService.shared @ObservedObject var localSendService = LocalSendService.shared
@State private var downloadManager = DownloadManager.shared @State private var downloadManager = DownloadManager.shared
@ObservedObject var shelfState = ShelfStateViewModel.shared
@Default(.enableStatsFeature) var enableStatsFeature @Default(.enableStatsFeature) var enableStatsFeature
@Default(.showCpuGraph) var showCpuGraph @Default(.showCpuGraph) var showCpuGraph
@ -84,6 +85,15 @@ struct ContentView: View {
@Default(.hideNonNotchUntilHover) var hideNonNotchUntilHover @Default(.hideNonNotchUntilHover) var hideNonNotchUntilHover
@Default(.terminalStickyMode) var terminalStickyMode @Default(.terminalStickyMode) var terminalStickyMode
// Battery settings reactivity
@Default(.showPowerStatusNotifications) var showPowerStatusNotifications
@Default(.showChargingBatteryHUD) var showChargingBatteryHUD
@Default(.showLowBatteryHUD) var showLowBatteryHUD
@Default(.showFullBatteryHUD) var showFullBatteryHUD
@Default(.showOnAllDisplays) var showOnAllDisplays
@Default(.lowBatteryHUDStyle) var lowBatteryHUDStyle
@Default(.fullBatteryHUDStyle) var fullBatteryHUDStyle
// 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 {
let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize
@ -100,6 +110,39 @@ struct ContentView: View {
return CGSize(width: max(baseSize.width, inlineWidth), height: baseSize.height) return CGSize(width: max(baseSize.width, inlineWidth), height: baseSize.height)
} }
// Handle battery HUD expansion sizing
if vm.notchState == .closed &&
coordinator.expandingView.show &&
coordinator.expandingView.type == .battery &&
isBatteryHUDVisibleOnCurrentScreen {
if let kind = batteryModel.activeTemporaryHUDKind {
let style: BatteryNotificationStyle = {
switch kind {
case .charging: return .compact
case .lowBattery: return Defaults[.lowBatteryHUDStyle]
case .fullBattery: return Defaults[.fullBatteryHUDStyle]
}
}()
var width = vm.closedNotchSize.width
var height = vm.effectiveClosedNotchHeight
switch (kind, style) {
case (.charging, _), (.lowBattery, .compact), (.fullBattery, .compact):
width += 180
case (.lowBattery, .standard):
width += 100
height += 75
case (.fullBattery, .standard):
width += 80
height += 70
}
return CGSize(width: width, height: height)
}
}
if coordinator.currentView == .timer { if coordinator.currentView == .timer {
return CGSize(width: baseSize.width, height: 250) // Extra height for timer presets return CGSize(width: baseSize.width, height: 250) // Extra height for timer presets
} }
@ -382,9 +425,78 @@ struct ContentView: View {
return DynamicIslandPillShape(cornerRadius: radius) return DynamicIslandPillShape(cornerRadius: radius)
} }
private var isBatteryHUDVisibleOnCurrentScreen: Bool {
guard coordinator.expandingView.show, coordinator.expandingView.type == .battery else { return false }
guard showPowerStatusNotifications else { return false }
guard batteryModel.activeTemporaryHUDKind != nil else { return false }
if showOnAllDisplays { return true }
guard let targetScreenName = batteryModel.activeTemporaryHUDTargetScreenName else { return true }
return currentScreenName == targetScreenName
}
private var isCurrentScreenExpansionVisible: Bool {
guard coordinator.expandingView.show else { return false }
if coordinator.expandingView.type == .battery {
return isBatteryHUDVisibleOnCurrentScreen
}
return true
}
private var currentScreenExpansionType: SneakContentType? {
isCurrentScreenExpansionVisible ? coordinator.expandingView.type : nil
}
private var displayedBatteryHUDLevel: Int {
let resolvedLevel = batteryModel.activeTemporaryHUDLevelOverride
?? Int(batteryModel.levelBattery.rounded())
return min(max(resolvedLevel, 0), 100)
}
private var displayedBatteryHUDUsesLowPowerMode: Bool {
batteryModel.activeTemporaryHUDLowPowerModeOverride ?? batteryModel.isInLowPowerMode
}
private var activeClosedBatterySurfaceShape: AnyShape? {
guard vm.notchState == .closed else { return nil }
guard isBatteryHUDVisibleOnCurrentScreen else { return nil }
guard let kind = batteryModel.activeTemporaryHUDKind else { return nil }
if isDynamicIslandMode {
let radius = dynamicIslandPillCornerRadiusInsets.opened
return AnyShape(DynamicIslandPillShape(cornerRadius: radius))
} else {
let topRadius = activeCornerRadiusInsets.closed.top
let bottomRadius: CGFloat = {
switch resolvedBatteryNotificationStyle(for: kind) {
case .compact:
return activeCornerRadiusInsets.closed.bottom
case .standard:
return kind == .fullBattery ? 36 : 40
}
}()
return AnyShape(NotchShape(topCornerRadius: topRadius, bottomCornerRadius: bottomRadius))
}
}
private func resolvedBatteryNotificationStyle(for kind: BatteryTemporaryHUDKind) -> BatteryNotificationStyle {
switch kind {
case .charging:
return .compact
case .lowBattery:
return lowBatteryHUDStyle
case .fullBattery:
return fullBatteryHUDStyle
}
}
/// Resolves the clip/content shape per-screen: pill on non-notch screens /// Resolves the clip/content shape per-screen: pill on non-notch screens
/// when dynamic island mode is active, standard notch shape otherwise. /// when dynamic island mode is active, standard notch shape otherwise.
private var resolvedClipShape: AnyShape { private var resolvedClipShape: AnyShape {
if let activeClosedBatterySurfaceShape {
return activeClosedBatterySurfaceShape
}
if isDynamicIslandMode { if isDynamicIslandMode {
return AnyShape(currentPillShape) return AnyShape(currentPillShape)
} }
@ -763,47 +875,41 @@ struct ContentView: View {
guard let musicSecondary else { return false } guard let musicSecondary else { return false }
switch musicSecondary { switch musicSecondary {
case .timer: case .timer:
return coordinator.expandingView.type == .timer return currentScreenExpansionType == .timer
case .reminder: case .reminder:
return coordinator.expandingView.type == .reminder return currentScreenExpansionType == .reminder
case .recording: case .recording:
return coordinator.expandingView.type == .recording return currentScreenExpansionType == .recording
case .focus: case .focus:
return coordinator.expandingView.type == .doNotDisturb return currentScreenExpansionType == .doNotDisturb
case .capsLock: case .capsLock:
return false return false
case .extensionPayload: case .extensionPayload:
return false return false
case .shelf:
return false
} }
}() }()
let canShowMusicDuringExpansion = !coordinator.expandingView.show let canShowMusicDuringExpansion = !isCurrentScreenExpansionVisible
|| coordinator.expandingView.type == .music || currentScreenExpansionType == .music
|| expansionMatchesSecondary || expansionMatchesSecondary
if coordinator.expandingView.type == .battery && coordinator.expandingView.show && vm.notchState == .closed && Defaults[.showPowerStatusNotifications] { if currentScreenExpansionType == .battery
HStack(spacing: 0) { && isBatteryHUDVisibleOnCurrentScreen
HStack { && vm.notchState == .closed
Text(batteryModel.statusText) && Defaults[.showPowerStatusNotifications]
.font(.subheadline) && batteryModel.activeTemporaryHUDKind != nil {
} BatteryTemporaryActivityView(
kind: batteryModel.activeTemporaryHUDKind ?? .charging,
Rectangle() batteryLevel: displayedBatteryHUDLevel,
.fill(.black) isLowPowerMode: displayedBatteryHUDUsesLowPowerMode,
.frame(width: vm.closedNotchSize.width + 10) closedNotchWidth: vm.closedNotchSize.width + (isHovering ? 8 : 0),
baseHeight: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0),
HStack { isDynamicIslandMode: isDynamicIslandMode,
DynamicIslandBatteryView( topCornerRadius: activeCornerRadiusInsets.closed.top,
batteryWidth: 30, styleOverride: batteryModel.activeTemporaryHUDKind.map { resolvedBatteryNotificationStyle(for: $0) }
isCharging: batteryModel.isCharging, )
isInLowPowerMode: batteryModel.isInLowPowerMode, .id(batteryModel.activeTemporaryHUDToken)
isPluggedIn: batteryModel.isPluggedIn,
levelBattery: batteryModel.levelBattery,
isForNotification: true
)
}
.frame(width: 76, alignment: .trailing)
}
.frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center)
} else if isSneakPeekVisibleOnCurrentScreen && 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 && coordinator.sneakPeek.type != .brightness && coordinator.sneakPeek.type != .backlight) || vm.notchState == .closed) { } else if isSneakPeekVisibleOnCurrentScreen && 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 && coordinator.sneakPeek.type != .brightness && coordinator.sneakPeek.type != .backlight) || 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(
@ -818,25 +924,25 @@ struct ContentView: View {
MusicLiveActivity(secondary: musicSecondary) MusicLiveActivity(secondary: musicSecondary)
.id("closed-music-live-activity") .id("closed-music-live-activity")
.transition(closedLiveActivitySwapTransition) .transition(closedLiveActivitySwapTransition)
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed {
TimerLiveActivity() TimerLiveActivity()
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .reminder) && vm.notchState == .closed && reminderManager.isActive && enableReminderLiveActivity && !vm.hideOnClosed { } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .reminder) && vm.notchState == .closed && reminderManager.isActive && enableReminderLiveActivity && !vm.hideOnClosed {
ReminderLiveActivity() ReminderLiveActivity()
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed && !musicPairingEligible { } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed && !musicPairingEligible {
RecordingLiveActivity() RecordingLiveActivity()
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .download) && vm.notchState == .closed && downloadManager.isDownloading && Defaults[.enableDownloadListener] && !vm.hideOnClosed { } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .download) && vm.notchState == .closed && downloadManager.isDownloading && Defaults[.enableDownloadListener] && !vm.hideOnClosed {
DownloadLiveActivity() DownloadLiveActivity()
.transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2)))
} else if !coordinator.expandingView.show && vm.notchState == .closed && localSendLiveActivityActive && !vm.hideOnClosed { } else if !isCurrentScreenExpansionVisible && vm.notchState == .closed && localSendLiveActivityActive && !vm.hideOnClosed {
LocalSendLiveActivity() LocalSendLiveActivity()
.transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2))) .transition(.blurReplace.animation(.interactiveSpring(dampingFraction: 1.2)))
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .doNotDisturb) && vm.notchState == .closed && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && (doNotDisturbManager.isDoNotDisturbActive || doNotDisturbManager.isFocusToastDismissing) && !vm.hideOnClosed && !lockScreenManager.isLocked { } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .doNotDisturb) && vm.notchState == .closed && Defaults[.enableDoNotDisturbDetection] && Defaults[.showDoNotDisturbIndicator] && (doNotDisturbManager.isDoNotDisturbActive || doNotDisturbManager.isFocusToastDismissing) && !vm.hideOnClosed && !lockScreenManager.isLocked {
DoNotDisturbLiveActivity() DoNotDisturbLiveActivity()
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed {
LockScreenLiveActivity() LockScreenLiveActivity()
.id("lock-screen-live-activity") .id("lock-screen-live-activity")
.transition(closedLiveActivitySwapTransition) .transition(closedLiveActivitySwapTransition)
} else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed { } else if (!isCurrentScreenExpansionVisible || currentScreenExpansionType == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed {
PrivacyLiveActivity() PrivacyLiveActivity()
} else if let extensionPayload = extensionStandalonePayload { } else if let extensionPayload = extensionStandalonePayload {
let layout = extensionStandaloneLayout( let layout = extensionStandaloneLayout(
@ -849,7 +955,11 @@ struct ContentView: View {
layout: layout, layout: layout,
isHovering: isHovering isHovering: isHovering
) )
} else if !coordinator.expandingView.show && vm.notchState == .closed && !shelfState.isEmpty && !vm.hideOnClosed && !lockScreenManager.isLocked && !enableMinimalisticUI {
ShelfInlineLiveActivity()
.transition(.opacity.animation(.smooth(duration: 0.25)))
} 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 {
} else if !isCurrentScreenExpansionVisible && 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 {
DynamicIslandHeader() DynamicIslandHeader()
@ -1200,6 +1310,11 @@ struct ContentView: View {
return .extensionPayload(extensionPayload) return .extensionPayload(extensionPayload)
} }
// Shelf: show file count as lowest-priority secondary
if !shelfState.isEmpty && !lockScreenManager.isLocked && !enableMinimalisticUI {
return .shelf(count: shelfState.items.count)
}
return nil return nil
} }
@ -1220,6 +1335,8 @@ struct ContentView: View {
case .extensionPayload(let payload): case .extensionPayload(let payload):
let maxWidth = baseWidth + centerBaseWidth * 0.6 let maxWidth = baseWidth + centerBaseWidth * 0.6
return ExtensionLayoutMetrics.trailingWidth(for: payload, baseWidth: baseWidth, maxWidth: maxWidth) return ExtensionLayoutMetrics.trailingWidth(for: payload, baseWidth: baseWidth, maxWidth: maxWidth)
case .shelf:
return baseWidth
} }
} }
@ -1322,6 +1439,10 @@ struct ContentView: View {
accent: payload.descriptor.accentColor.swiftUIColor, accent: payload.descriptor.accentColor.swiftUIColor,
size: badgeSize size: badgeSize
) )
case .shelf:
Image(systemName: "tray.and.arrow.down.fill")
.font(.system(size: badgeSize * 0.50, weight: .semibold))
.foregroundStyle(.white)
} }
} }
.frame(width: badgeSize, height: badgeSize) .frame(width: badgeSize, height: badgeSize)
@ -1380,6 +1501,14 @@ struct ContentView: View {
spectrumView(forceSpectrum: true, trailingInset: 6) spectrumView(forceSpectrum: true, trailingInset: 6)
case .extensionPayload(let payload): case .extensionPayload(let payload):
ExtensionMusicWingView(payload: payload, notchHeight: notchHeight, trailingWidth: trailingWidth) ExtensionMusicWingView(payload: payload, notchHeight: notchHeight, trailingWidth: trailingWidth)
case .shelf(let count):
// File count badge: bold white number, like a minimal pill
Text("\(count)")
.font(.system(.callout, design: .rounded, weight: .bold))
.foregroundStyle(.white)
.contentTransition(.numericText(countsDown: false))
.animation(.smooth(duration: 0.3), value: count)
.frame(alignment: .center)
case .none: case .none:
spectrumView( spectrumView(
forceSpectrum: false, forceSpectrum: false,
@ -1614,10 +1743,10 @@ struct ContentView: View {
return nil return nil
} }
guard !coordinator.expandingView.show else { guard !isCurrentScreenExpansionVisible else {
ExtensionRoutingDiagnostics.shared.logSuppression( ExtensionRoutingDiagnostics.shared.logSuppression(
.standalone, .standalone,
reason: "expanding view \(coordinator.expandingView.type) visible", reason: "expanding view \(String(describing: currentScreenExpansionType ?? coordinator.expandingView.type)) visible",
pendingCount: candidates.count pendingCount: candidates.count
) )
return nil return nil
@ -1737,7 +1866,7 @@ struct ContentView: View {
&& !vm.hideOnClosed && !vm.hideOnClosed
&& !lockScreenManager.isLocked && !lockScreenManager.isLocked
&& !isMusicHUDDeferredAfterUnlock && !isMusicHUDDeferredAfterUnlock
&& !coordinator.expandingView.show && !isCurrentScreenExpansionVisible
&& (!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil) && (!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil)
&& !coordinator.firstLaunch && !coordinator.firstLaunch
} }
@ -2482,6 +2611,7 @@ private enum MusicSecondaryLiveActivity: Equatable {
case focus(FocusModeType) case focus(FocusModeType)
case capsLock(showLabel: Bool) case capsLock(showLabel: Bool)
case extensionPayload(ExtensionLiveActivityPayload) case extensionPayload(ExtensionLiveActivityPayload)
case shelf(count: Int)
var id: String { var id: String {
switch self { switch self {
@ -2497,6 +2627,8 @@ private enum MusicSecondaryLiveActivity: Equatable {
return showLabel ? "caps-lock-label" : "caps-lock-icon" return showLabel ? "caps-lock-label" : "caps-lock-icon"
case .extensionPayload(let payload): case .extensionPayload(let payload):
return "extension-\(payload.id)" return "extension-\(payload.id)"
case .shelf(let count):
return "shelf-\(count)"
} }
} }
} }

View file

@ -396,6 +396,43 @@ class AppDelegate: NSObject, NSApplicationDelegate {
return CGSize(width: inlineSneakPeekWidth, height: vm.effectiveClosedNotchHeight) return CGSize(width: inlineSneakPeekWidth, height: vm.effectiveClosedNotchHeight)
} }
// Check for battery HUD expansion
if vm.notchState == .closed &&
coordinator.expandingView.show &&
coordinator.expandingView.type == .battery &&
Defaults[.showPowerStatusNotifications] {
let batteryModel = BatteryStatusViewModel.shared
if let kind = batteryModel.activeTemporaryHUDKind {
let closedNotchHeight = vm.effectiveClosedNotchHeight
let closedNotchWidth = vm.closedNotchSize.width
let style: BatteryNotificationStyle = {
switch kind {
case .charging: return .compact
case .lowBattery: return Defaults[.lowBatteryHUDStyle]
case .fullBattery: return Defaults[.fullBatteryHUDStyle]
}
}()
var width = closedNotchWidth
var height = closedNotchHeight
switch (kind, style) {
case (.charging, _), (.lowBattery, .compact), (.fullBattery, .compact):
width += 180
case (.lowBattery, .standard):
width += 100
height += 75
case (.fullBattery, .standard):
width += 80
height += 70
}
return addShadowPadding(to: CGSize(width: width, height: height), isMinimalistic: Defaults[.enableMinimalisticUI])
}
}
// Use minimalistic or normal size based on settings // Use minimalistic or normal size based on settings
var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize

View file

@ -97,6 +97,7 @@ struct ExpandedItem {
var type: SneakContentType = .battery var type: SneakContentType = .battery
var value: CGFloat = 0 var value: CGFloat = 0
var browser: BrowserType = .chromium var browser: BrowserType = .chromium
var autoHideDuration: TimeInterval? = nil
} }
class DynamicIslandViewCoordinator: ObservableObject { class DynamicIslandViewCoordinator: ObservableObject {
@ -413,7 +414,8 @@ class DynamicIslandViewCoordinator: ObservableObject {
status: Bool, status: Bool,
type: SneakContentType, type: SneakContentType,
value: CGFloat = 0, value: CGFloat = 0,
browser: BrowserType = .chromium browser: BrowserType = .chromium,
autoHideDuration: TimeInterval? = nil
) { ) {
Task { @MainActor in Task { @MainActor in
withAnimation(.smooth) { withAnimation(.smooth) {
@ -421,6 +423,7 @@ class DynamicIslandViewCoordinator: ObservableObject {
self.expandingView.type = type self.expandingView.type = type
self.expandingView.value = value self.expandingView.value = value
self.expandingView.browser = browser self.expandingView.browser = browser
self.expandingView.autoHideDuration = autoHideDuration
} }
} }
} }
@ -433,7 +436,7 @@ class DynamicIslandViewCoordinator: ObservableObject {
expandingViewTask?.cancel() expandingViewTask?.cancel()
// Only auto-hide for battery, not for downloads (DownloadManager handles that) // Only auto-hide for battery, not for downloads (DownloadManager handles that)
if expandingView.type != .download { if expandingView.type != .download {
let duration: TimeInterval = 3 let duration = expandingView.autoHideDuration ?? 3
expandingViewTask = Task { [weak self] in expandingViewTask = Task { [weak self] in
try? await Task.sleep(for: .seconds(duration)) try? await Task.sleep(for: .seconds(duration))
guard let self = self, !Task.isCancelled else { return } guard let self = self, !Task.isCancelled else { return }

View file

@ -7,6 +7,8 @@
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
<true/> <true/>
</dict> </dict>
<key>NSAppleMusicUsageDescription</key>
<string>Atoll uses Apple Music to fetch animated artwork for the lock screen wallpaper.</string>
<key>NSAudioCaptureUsageDescription</key> <key>NSAudioCaptureUsageDescription</key>
<string>Atoll needs audio capture permission to display the real-time music waveform visualizer.</string> <string>Atoll needs audio capture permission to display the real-time music waveform visualizer.</string>
<key>NSScreenCaptureUsageDescription</key> <key>NSScreenCaptureUsageDescription</key>

File diff suppressed because it is too large Load diff

View file

@ -20,8 +20,8 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
import Foundation
import Combine import Combine
import Foundation
import SwiftUI import SwiftUI
class SpotifyController: MediaControllerProtocol { class SpotifyController: MediaControllerProtocol {
@ -37,19 +37,26 @@ class SpotifyController: MediaControllerProtocol {
} }
var isWorking: Bool { var isWorking: Bool {
return true // Spotify controller always works true
} }
private var notificationTask: Task<Void, Never>? private var notificationTask: Task<Void, Never>?
private var sessionChangeCancellable: AnyCancellable?
// Constant for time between command and update // Constant for time between command and update
private let commandUpdateDelay: Duration = .milliseconds(25) private let commandUpdateDelay: Duration = .milliseconds(25)
private var lastArtworkURL: String? private var lastArtworkURL: String?
private var artworkFetchTask: Task<Void, Never>? private var artworkFetchTask: Task<Void, Never>?
private var canvasFetchTask: Task<Void, Never>?
private var currentCanvasTrackURI: String?
private var lastCanvasRequestTrackURI: String?
private var lastCanvasRequestDate: Date = .distantPast
private let canvasResolver = SpotifyCanvasResolver()
init() { init() {
setupPlaybackStateChangeObserver() setupPlaybackStateChangeObserver()
setupSessionChangeObserver()
Task { Task {
if isActive() { if isActive() {
await updatePlaybackInfo() await updatePlaybackInfo()
@ -69,9 +76,27 @@ class SpotifyController: MediaControllerProtocol {
} }
} }
private func setupSessionChangeObserver() {
sessionChangeCancellable = NotificationCenter.default.publisher(for: .spotifyCanvasSessionDidChange)
.sink { [weak self] _ in
self?.lastCanvasRequestTrackURI = nil
self?.lastCanvasRequestDate = .distantPast
if let self {
var updatedState = self.playbackState
updatedState.liveArtworkURL = nil
self.playbackState = updatedState
}
Task {
await self?.updatePlaybackInfo()
}
}
}
deinit { deinit {
notificationTask?.cancel() notificationTask?.cancel()
artworkFetchTask?.cancel() artworkFetchTask?.cancel()
canvasFetchTask?.cancel()
sessionChangeCancellable?.cancel()
} }
// MARK: - Protocol Implementation // MARK: - Protocol Implementation
@ -109,12 +134,13 @@ class SpotifyController: MediaControllerProtocol {
let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown" let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown"
let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown" let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown"
let currentTime = descriptor.atIndex(5)?.doubleValue ?? 0 let currentTime = descriptor.atIndex(5)?.doubleValue ?? 0
let duration = (descriptor.atIndex(6)?.doubleValue ?? 0)/1000 let duration = (descriptor.atIndex(6)?.doubleValue ?? 0) / 1000
let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false let isShuffled = descriptor.atIndex(7)?.booleanValue ?? false
let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false let isRepeating = descriptor.atIndex(8)?.booleanValue ?? false
let artworkURL = descriptor.atIndex(9)?.stringValue ?? "" let artworkURL = descriptor.atIndex(9)?.stringValue ?? ""
let trackIdentifier = descriptor.atIndex(10)?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let trackIdentifier = descriptor.atIndex(10)?.stringValue ?? ""
let trackSpotifyURL = descriptor.atIndex(11)?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let spotifyURLString = descriptor.atIndex(11)?.stringValue ?? ""
let trackURI = Self.canonicalTrackURI(from: trackIdentifier, spotifyURLString: spotifyURLString)
var state = PlaybackState( var state = PlaybackState(
bundleIdentifier: Self.bundleIdentifier, bundleIdentifier: Self.bundleIdentifier,
@ -129,20 +155,40 @@ class SpotifyController: MediaControllerProtocol {
repeatMode: isRepeating ? .all : .off, repeatMode: isRepeating ? .all : .off,
lastUpdated: Date() lastUpdated: Date()
) )
state.contentIdentifier = trackIdentifier.isEmpty ? nil : trackIdentifier state.contentIdentifier = trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
state.contentURL = trackSpotifyURL.isEmpty ? nil : trackSpotifyURL ? nil
: trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
state.contentURL = spotifyURLString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? nil
: spotifyURLString.trimmingCharacters(in: .whitespacesAndNewlines)
if artworkURL == lastArtworkURL, let existingArtwork = self.playbackState.artwork { if artworkURL == lastArtworkURL, let existingArtwork = self.playbackState.artwork {
state.artwork = existingArtwork state.artwork = existingArtwork
} }
playbackState = state let isSameCanvasTrack = trackURI == currentCanvasTrackURI
if isSameCanvasTrack {
state.liveArtworkURL = playbackState.liveArtworkURL
} else {
state.liveArtworkURL = nil
currentCanvasTrackURI = trackURI.isEmpty ? nil : trackURI
canvasFetchTask?.cancel()
canvasFetchTask = nil
}
playbackState = state
if !trackURI.isEmpty {
scheduleCanvasFetchIfNeeded(for: trackURI)
}
if !artworkURL.isEmpty, let url = URL(string: artworkURL) { if !artworkURL.isEmpty, let url = URL(string: artworkURL) {
guard artworkURL != lastArtworkURL || state.artwork == nil else { return } guard artworkURL != lastArtworkURL || state.artwork == nil else { return }
artworkFetchTask?.cancel() artworkFetchTask?.cancel()
let currentState = state let currentState = state
let expectedTrackURI = trackURI
artworkFetchTask = Task { artworkFetchTask = Task {
do { do {
@ -150,8 +196,13 @@ class SpotifyController: MediaControllerProtocol {
await MainActor.run { [weak self] in await MainActor.run { [weak self] in
guard let self = self else { return } guard let self = self else { return }
guard expectedTrackURI.isEmpty || self.currentCanvasTrackURI == expectedTrackURI else {
self.artworkFetchTask = nil
return
}
var updatedState = currentState var updatedState = currentState
updatedState.artwork = data updatedState.artwork = data
updatedState.liveArtworkURL = self.playbackState.liveArtworkURL
self.playbackState = updatedState self.playbackState = updatedState
self.lastArtworkURL = artworkURL self.lastArtworkURL = artworkURL
self.artworkFetchTask = nil self.artworkFetchTask = nil
@ -165,8 +216,39 @@ class SpotifyController: MediaControllerProtocol {
} }
} }
// MARK: - Private Methods private func scheduleCanvasFetchIfNeeded(for trackURI: String) {
guard !trackURI.isEmpty else { return }
guard playbackState.liveArtworkURL == nil else { return }
guard canvasFetchTask == nil else { return }
if lastCanvasRequestTrackURI == trackURI,
Date().timeIntervalSince(lastCanvasRequestDate) < 5
{
return
}
lastCanvasRequestTrackURI = trackURI
lastCanvasRequestDate = Date()
canvasFetchTask = Task { [weak self] in
guard let self else { return }
let canvasURL = await self.canvasResolver.canvasURL(for: trackURI)
guard !Task.isCancelled else { return }
await MainActor.run {
guard self.currentCanvasTrackURI == trackURI else {
self.canvasFetchTask = nil
return
}
var updatedState = self.playbackState
updatedState.liveArtworkURL = canvasURL
self.playbackState = updatedState
self.canvasFetchTask = nil
}
}
}
private func executeCommand(_ command: String) async { private func executeCommand(_ command: String) async {
let script = "tell application \"Spotify\" to \(command)" let script = "tell application \"Spotify\" to \(command)"
try? await AppleScriptHelper.executeVoid(script) try? await AppleScriptHelper.executeVoid(script)
@ -192,15 +274,9 @@ class SpotifyController: MediaControllerProtocol {
set shuffleState to shuffling set shuffleState to shuffling
set repeatState to repeating set repeatState to repeating
set artworkURL to artwork url of current track set artworkURL to artwork url of current track
set trackIdentifier to "" set trackID to id of current track
try set spotifyURL to spotify url of current track
set trackIdentifier to id of current track return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL, trackID, spotifyURL}
end try
set trackSpotifyURL to ""
try
set trackSpotifyURL to spotify url of current track
end try
return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL, trackIdentifier, trackSpotifyURL}
on error on error
return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, "", "", ""} return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, "", "", ""}
end try end try
@ -209,4 +285,283 @@ class SpotifyController: MediaControllerProtocol {
return try await AppleScriptHelper.execute(script) return try await AppleScriptHelper.execute(script)
} }
private static func canonicalTrackURI(from trackIdentifier: String, spotifyURLString: String) -> String {
let trimmedIdentifier = trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedIdentifier.hasPrefix("spotify:track:") {
return trimmedIdentifier
}
if isLikelySpotifyTrackID(trimmedIdentifier) {
return "spotify:track:\(trimmedIdentifier)"
}
if let trackID = extractTrackID(from: spotifyURLString) {
return "spotify:track:\(trackID)"
}
return ""
}
private static func extractTrackID(from spotifyURLString: String) -> String? {
guard let url = URL(string: spotifyURLString),
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
else { return nil }
let pathComponents = components.path.split(separator: "/")
guard let trackIndex = pathComponents.firstIndex(of: "track"),
trackIndex + 1 < pathComponents.count
else { return nil }
let identifier = String(pathComponents[trackIndex + 1])
return isLikelySpotifyTrackID(identifier) ? identifier : nil
}
private static func isLikelySpotifyTrackID(_ value: String) -> Bool {
value.count == 22 && value.unicodeScalars.allSatisfy { scalar in
CharacterSet.alphanumerics.contains(scalar)
}
}
}
private actor SpotifyCanvasResolver {
private struct CacheEntry {
let url: URL?
let expiresAt: Date
}
private var cache: [String: CacheEntry] = [:]
func canvasURL(for trackURI: String) async -> URL? {
let now = Date()
if let entry = cache[trackURI], entry.expiresAt > now {
return entry.url
}
let resolvedURL = await fetchCanvasURL(for: trackURI, retryOnUnauthorized: true)
let cacheLifetime: TimeInterval = resolvedURL == nil ? 300 : 3600
cache[trackURI] = CacheEntry(url: resolvedURL, expiresAt: now.addingTimeInterval(cacheLifetime))
return resolvedURL
}
private func fetchCanvasURL(for trackURI: String, retryOnUnauthorized: Bool) async -> URL? {
guard let accessToken = await SpotifyAuthManager.shared.validAccessToken() else {
return nil
}
var request = URLRequest(url: URL(string: "https://spclient.wg.spotify.com/canvaz-cache/v0/canvases")!)
request.httpMethod = "POST"
request.httpBody = SpotifyCanvasProtobuf.makeCanvasRequest(trackURI: trackURI)
request.setValue("application/protobuf", forHTTPHeaderField: "Accept")
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.setValue("en", forHTTPHeaderField: "Accept-Language")
request.setValue("Spotify/9.0.34.593 iOS/18.4 (iPhone15,3)", forHTTPHeaderField: "User-Agent")
request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
return nil
}
if httpResponse.statusCode == 401, retryOnUnauthorized {
await SpotifyAuthManager.shared.invalidateAccessToken()
return await fetchCanvasURL(for: trackURI, retryOnUnauthorized: false)
}
guard (200..<300).contains(httpResponse.statusCode) else {
return nil
}
return SpotifyCanvasProtobuf.parseCanvasURL(from: data, matching: trackURI)
} catch {
return nil
}
}
}
private enum SpotifyCanvasProtobuf {
static func makeCanvasRequest(trackURI: String) -> Data {
let uriData = Data(trackURI.utf8)
var trackMessage = Data()
trackMessage.append(protobufKey(fieldNumber: 1, wireType: 2))
trackMessage.append(varint(for: uriData.count))
trackMessage.append(uriData)
var request = Data()
request.append(protobufKey(fieldNumber: 1, wireType: 2))
request.append(varint(for: trackMessage.count))
request.append(trackMessage)
return request
}
static func parseCanvasURL(from data: Data, matching trackURI: String) -> URL? {
do {
var reader = ProtobufReader(data: data)
var matchingURLs: [URL] = []
while !reader.isAtEnd {
let key = try reader.readVarint()
let fieldNumber = Int(key >> 3)
let wireType = Int(key & 0x7)
switch (fieldNumber, wireType) {
case (1, 2):
let canvasData = try reader.readLengthDelimited()
if let record = try parseCanvasRecord(from: canvasData),
record.trackURI == trackURI,
let canvasURL = record.canvasURL
{
matchingURLs.append(canvasURL)
}
default:
try reader.skipField(wireType: wireType)
}
}
return matchingURLs.first { $0.absoluteString.lowercased().hasSuffix(".mp4") } ?? matchingURLs.first
} catch {
return nil
}
}
private static func parseCanvasRecord(from data: Data) throws -> SpotifyCanvasRecord? {
var reader = ProtobufReader(data: data)
var trackURI = ""
var canvasURL: URL?
while !reader.isAtEnd {
let key = try reader.readVarint()
let fieldNumber = Int(key >> 3)
let wireType = Int(key & 0x7)
switch (fieldNumber, wireType) {
case (2, 2):
let urlString = try reader.readString()
canvasURL = URL(string: urlString)
case (5, 2):
trackURI = try reader.readString()
default:
try reader.skipField(wireType: wireType)
}
}
guard !trackURI.isEmpty else { return nil }
return SpotifyCanvasRecord(trackURI: trackURI, canvasURL: canvasURL)
}
private static func protobufKey(fieldNumber: Int, wireType: Int) -> Data {
var key = Data()
key.append(varint(for: (fieldNumber << 3) | wireType))
return key
}
private static func varint(for value: Int) -> Data {
var remaining = UInt64(value)
var buffer = Data()
repeat {
var nextByte = UInt8(remaining & 0x7F)
remaining >>= 7
if remaining != 0 {
nextByte |= 0x80
}
buffer.append(nextByte)
} while remaining != 0
return buffer
}
}
private struct SpotifyCanvasRecord {
let trackURI: String
let canvasURL: URL?
}
private struct ProtobufReader {
enum ReaderError: Error {
case malformedVarint
case unexpectedEnd
case unsupportedWireType
}
private let data: Data
private var index: Data.Index
init(data: Data) {
self.data = data
self.index = data.startIndex
}
var isAtEnd: Bool {
index >= data.endIndex
}
mutating func readVarint() throws -> UInt64 {
var result: UInt64 = 0
var shift: UInt64 = 0
while shift < 64 {
guard index < data.endIndex else {
throw ReaderError.unexpectedEnd
}
let byte = data[index]
index = data.index(after: index)
result |= UInt64(byte & 0x7F) << shift
if (byte & 0x80) == 0 {
return result
}
shift += 7
}
throw ReaderError.malformedVarint
}
mutating func readLengthDelimited() throws -> Data {
let length = Int(try readVarint())
guard length >= 0 else {
throw ReaderError.unexpectedEnd
}
let endIndex = data.index(index, offsetBy: length, limitedBy: data.endIndex)
guard let endIndex else {
throw ReaderError.unexpectedEnd
}
let slice = data[index..<endIndex]
index = endIndex
return Data(slice)
}
mutating func readString() throws -> String {
let bytes = try readLengthDelimited()
return String(decoding: bytes, as: UTF8.self)
}
mutating func skipField(wireType: Int) throws {
switch wireType {
case 0:
_ = try readVarint()
case 1:
try advance(by: 8)
case 2:
_ = try readLengthDelimited()
case 5:
try advance(by: 4)
default:
throw ReaderError.unsupportedWireType
}
}
private mutating func advance(by count: Int) throws {
let endIndex = data.index(index, offsetBy: count, limitedBy: data.endIndex)
guard let endIndex else {
throw ReaderError.unexpectedEnd
}
index = endIndex
}
} }

View file

@ -21,7 +21,7 @@ import Defaults
/// A view that displays the battery status with an icon and charging indicator. /// A view that displays the battery status with an icon and charging indicator.
struct BatteryView: View { struct BatteryView: View {
@Default(.showPowerStatusIcons) var showPowerStatusIcons
var levelBattery: Float var levelBattery: Float
var isPluggedIn: Bool var isPluggedIn: Bool
var isCharging: Bool var isCharging: Bool
@ -79,7 +79,7 @@ struct BatteryView: View {
) )
.padding(.leading, 2) .padding(.leading, 2)
if iconStatus != "" && (isForNotification || Defaults[.showPowerStatusIcons]) { if iconStatus != "" && (isForNotification || showPowerStatusIcons) {
ZStack { ZStack {
Image(iconStatus) Image(iconStatus)
.resizable() .resizable()
@ -182,6 +182,7 @@ struct BatteryMenuView: View {
/// A view that displays the battery status and allows interaction to show detailed information. /// A view that displays the battery status and allows interaction to show detailed information.
struct DynamicIslandBatteryView: View { struct DynamicIslandBatteryView: View {
@Default(.showBatteryPercentage) var showBatteryPercentage
@State var batteryWidth: CGFloat = 26 @State var batteryWidth: CGFloat = 26
var isCharging: Bool = false var isCharging: Bool = false
var isInLowPowerMode: Bool = false var isInLowPowerMode: Bool = false
@ -199,7 +200,7 @@ struct DynamicIslandBatteryView: View {
var body: some View { var body: some View {
HStack { HStack {
if Defaults[.showBatteryPercentage] { if showBatteryPercentage {
ZStack(alignment: .trailing) { ZStack(alignment: .trailing) {
Text("100%") Text("100%")
.font(.callout) .font(.callout)
@ -268,6 +269,443 @@ struct DynamicIslandBatteryView: View {
} }
} }
private struct BatteryTemporaryHUDMetrics {
let width: CGFloat
let height: CGFloat
let topRadius: CGFloat
let bottomRadius: CGFloat
}
private extension BatteryTemporaryHUDKind {
func metrics(
style: BatteryNotificationStyle,
closedNotchWidth: CGFloat,
baseHeight: CGFloat
) -> BatteryTemporaryHUDMetrics {
let compactBaseRadius = max(baseHeight / 2, 16)
let compactTopRadius = max(12, compactBaseRadius - 4)
switch (self, style) {
case (.charging, _), (.lowBattery, .compact), (.fullBattery, .compact):
return BatteryTemporaryHUDMetrics(
width: closedNotchWidth + 180,
height: baseHeight,
topRadius: compactTopRadius,
bottomRadius: compactBaseRadius
)
case (.lowBattery, .standard):
return BatteryTemporaryHUDMetrics(
width: closedNotchWidth + 150,
height: baseHeight + 75,
topRadius: 22,
bottomRadius: 40
)
case (.fullBattery, .standard):
return BatteryTemporaryHUDMetrics(
width: closedNotchWidth + 140,
height: baseHeight + 70,
topRadius: 18,
bottomRadius: 36
)
}
}
}
private struct BatteryCompactStatusRow: View {
let title: String
let batteryLevel: Int
let tint: Color
var body: some View {
HStack {
Text(verbatim: title)
.font(.system(size: 14))
.foregroundColor(.white.opacity(0.8))
Spacer()
HStack(spacing: 6) {
Text("\(batteryLevel)%")
.font(.system(size: 14))
.foregroundColor(tint)
HStack(spacing: 1.5) {
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(tint.opacity(0.3))
GeometryReader { geo in
let clamped = max(0, min(batteryLevel, 100))
let width = CGFloat(clamped) / 100 * geo.size.width
Rectangle()
.fill(tint.gradient)
.frame(width: max(0, width))
}
}
.frame(width: 28, height: 16)
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
RoundedRectangle(cornerRadius: 1.5, style: .continuous)
.fill(batteryLevel == 100 ? tint.gradient : tint.opacity(0.3).gradient)
.frame(width: 2, height: 6)
}
}
}
.padding(.horizontal, 16)
}
}
struct BatteryTemporaryActivityView: View {
let kind: BatteryTemporaryHUDKind
let batteryLevel: Int
let isLowPowerMode: Bool
let closedNotchWidth: CGFloat
let baseHeight: CGFloat
let isDynamicIslandMode: Bool
let topCornerRadius: CGFloat
@Default(.lowBatteryHUDStyle) var lowBatteryHUDStyle
@Default(.fullBatteryHUDStyle) var fullBatteryHUDStyle
var styleOverride: BatteryNotificationStyle? = nil
@State private var pulse = false
@State private var showBatteryIndicator = false
@State private var changeBatteryIndicator = true
private var style: BatteryNotificationStyle {
if kind == .charging {
return .compact
}
if let styleOverride {
return styleOverride
}
switch kind {
case .charging:
return .compact
case .lowBattery:
return lowBatteryHUDStyle
case .fullBattery:
return fullBatteryHUDStyle
}
}
private var metrics: BatteryTemporaryHUDMetrics {
kind.metrics(
style: style,
closedNotchWidth: closedNotchWidth,
baseHeight: baseHeight
)
}
private var batteryTint: Color {
switch kind {
case .charging:
if isLowPowerMode {
return .yellow
} else if batteryLevel <= 20 {
return .red
}
return .green
case .lowBattery:
return isLowPowerMode ? .yellow : .red
case .fullBattery:
return isLowPowerMode ? .yellow : .green
}
}
private var surfaceShape: AnyShape {
if isDynamicIslandMode {
return AnyShape(DynamicIslandPillShape(cornerRadius: dynamicIslandPillCornerRadiusInsets.opened))
} else {
return AnyShape(NotchShape(topCornerRadius: topCornerRadius, bottomCornerRadius: metrics.bottomRadius))
}
}
var body: some View {
ZStack(alignment: .bottom) {
content
}
.frame(width: metrics.width, height: metrics.height, alignment: .bottom)
.clipShape(surfaceShape)
.onAppear(perform: prepareAnimations)
}
@ViewBuilder
private var content: some View {
if style == .compact {
BatteryCompactStatusRow(
title: compactTitle,
batteryLevel: batteryLevel,
tint: batteryTint
)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
} else {
VStack {
Spacer()
HStack {
VStack(alignment: .leading, spacing: kind == .lowBattery ? 2 : 3) {
standardTitle
standardDescription
}
Spacer()
standardIndicator
}
.padding(.leading, kind == .lowBattery ? 40 : 35)
.padding(.trailing, kind == .lowBattery ? 45 : 40)
.padding(.bottom, 20)
}
}
}
private var compactTitle: String {
switch kind {
case .charging:
return "Charging"
case .lowBattery:
return "Low Battery"
case .fullBattery:
return "Full Battery"
}
}
@ViewBuilder
private var standardTitle: some View {
HStack(spacing: 5) {
Text(verbatim: kind == .lowBattery ? "Battery Low" : "Full Battery")
.font(.system(size: kind == .lowBattery ? 13 : 13, weight: .semibold))
.foregroundStyle(.white.opacity(0.82))
.lineLimit(1)
Text("\(batteryLevel)%")
.font(.system(size: kind == .lowBattery ? 12 : 13, weight: .semibold))
.foregroundStyle(batteryTint)
}
}
@ViewBuilder
private var standardDescription: some View {
switch kind {
case .charging:
EmptyView()
case .lowBattery:
if isLowPowerMode {
(
Text(verbatim: "Low Power Mode enabled")
.foregroundColor(.yellow)
.font(.system(size: 10, weight: .medium))
+
Text(verbatim: ", it is recommended to charge it.")
.foregroundColor(.gray.opacity(0.6))
.font(.system(size: 10, weight: .medium))
)
} else {
Text(verbatim: "Turn on Low Power Mode or it\nis recommended to charge it.")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.gray.opacity(0.6))
.lineLimit(2)
}
case .fullBattery:
Text(verbatim: "Your Mac is fully charged.")
.font(.system(size: 10))
.foregroundStyle(.gray.opacity(0.6))
.fontWeight(.medium)
.lineLimit(1)
}
}
@ViewBuilder
private var standardIndicator: some View {
switch kind {
case .charging:
EmptyView()
case .lowBattery:
if isLowPowerMode {
yellowLowIndicator
} else {
redLowIndicator
}
case .fullBattery:
if showBatteryIndicator {
if isLowPowerMode {
yellowFullIndicator
.transition(.opacity.combined(with: .scale))
} else {
greenFullIndicator
.transition(.opacity.combined(with: .scale))
}
} else {
magSafeIndicator
.transition(.opacity.combined(with: .scale))
}
}
}
private var redLowIndicator: some View {
ZStack {
RoundedRectangle(cornerRadius: 30)
.fill(.red.opacity(0.2))
.frame(width: 70, height: 40)
HStack(spacing: 2) {
RoundedRectangle(cornerRadius: 10)
.fill(.red.opacity(0.4))
.frame(width: 40, height: 24)
RoundedRectangle(cornerRadius: 10)
.fill(.red.opacity(0.4))
.frame(width: 3, height: 8)
}
.padding(.trailing, 5)
RoundedRectangle(cornerRadius: 8)
.fill(Color.red.gradient)
.frame(width: 8, height: 14)
.opacity(pulse ? 1 : 0.3)
.offset(x: -15)
RoundedRectangle(cornerRadius: 30)
.stroke(Color.red.opacity(0.9), lineWidth: 1.5)
.frame(width: pulse ? 8 : 30, height: pulse ? 14 : 32)
.offset(x: -15)
.opacity(pulse ? 0.3 : 1)
}
}
private var yellowLowIndicator: some View {
ZStack {
RoundedRectangle(cornerRadius: 30)
.fill(.yellow.opacity(0.2))
.frame(width: 70, height: 40)
HStack(spacing: 2) {
RoundedRectangle(cornerRadius: 10)
.fill(.yellow.opacity(0.4))
.frame(width: 40, height: 24)
RoundedRectangle(cornerRadius: 10)
.fill(.yellow.opacity(0.4))
.frame(width: 3, height: 8)
}
.padding(.trailing, 5)
RoundedRectangle(cornerRadius: 8)
.fill(.yellow.gradient)
.frame(width: 8, height: 14)
.offset(x: -15)
}
}
private var greenFullIndicator: some View {
ZStack {
RoundedRectangle(cornerRadius: 30)
.fill(.green.opacity(0.2))
.frame(width: 70, height: 40)
HStack(spacing: 2) {
RoundedRectangle(cornerRadius: 10)
.fill(.green.opacity(0.4))
.frame(width: 44, height: 24)
.overlay(
RoundedRectangle(cornerRadius: 4)
.fill(Color.green.gradient)
.frame(width: 34, height: 14)
.opacity(pulse ? 1 : 0.4)
)
RoundedRectangle(cornerRadius: 10)
.fill(.green.opacity(0.4))
.frame(width: 3, height: 8)
}
}
}
private var yellowFullIndicator: some View {
ZStack {
RoundedRectangle(cornerRadius: 30)
.fill(.yellow.opacity(0.2))
.frame(width: 70, height: 40)
HStack(spacing: 2) {
RoundedRectangle(cornerRadius: 10)
.fill(.yellow.opacity(0.4))
.frame(width: 44, height: 24)
.overlay(
RoundedRectangle(cornerRadius: 4)
.fill(Color.yellow.gradient)
.frame(width: 34, height: 14)
.opacity(pulse ? 1 : 0.4)
)
RoundedRectangle(cornerRadius: 10)
.fill(.yellow.opacity(0.4))
.frame(width: 3, height: 8)
}
}
}
private var magSafeIndicator: some View {
HStack(spacing: 0) {
Rectangle()
.fill(.gray.opacity(0.15))
.frame(width: 30, height: 5)
ZStack {
RoundedRectangle(cornerRadius: 2)
.fill(.gray.opacity(0.2).gradient)
.frame(width: 30, height: 40)
Circle()
.fill(changeBatteryIndicator ? .orange : .green)
.shadow(color: changeBatteryIndicator ? .orange : .green, radius: 5)
.frame(width: 5, height: 5)
}
Rectangle()
.fill(.white.opacity(0.4))
.frame(width: 3, height: 32)
}
}
private func prepareAnimations() {
pulse = false
showBatteryIndicator = kind == .fullBattery && style == .standard
changeBatteryIndicator = true
guard style == .standard else { return }
switch kind {
case .charging:
break
case .lowBattery:
if !isLowPowerMode {
withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) {
pulse = true
}
}
case .fullBattery:
withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) {
pulse = true
}
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
withAnimation(.spring(duration: 0.4)) {
showBatteryIndicator = false
}
}
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
withAnimation(.spring(duration: 0.2)) {
changeBatteryIndicator = false
}
}
}
}
}
#Preview { #Preview {
DynamicIslandBatteryView( DynamicIslandBatteryView(
batteryWidth: 30, batteryWidth: 30,

View file

@ -36,6 +36,7 @@ struct LockScreenMusicPanel: View {
@ObservedObject var musicManager = MusicManager.shared @ObservedObject var musicManager = MusicManager.shared
@ObservedObject private var routeManager = AudioRouteManager.shared @ObservedObject private var routeManager = AudioRouteManager.shared
@ObservedObject private var fullscreenArtworkManager = FullScreenArtworkWindowManager.shared
@StateObject private var volumeModel = MediaOutputVolumeViewModel() @StateObject private var volumeModel = MediaOutputVolumeViewModel()
@ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared @ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared
@ObservedObject private var animator: LockScreenPanelAnimator @ObservedObject private var animator: LockScreenPanelAnimator
@ -46,6 +47,7 @@ struct LockScreenMusicPanel: View {
@State private var isExpanded = false @State private var isExpanded = false
@State private var isVolumeSliderVisible = false @State private var isVolumeSliderVisible = false
@State private var isAirPlayPopoverPresented = false @State private var isAirPlayPopoverPresented = false
@State private var isArtworkFullscreen = false
@State private var collapseWorkItem: DispatchWorkItem? @State private var collapseWorkItem: DispatchWorkItem?
@State private var parallaxResumeWorkItem: DispatchWorkItem? @State private var parallaxResumeWorkItem: DispatchWorkItem?
@ -66,6 +68,9 @@ struct LockScreenMusicPanel: View {
@Default(.enableLyrics) private var enableLyrics @Default(.enableLyrics) private var enableLyrics
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled @Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled
@Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth @Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth
@Default(.lockScreenMusicFullscreenArtworkEnabled) private var fullscreenArtworkEnabled
@Default(.lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork) private var keepAlbumArtVisibleDuringFullscreenArtwork
@Default(.lockScreenMusicFullscreenVideoArtwork) private var fullscreenVideoArtwork
init(animator: LockScreenPanelAnimator) { init(animator: LockScreenPanelAnimator) {
_animator = ObservedObject(wrappedValue: animator) _animator = ObservedObject(wrappedValue: animator)
@ -119,6 +124,22 @@ struct LockScreenMusicPanel: View {
usesCustomLiquidGlass && useEnhancedLiquidBorder usesCustomLiquidGlass && useEnhancedLiquidBorder
} }
private var hidesInlineArtworkForSpotifyCanvasFallback: Bool {
fullscreenArtworkManager.isShowingSpotifyCanvasFallback
}
private var usesSpotifyCanvasFallbackContentPresentation: Bool {
fullscreenArtworkManager.isShowingSpotifyCanvasFallback
}
private var showsDetachedFullscreenLyrics: Bool {
usesSpotifyCanvasFallbackContentPresentation && enableLyrics
}
private var shouldShowInlineLyrics: Bool {
enableLyrics && !showsDetachedFullscreenLyrics
}
var body: some View { var body: some View {
if isActive && musicManager.hasActiveSession { if isActive && musicManager.hasActiveSession {
panelContent panelContent
@ -132,16 +153,27 @@ struct LockScreenMusicPanel: View {
ZStack(alignment: .topLeading) { ZStack(alignment: .topLeading) {
panelBackgroundLayer panelBackgroundLayer
panelForeground panelForeground
.shadow(
color: usesSpotifyCanvasFallbackContentPresentation ? Color.black.opacity(0.55) : .clear,
radius: usesSpotifyCanvasFallbackContentPresentation ? 10 : 0,
x: 0,
y: usesSpotifyCanvasFallbackContentPresentation ? 2 : 0
)
} }
.frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading) .frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading)
.clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous))
.overlay { .overlay {
if showPanelBorder { if showPanelBorder && !usesSpotifyCanvasFallbackContentPresentation {
panelBorderOverlay panelBorderOverlay
} }
} }
.shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10) .shadow(
.contentShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) color: usesSpotifyCanvasFallbackContentPresentation ? Color.black.opacity(0.35) : Color.black.opacity(0.3),
radius: usesSpotifyCanvasFallbackContentPresentation ? 26 : 20,
x: 0,
y: usesSpotifyCanvasFallbackContentPresentation ? 14 : 10
)
.contentShape(Rectangle())
.animation(.easeInOut(duration: 0.28), value: isExpanded) .animation(.easeInOut(duration: 0.28), value: isExpanded)
.animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider) .animation(.easeInOut(duration: 0.24), value: shouldShowVolumeSlider)
.onAppear { .onAppear {
@ -163,6 +195,11 @@ struct LockScreenMusicPanel: View {
parallaxResumeWorkItem = nil parallaxResumeWorkItem = nil
isParallaxSuspended = false isParallaxSuspended = false
} }
.onReceive(NotificationCenter.default.publisher(for: .atollArtworkWallpaperDismissed)) { _ in
withAnimation(.easeInOut(duration: 0.28)) {
isArtworkFullscreen = false
}
}
.onChange(of: isExpanded) { _, expanded in .onChange(of: isExpanded) { _, expanded in
updatePanelSize() updatePanelSize()
} }
@ -222,9 +259,13 @@ struct LockScreenMusicPanel: View {
collapsedLayout collapsedLayout
} }
} }
.padding(.horizontal, isExpanded ? 24 : 20) .padding(.horizontal, usesSpotifyCanvasFallbackContentPresentation ? (isExpanded ? 24 : 22) : (isExpanded ? 24 : 20))
.padding(.vertical, isExpanded ? 22 : 16) .padding(.vertical, usesSpotifyCanvasFallbackContentPresentation ? (isExpanded ? 18 : 14) : (isExpanded ? 22 : 16))
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: usesSpotifyCanvasFallbackContentPresentation ? .center : .topLeading
)
} }
private var collapsedLayout: some View { private var collapsedLayout: some View {
@ -239,25 +280,40 @@ struct LockScreenMusicPanel: View {
} }
private var expandedLayout: some View { private var expandedLayout: some View {
HStack(alignment: .center, spacing: expandedContentSpacing) { Group {
albumArtButton(size: 230, cornerRadius: expandedAlbumArtCornerRadius) if hidesInlineArtworkForSpotifyCanvasFallback {
.frame(width: 230, height: 230) VStack(alignment: .leading, spacing: 20) {
expandedHeader
progressBar
.padding(.top, 10)
.frame(maxWidth: .infinity)
playbackControls(alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
} else {
HStack(alignment: .center, spacing: expandedContentSpacing) {
albumArtButton(size: 230, cornerRadius: expandedAlbumArtCornerRadius)
.frame(width: 230, height: 230)
VStack(alignment: .leading, spacing: 20) { VStack(alignment: .leading, spacing: 20) {
expandedHeader expandedHeader
progressBar progressBar
.padding(.top, 10) .padding(.top, 10)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
playbackControls(alignment: .leading) playbackControls(alignment: .leading)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
} }
.frame(maxWidth: .infinity, alignment: .leading)
} }
.frame(maxHeight: .infinity, alignment: .center) .frame(maxHeight: .infinity, alignment: .center)
} }
private var collapsedHeader: some View { private var collapsedHeader: some View {
HStack(alignment: .center, spacing: 16) { HStack(alignment: .center, spacing: hidesInlineArtworkForSpotifyCanvasFallback ? 10 : 16) {
albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius) if !hidesInlineArtworkForSpotifyCanvasFallback {
albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius)
}
GeometryReader { geo in GeometryReader { geo in
VStack(alignment: .leading, spacing: 1) { VStack(alignment: .leading, spacing: 1) {
@ -329,30 +385,48 @@ struct LockScreenMusicPanel: View {
} }
private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View { private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View {
Button(action: toggleExpanded) { let artworkCornerRadius = resolvedArtworkCornerRadius(from: cornerRadius)
ZStack(alignment: .bottomTrailing) {
albumArtImage(size: size, cornerRadius: cornerRadius)
if showAppIcon, let icon = lockScreenAppIcon {
icon
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: appIconSize, height: appIconSize)
.shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4) return ZStack(alignment: .bottomTrailing) {
.offset(x: appIconOffset, y: appIconOffset) if isArtworkFullscreen && !keepAlbumArtVisibleDuringFullscreenArtwork {
.transition(.scale.combined(with: .opacity)) RoundedRectangle(cornerRadius: artworkCornerRadius, style: .continuous)
} .fill(Color.white.opacity(0.06))
.overlay(
Image(systemName: "photo.fill")
.font(.system(size: size * 0.3, weight: .light))
.foregroundColor(.white.opacity(0.2))
)
} else {
albumArtImage(size: size, cornerRadius: cornerRadius)
if showAppIcon, let icon = lockScreenAppIcon {
icon
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: appIconSize, height: appIconSize)
.shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4)
.offset(x: appIconOffset, y: appIconOffset)
.transition(.scale.combined(with: .opacity))
} }
.albumArtFlip(angle: musicManager.flipAngle) }
.parallax3D(enableOverride: lockScreenParallaxEnabled, suspended: isParallaxSuspended)
.frame(width: size)
.background(albumArtBackground(cornerRadius: cornerRadius))
.clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? appIconCornerRadius/3 : appIconCornerRadius, style: .continuous))
} }
.buttonStyle(PlainButtonStyle()) .albumArtFlip(angle: isArtworkFullscreen && !keepAlbumArtVisibleDuringFullscreenArtwork ? 0 : musicManager.flipAngle)
.parallax3D(
enableOverride: lockScreenParallaxEnabled && (!isArtworkFullscreen || keepAlbumArtVisibleDuringFullscreenArtwork),
suspended: isParallaxSuspended
)
.frame(width: size)
.background(albumArtBackground(cornerRadius: artworkCornerRadius))
.clipShape(RoundedRectangle(cornerRadius: artworkCornerRadius, style: .continuous))
.opacity(musicManager.isPlaying ? 1 : 0.4) .opacity(musicManager.isPlaying ? 1 : 0.4)
.scaleEffect(musicManager.isPlaying ? 1 : 0.85) .scaleEffect(musicManager.isPlaying ? 1 : 0.85)
.animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying) .animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying)
.animation(.easeInOut(duration: 0.28), value: isArtworkFullscreen)
.onTapGesture {
toggleExpanded()
}
.onRightClick {
expandArtworkToFullscreen()
}
} }
@ViewBuilder @ViewBuilder
@ -384,6 +458,41 @@ struct LockScreenMusicPanel: View {
} }
} }
private func expandArtworkToFullscreen() {
guard fullscreenArtworkEnabled else { return }
if FullScreenArtworkWindowManager.shared.isShowing {
FullScreenArtworkWindowManager.shared.hide()
withAnimation(.easeInOut(duration: 0.28)) {
isArtworkFullscreen = false
}
return
}
guard musicManager.hasActiveSession else { return }
let artwork = musicManager.albumArt
let videoURL = musicManager.videoArtworkURL
withAnimation(.easeInOut(duration: 0.28)) {
isArtworkFullscreen = true
}
FullScreenArtworkWindowManager.shared.onDismiss = {
Task { @MainActor in
withAnimation(.easeInOut(duration: 0.28)) {
NotificationCenter.default.post(name: .atollArtworkWallpaperDismissed, object: nil)
}
}
}
FullScreenArtworkWindowManager.shared.show(
artwork: artwork,
videoURL: fullscreenVideoArtwork ? videoURL : nil,
allowLiveWallpaper: fullscreenVideoArtwork
)
}
private func registerInteraction() { private func registerInteraction() {
cancelCollapseTimer() cancelCollapseTimer()
guard isExpanded else { return } guard isExpanded else { return }
@ -484,7 +593,7 @@ struct LockScreenMusicPanel: View {
private func playbackControls(alignment: Alignment) -> some View { private func playbackControls(alignment: Alignment) -> some View {
let spacing: CGFloat = isExpanded ? 24 : 20 let spacing: CGFloat = isExpanded ? 24 : 20
let verticalSpacing: CGFloat = (shouldShowVolumeSlider || enableLyrics) ? 14 : 10 let verticalSpacing: CGFloat = (shouldShowVolumeSlider || shouldShowInlineLyrics) ? 14 : 10
return VStack(spacing: verticalSpacing) { return VStack(spacing: verticalSpacing) {
controlsRow(alignment: alignment, spacing: spacing) controlsRow(alignment: alignment, spacing: spacing)
@ -505,7 +614,7 @@ struct LockScreenMusicPanel: View {
} }
} }
if enableLyrics { if shouldShowInlineLyrics {
lyricsSection(alignment: alignment) lyricsSection(alignment: alignment)
.transition(.move(edge: .top).combined(with: .opacity)) .transition(.move(edge: .top).combined(with: .opacity))
} }
@ -949,7 +1058,7 @@ struct LockScreenMusicPanel: View {
} }
private var lyricsExtraHeight: CGFloat { private var lyricsExtraHeight: CGFloat {
lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics) lyricsHeight(forExpanded: isExpanded, enabled: shouldShowInlineLyrics)
} }
private var accessorySectionExtraHeight: CGFloat { private var accessorySectionExtraHeight: CGFloat {
@ -979,7 +1088,7 @@ struct LockScreenMusicPanel: View {
private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat { private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat {
sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) + sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) +
accessorySectionExtraHeight + accessorySectionExtraHeight +
lyricsHeight(forExpanded: expanded, enabled: enableLyrics) lyricsHeight(forExpanded: expanded, enabled: shouldShowInlineLyrics)
} }
private func updatePanelSize(animated: Bool = true) { private func updatePanelSize(animated: Bool = true) {
@ -1034,7 +1143,9 @@ struct LockScreenMusicPanel: View {
@ViewBuilder @ViewBuilder
private var panelBackgroundLayer: some View { private var panelBackgroundLayer: some View {
if usesCustomLiquidGlass { if usesSpotifyCanvasFallbackContentPresentation {
canvasFallbackScrim
} else if usesCustomLiquidGlass {
customLiquidPanelBackdrop customLiquidPanelBackdrop
} else if usesStandardLiquidGlass { } else if usesStandardLiquidGlass {
standardLiquidPanelBackdrop standardLiquidPanelBackdrop
@ -1045,6 +1156,31 @@ struct LockScreenMusicPanel: View {
} }
} }
private var canvasFallbackScrim: some View {
RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)
.fill(.ultraThinMaterial)
.environment(\.colorScheme, .dark)
.overlay {
LinearGradient(
colors: [
Color.black.opacity(0.32),
Color.black.opacity(0.18),
Color.black.opacity(0.32)
],
startPoint: .top,
endPoint: .bottom
)
.clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous))
}
.overlay {
RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)
.stroke(Color.white.opacity(0.10), lineWidth: 0.6)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.allowsHitTesting(false)
.accessibilityHidden(true)
}
@ViewBuilder @ViewBuilder
private var standardLiquidPanelBackdrop: some View { private var standardLiquidPanelBackdrop: some View {
if #available(macOS 26.0, *) { if #available(macOS 26.0, *) {
@ -1132,7 +1268,8 @@ struct LockScreenMusicPanel: View {
Image(nsImage: musicManager.albumArt) Image(nsImage: musicManager.albumArt)
.resizable() .resizable()
.aspectRatio(contentMode: .fit) .aspectRatio(contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? cornerRadius/3 : cornerRadius)) .compositingGroup()
.clipShape(RoundedRectangle(cornerRadius: resolvedArtworkCornerRadius(from: cornerRadius), style: .continuous))
} }
@ViewBuilder @ViewBuilder
@ -1170,6 +1307,13 @@ struct LockScreenMusicPanel: View {
isExpanded ? 18 : 12 isExpanded ? 18 : 12
} }
private func resolvedArtworkCornerRadius(from baseCornerRadius: CGFloat) -> CGFloat {
let aspectRatio = musicManager.albumArt.size.height > 0
? musicManager.albumArt.size.width / musicManager.albumArt.size.height
: 1
return aspectRatio > 1.0 ? baseCornerRadius / 3 : baseCornerRadius
}
@available(macOS 26.0, *) @available(macOS 26.0, *)
private func clearLiquidGlassSurface(cornerRadius: CGFloat) -> some View { private func clearLiquidGlassSurface(cornerRadius: CGFloat) -> some View {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
@ -1384,3 +1528,87 @@ private struct GlassTextBackdrop: View {
.accessibilityHidden(true) .accessibilityHidden(true)
} }
} }
// MARK: - Right Click Gesture
private struct RightClickOverlay: ViewModifier {
let action: () -> Void
func body(content: Content) -> some View {
content.overlay(
RightClickReceiverView(action: action)
.frame(maxWidth: .infinity, maxHeight: .infinity)
)
}
}
private struct RightClickReceiverView: NSViewRepresentable {
let action: () -> Void
func makeNSView(context: Context) -> RightClickNSView {
let view = RightClickNSView()
view.onRightClick = action
return view
}
func updateNSView(_ nsView: RightClickNSView, context: Context) {
nsView.onRightClick = action
}
}
private extension View {
func onRightClick(perform action: @escaping () -> Void) -> some View {
modifier(RightClickOverlay(action: action))
}
}
final class RightClickNSView: NSView {
var onRightClick: (() -> Void)?
private var eventMonitor: Any?
deinit {
removeEventMonitor()
}
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
if window == nil {
removeEventMonitor()
} else {
installEventMonitorIfNeeded()
}
}
override func hitTest(_ point: NSPoint) -> NSView? {
nil
}
private func installEventMonitorIfNeeded() {
guard eventMonitor == nil else { return }
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.rightMouseDown]) { [weak self] event in
guard let self, let window = self.window, event.window === window else {
return event
}
let pointInView = self.convert(event.locationInWindow, from: nil)
guard self.bounds.contains(pointInView) else {
return event
}
self.onRightClick?()
return nil
}
}
private func removeEventMonitor() {
guard let eventMonitor else { return }
NSEvent.removeMonitor(eventMonitor)
self.eventMonitor = nil
}
}
extension Notification.Name {
static let atollArtworkWallpaperDismissed = Notification.Name("atollArtworkWallpaperDismissed")
}

View file

@ -45,10 +45,6 @@ struct LockScreenTimerWidget: View {
} }
} }
private func displayFont(size: CGFloat) -> Font {
.system(size: size, weight: .semibold, design: .rounded)
}
private var hasHoursComponent: Bool { private var hasHoursComponent: Bool {
abs(timerManager.remainingTime) >= 3600 abs(timerManager.remainingTime) >= 3600
} }
@ -57,12 +53,6 @@ struct LockScreenTimerWidget: View {
abs(timerManager.remainingTime) >= 36_000 // 10 hours or more abs(timerManager.remainingTime) >= 36_000 // 10 hours or more
} }
private var titleFrameWidth: CGFloat {
if hasDoubleDigitHours { return 60 }
if hasHoursComponent { return 80 }
return 110
}
private var countdownFrameWidth: CGFloat { private var countdownFrameWidth: CGFloat {
if hasDoubleDigitHours { return 200 } if hasDoubleDigitHours { return 200 }
if hasHoursComponent { return 185 } if hasHoursComponent { return 185 }
@ -74,10 +64,6 @@ struct LockScreenTimerWidget: View {
return .system(size: baseSize, weight: .bold, design: .rounded) return .system(size: baseSize, weight: .bold, design: .rounded)
} }
private var timerLabel: String {
timerManager.timerName.isEmpty ? "Timer" : timerManager.timerName
}
private var countdownText: String { private var countdownText: String {
timerManager.formattedRemainingTime() timerManager.formattedRemainingTime()
} }
@ -195,8 +181,7 @@ struct LockScreenTimerWidget: View {
controlButtons controlButtons
.padding(.trailing, 12) .padding(.trailing, 12)
titleSection Spacer()
.frame(maxWidth: .infinity)
countdownSection countdownSection
.padding(.leading, 12) .padding(.leading, 12)
@ -261,19 +246,6 @@ struct LockScreenTimerWidget: View {
.layoutPriority(2) .layoutPriority(2)
} }
private var titleSection: some View {
MarqueeText(
.constant(timerLabel),
font: displayFont(size: 15),
nsFont: .title3,
textColor: accentColor,
minDuration: 0.16,
frameWidth: titleFrameWidth
)
.frame(maxWidth: .infinity, alignment: .center)
.layoutPriority(0)
}
private var accentRibbon: some View { private var accentRibbon: some View {
Capsule() Capsule()
.fill(accentGradient) .fill(accentGradient)

View file

@ -1261,7 +1261,15 @@ struct LockScreenWeatherWidget: View {
private func chargingStatusFallback(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String { private func chargingStatusFallback(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String {
if info.isPluggedIn && !info.isCharging { if info.isPluggedIn && !info.isCharging {
return NSLocalizedString("Fully charged", comment: "Charging fallback label when already charged") let level = info.batteryLevel.map(clampedBatteryLevel)
if level == 100 {
return NSLocalizedString("Fully charged", comment: "Charging fallback label when already charged")
}
if let level {
let onHold = NSLocalizedString("On hold", comment: "Plugged in but charging is paused by the system")
return "\(level)% • \(onHold)"
}
return NSLocalizedString("On hold", comment: "Plugged in but charging is paused by the system")
} }
return NSLocalizedString("Charging", comment: "Charging fallback label when no estimate is available") return NSLocalizedString("Charging", comment: "Charging fallback label when no estimate is available")
} }
@ -1456,7 +1464,17 @@ struct LockScreenWeatherWidget: View {
} }
if charging.isPluggedIn && !charging.isCharging { if charging.isPluggedIn && !charging.isCharging {
return NSLocalizedString("Battery fully charged", comment: "Battery is full") let level = charging.batteryLevel.map(clampedBatteryLevel)
if level == 100 {
return NSLocalizedString("Battery fully charged", comment: "Battery is full")
}
if let level {
return String(
format: NSLocalizedString("Battery at %d percent, charging on hold", comment: "Plugged in but charging is paused by the system"),
level
)
}
return NSLocalizedString("Charging on hold", comment: "Plugged in but charging is paused by the system")
} }
if snapshot.showsChargingPercentage, let level = charging.batteryLevel { if snapshot.showsChargingPercentage, let level = charging.batteryLevel {

View file

@ -36,10 +36,12 @@ struct DynamicIslandHeader: View {
@Default(.showClipboardIcon) var showClipboardIcon @Default(.showClipboardIcon) var showClipboardIcon
@Default(.showColorPickerIcon) var showColorPickerIcon @Default(.showColorPickerIcon) var showColorPickerIcon
@Default(.clipboardDisplayMode) var clipboardDisplayMode @Default(.clipboardDisplayMode) var clipboardDisplayMode
@Default(.showBatteryIndicator) var showBatteryIndicator
@Default(.enableMinimalisticUI) var enableMinimalisticUI
var body: some View { var body: some View {
HStack(spacing: 0) { HStack(spacing: 0) {
if !Defaults[.enableMinimalisticUI] { if !enableMinimalisticUI {
HStack { HStack {
let shouldShowTabs = coordinator.alwaysShowTabs || vm.notchState == .open || !shelfState.items.isEmpty let shouldShowTabs = coordinator.alwaysShowTabs || vm.notchState == .open || !shelfState.items.isEmpty
if shouldShowTabs { if shouldShowTabs {
@ -55,7 +57,7 @@ struct DynamicIslandHeader: View {
} }
if vm.notchState == .open && !Defaults[.enableMinimalisticUI] { if vm.notchState == .open && !enableMinimalisticUI {
let spacerWidth = min(vm.closedNotchSize.width, 300) let spacerWidth = min(vm.closedNotchSize.width, 300)
Rectangle() Rectangle()
.fill(NSScreen.screens .fill(NSScreen.screens
@ -67,7 +69,7 @@ struct DynamicIslandHeader: View {
} }
HStack(spacing: 4) { HStack(spacing: 4) {
if vm.notchState == .open && !Defaults[.enableMinimalisticUI] { if vm.notchState == .open && !enableMinimalisticUI {
if Defaults[.showMirror] { if Defaults[.showMirror] {
Button(action: { Button(action: {
vm.toggleCameraPreview() vm.toggleCameraPreview()
@ -230,7 +232,7 @@ struct DynamicIslandHeader: View {
if Defaults[.showBatteryIndicator] { if showBatteryIndicator {
DynamicIslandBatteryView( DynamicIslandBatteryView(
batteryWidth: 30, batteryWidth: 30,
isCharging: batteryModel.isCharging, isCharging: batteryModel.isCharging,

View file

@ -113,6 +113,7 @@ struct MinimalisticMusicPlayerView: View {
// Compact progress bar // Compact progress bar
progressBar progressBar
.padding(.top, 6) .padding(.top, 6)
.clipped()
// Compact playback controls // Compact playback controls
if shouldShowControlHUDRow { if shouldShowControlHUDRow {
@ -133,7 +134,7 @@ struct MinimalisticMusicPlayerView: View {
reminderList reminderList
} }
.padding(.horizontal, 12) .padding(.horizontal, 12)
.padding(.top, -15) .padding(.top, 12)
.padding(.bottom, ReminderLiveActivityManager.baselineMinimalisticBottomPadding) .padding(.bottom, ReminderLiveActivityManager.baselineMinimalisticBottomPadding)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.frame(height: calculateDynamicHeight(), alignment: .top) .frame(height: calculateDynamicHeight(), alignment: .top)
@ -254,7 +255,7 @@ struct MinimalisticMusicPlayerView: View {
height += 6 + 4 // progress bar + top padding height += 6 + 4 // progress bar + top padding
// Add playback controls height // Add playback controls height
height += 40 + 2 // controls + top padding height += 54 + 2 // controls + top padding
// Add lyrics height if enabled in settings (reserve space even while loading) // Add lyrics height if enabled in settings (reserve space even while loading)
if enableLyrics { if enableLyrics {
@ -706,7 +707,7 @@ private struct MinimalisticReminderDetailsView: View {
// MARK: - Playback Controls (Larger) // MARK: - Playback Controls (Larger)
private var playbackControls: some View { private var playbackControls: some View {
HStack(spacing: 16) { HStack(spacing: 10) {
ForEach(Array(displayedSlots.enumerated()), id: \.offset) { _, slot in ForEach(Array(displayedSlots.enumerated()), id: \.offset) { _, slot in
slotView(for: slot) slotView(for: slot)
} }
@ -834,10 +835,10 @@ private struct MinimalisticReminderDetailsView: View {
private var playPauseButton: some View { private var playPauseButton: some View {
MinimalisticSquircircleButton( MinimalisticSquircircleButton(
icon: musicManager.isPlaying ? (musicManager.isLiveStream ? "stop.fill" : "pause.fill") : "play.fill", icon: musicManager.isPlaying ? (musicManager.isLiveStream ? "stop.fill" : "pause.fill") : "play.fill",
fontSize: 28, fontSize: 26,
fontWeight: .semibold, fontWeight: .semibold,
frameSize: CGSize(width: 60, height: 60), frameSize: CGSize(width: 54, height: 54),
cornerRadius: 24, cornerRadius: 22,
foregroundColor: .white, foregroundColor: .white,
pressEffect: .none, pressEffect: .none,
symbolEffectStyle: .replace, symbolEffectStyle: .replace,
@ -867,8 +868,8 @@ private struct MinimalisticReminderDetailsView: View {
icon: icon, icon: icon,
fontSize: size, fontSize: size,
fontWeight: .medium, fontWeight: .medium,
frameSize: CGSize(width: 40, height: 40), frameSize: CGSize(width: 36, height: 36),
cornerRadius: 16, cornerRadius: 14,
foregroundColor: isActive ? resolvedActiveColor : .white.opacity(0.85), foregroundColor: isActive ? resolvedActiveColor : .white.opacity(0.85),
pressEffect: pressEffect, pressEffect: pressEffect,
symbolEffectStyle: symbolEffect, symbolEffectStyle: symbolEffect,
@ -991,8 +992,8 @@ private struct MinimalisticReminderDetailsView: View {
icon: routeManager.activeDevice?.iconName ?? "speaker.wave.2", icon: routeManager.activeDevice?.iconName ?? "speaker.wave.2",
fontSize: 18, fontSize: 18,
fontWeight: .medium, fontWeight: .medium,
frameSize: CGSize(width: 40, height: 40), frameSize: CGSize(width: 36, height: 36),
cornerRadius: 16, cornerRadius: 14,
foregroundColor: .white.opacity(0.85), foregroundColor: .white.opacity(0.85),
symbolEffectStyle: .replace symbolEffectStyle: .replace
) { ) {
@ -1051,8 +1052,8 @@ private struct MinimalisticReminderDetailsView: View {
icon: "airplayaudio", icon: "airplayaudio",
fontSize: 18, fontSize: 18,
fontWeight: .medium, fontWeight: .medium,
frameSize: CGSize(width: 40, height: 40), frameSize: CGSize(width: 36, height: 36),
cornerRadius: 16, cornerRadius: 14,
foregroundColor: .white.opacity(0.85), foregroundColor: .white.opacity(0.85),
symbolEffectStyle: .replace symbolEffectStyle: .replace
) { ) {
@ -1113,8 +1114,17 @@ private struct MinimalisticReminderDetailsView: View {
struct MinimalisticAlbumArtView: View { struct MinimalisticAlbumArtView: View {
@ObservedObject var musicManager = MusicManager.shared @ObservedObject var musicManager = MusicManager.shared
@ObservedObject var vm: DynamicIslandViewModel @ObservedObject var vm: DynamicIslandViewModel
@Default(.showLiveCanvasInDynamicIsland) private var showLiveCanvasInDynamicIsland
let albumArtNamespace: Namespace.ID let albumArtNamespace: Namespace.ID
private var usesLiveCanvasArtwork: Bool {
showLiveCanvasInDynamicIsland && musicManager.videoArtworkURL != nil
}
private var albumArtCornerRadius: CGFloat {
musicManager.albumArt.size.width / musicManager.albumArt.size.height > 1.0 ? 4 : 12
}
var body: some View { var body: some View {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
if Defaults[.lightingEffect] { if Defaults[.lightingEffect] {
@ -1128,16 +1138,27 @@ struct MinimalisticAlbumArtView: View {
Color.clear Color.clear
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.background( .background(
Image(nsImage: musicManager.albumArt) DynamicIslandArtworkSourceView(
.resizable() cornerRadius: albumArtCornerRadius,
.scaledToFill() contentMode: .fill
)
) )
.clipped() .clipped()
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: albumArtCornerRadius))
.scaleEffect(x: 1.3, y: 1.4) .scaleEffect(x: 1.08, y: 1.1)
.rotationEffect(.degrees(92)) .rotationEffect(.degrees(92))
.blur(radius: 35) .blur(radius: 16)
.opacity(min(0.6, 1 - max(musicManager.albumArt.getBrightness(), 0.3))) .opacity(
usesLiveCanvasArtwork
? (musicManager.isPlaying ? 0.55 : 0.18)
: min(0.45, 1 - max(musicManager.albumArt.getBrightness(), 0.3))
)
.shadow(
color: Color(nsColor: musicManager.avgColor).opacity(usesLiveCanvasArtwork ? 0.22 : 0.12),
radius: usesLiveCanvasArtwork ? 10 : 7,
x: 0,
y: 0
)
} }
private var albumArtButton: some View { private var albumArtButton: some View {
@ -1147,13 +1168,10 @@ struct MinimalisticAlbumArtView: View {
Color.clear Color.clear
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.background( .background(
DynamicIslandArtworkSourceView(
Image(nsImage: musicManager.albumArt) cornerRadius: albumArtCornerRadius,
.resizable() contentMode: .fit
.aspectRatio(contentMode: .fit) )
.clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? 4 : 12))
) )
.clipped() .clipped()
.matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)

View file

@ -23,6 +23,129 @@
import Combine import Combine
import Defaults import Defaults
import SwiftUI import SwiftUI
import AppKit
import AVFoundation
private final class DynamicIslandArtworkLoopController {
let player: AVQueuePlayer
private var looper: AVPlayerLooper?
private var playbackStateCancellable: AnyCancellable?
init(url: URL) {
let item = AVPlayerItem(url: url)
player = AVQueuePlayer()
player.isMuted = true
player.actionAtItemEnd = .none
looper = AVPlayerLooper(player: player, templateItem: item)
if MusicManager.shared.isPlaying {
player.play()
}
playbackStateCancellable = MusicManager.shared.$isPlaying
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] isPlaying in
guard let self else { return }
if isPlaying {
self.player.play()
} else {
self.player.pause()
}
}
}
deinit {
player.pause()
looper = nil
playbackStateCancellable = nil
}
}
private final class DynamicIslandArtworkVideoContainerView: NSView {
let playerLayer = AVPlayerLayer()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
layer?.backgroundColor = NSColor.clear.cgColor
playerLayer.videoGravity = .resizeAspectFill
playerLayer.backgroundColor = NSColor.clear.cgColor
layer?.addSublayer(playerLayer)
}
required init?(coder: NSCoder) {
nil
}
override func layout() {
super.layout()
playerLayer.frame = bounds
}
}
private struct DynamicIslandArtworkVideoView: NSViewRepresentable {
let url: URL
let videoGravity: AVLayerVideoGravity
func makeCoordinator() -> Coordinator {
Coordinator()
}
func makeNSView(context: Context) -> DynamicIslandArtworkVideoContainerView {
let view = DynamicIslandArtworkVideoContainerView(frame: .zero)
context.coordinator.attach(layer: view.playerLayer, url: url, gravity: videoGravity)
return view
}
func updateNSView(_ nsView: DynamicIslandArtworkVideoContainerView, context: Context) {
context.coordinator.attach(layer: nsView.playerLayer, url: url, gravity: videoGravity)
}
final class Coordinator {
private var controller: DynamicIslandArtworkLoopController?
private var currentURL: URL?
func attach(layer: AVPlayerLayer, url: URL, gravity: AVLayerVideoGravity) {
layer.videoGravity = gravity
if currentURL != url || controller == nil {
currentURL = url
controller = DynamicIslandArtworkLoopController(url: url)
}
if layer.player !== controller?.player {
layer.player = controller?.player
}
}
}
}
struct DynamicIslandArtworkSourceView: View {
@ObservedObject private var musicManager = MusicManager.shared
@Default(.showLiveCanvasInDynamicIsland) private var showLiveCanvasInDynamicIsland
let cornerRadius: CGFloat
let contentMode: ContentMode
private var liveCanvasURL: URL? {
guard showLiveCanvasInDynamicIsland else { return nil }
return musicManager.videoArtworkURL
}
var body: some View {
Group {
if let liveCanvasURL {
DynamicIslandArtworkVideoView(url: liveCanvasURL, videoGravity: .resizeAspectFill)
} else {
Image(nsImage: musicManager.albumArt)
.resizable()
.aspectRatio(contentMode: contentMode)
}
}
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
}
}
// MARK: - Music Player Components // MARK: - Music Player Components
@ -43,8 +166,23 @@ struct MusicPlayerView: View {
struct AlbumArtView: View { struct AlbumArtView: View {
@ObservedObject var musicManager = MusicManager.shared @ObservedObject var musicManager = MusicManager.shared
@ObservedObject var vm: DynamicIslandViewModel @ObservedObject var vm: DynamicIslandViewModel
@Default(.showLiveCanvasInDynamicIsland) private var showLiveCanvasInDynamicIsland
let albumArtNamespace: Namespace.ID let albumArtNamespace: Namespace.ID
private var usesLiveCanvasArtwork: Bool {
showLiveCanvasInDynamicIsland && musicManager.videoArtworkURL != nil
}
private var albumArtCornerRadius: CGFloat {
Defaults[.cornerRadiusScaling]
? musicManager.albumArt.size.width / musicManager.albumArt.size.height > 1.0
? MusicPlayerImageSizes.cornerRadiusInset.opened / 3
: MusicPlayerImageSizes.cornerRadiusInset.opened
: musicManager.albumArt.size.width / musicManager.albumArt.size.height > 1.0
? MusicPlayerImageSizes.cornerRadiusInset.closed / 3
: MusicPlayerImageSizes.cornerRadiusInset.closed
}
var body: some View { var body: some View {
ZStack(alignment: .bottomTrailing) { ZStack(alignment: .bottomTrailing) {
if Defaults[.lightingEffect] { if Defaults[.lightingEffect] {
@ -58,24 +196,26 @@ struct AlbumArtView: View {
Color.clear Color.clear
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.background( .background(
Image(nsImage: musicManager.albumArt) DynamicIslandArtworkSourceView(
.resizable() cornerRadius: albumArtCornerRadius,
.aspectRatio(contentMode: .fit) contentMode: .fill
.clipShape( )
RoundedRectangle(
cornerRadius: Defaults[.cornerRadiusScaling]
? musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.opened/3 : MusicPlayerImageSizes.cornerRadiusInset.opened
: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.closed/3 : MusicPlayerImageSizes.cornerRadiusInset.closed
)
)
) )
.clipped() .clipped()
.scaleEffect(x: 1.3, y: 1.4) .scaleEffect(x: 1.3, y: 1.4)
.rotationEffect(.degrees(92)) .rotationEffect(.degrees(92))
.blur(radius: 40) .blur(radius: 40)
.opacity(musicManager.isPlaying ? 0.5 : 0) .opacity(
usesLiveCanvasArtwork
? (musicManager.isPlaying ? 0.62 : 0.18)
: (musicManager.isPlaying ? 0.5 : 0)
)
.shadow(
color: Color(nsColor: musicManager.avgColor).opacity(usesLiveCanvasArtwork ? 0.24 : 0.16),
radius: usesLiveCanvasArtwork ? 22 : 14,
x: 0,
y: 0
)
} }
private var albumArtButton: some View { private var albumArtButton: some View {
@ -111,18 +251,10 @@ struct AlbumArtView: View {
Color.clear Color.clear
.aspectRatio(1, contentMode: .fit) .aspectRatio(1, contentMode: .fit)
.overlay { .overlay {
Image(nsImage: musicManager.albumArt) DynamicIslandArtworkSourceView(
.resizable() cornerRadius: albumArtCornerRadius,
.aspectRatio(contentMode: .fit) contentMode: .fit
.clipShape( )
RoundedRectangle(
cornerRadius: Defaults[.cornerRadiusScaling]
? musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.opened/3 : MusicPlayerImageSizes.cornerRadiusInset.opened
: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? MusicPlayerImageSizes.cornerRadiusInset.closed/3 : MusicPlayerImageSizes.cornerRadiusInset.closed
)
) // ensures that even the rectagular album cover is rounded just like the MacOS media player
} }
.matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace)
.clipped() .clipped()

View file

@ -731,6 +731,19 @@ struct SettingsView: View {
SettingsSearchEntry(tab: .battery, title: "Show power status notifications", keywords: ["notifications", "power"], highlightID: SettingsTab.battery.highlightID(for: "Show power status notifications")), SettingsSearchEntry(tab: .battery, title: "Show power status notifications", keywords: ["notifications", "power"], highlightID: SettingsTab.battery.highlightID(for: "Show power status notifications")),
SettingsSearchEntry(tab: .battery, title: "Show power status icons", keywords: ["power icons", "charging icon"], highlightID: SettingsTab.battery.highlightID(for: "Show power status icons")), SettingsSearchEntry(tab: .battery, title: "Show power status icons", keywords: ["power icons", "charging icon"], highlightID: SettingsTab.battery.highlightID(for: "Show power status icons")),
SettingsSearchEntry(tab: .battery, title: "Play low battery alert sound", keywords: ["low battery", "alert", "sound"], highlightID: SettingsTab.battery.highlightID(for: "Play low battery alert sound")), SettingsSearchEntry(tab: .battery, title: "Play low battery alert sound", keywords: ["low battery", "alert", "sound"], highlightID: SettingsTab.battery.highlightID(for: "Play low battery alert sound")),
SettingsSearchEntry(tab: .battery, title: "Charging HUD", keywords: ["battery", "charging", "temporary activity"], highlightID: SettingsTab.battery.highlightID(for: "Charging HUD")),
SettingsSearchEntry(tab: .battery, title: "Low battery HUD", keywords: ["battery", "low", "temporary activity"], highlightID: SettingsTab.battery.highlightID(for: "Low battery HUD")),
SettingsSearchEntry(tab: .battery, title: "Fully charged HUD", keywords: ["battery", "full", "temporary activity"], highlightID: SettingsTab.battery.highlightID(for: "Fully charged HUD")),
SettingsSearchEntry(tab: .battery, title: "Charging duration", keywords: ["charging", "duration", "seconds"], highlightID: SettingsTab.battery.highlightID(for: "Charging duration")),
SettingsSearchEntry(tab: .battery, title: "Low battery duration", keywords: ["low battery", "duration", "seconds"], highlightID: SettingsTab.battery.highlightID(for: "Low battery duration")),
SettingsSearchEntry(tab: .battery, title: "Full battery duration", keywords: ["full battery", "duration", "seconds"], highlightID: SettingsTab.battery.highlightID(for: "Full battery duration")),
SettingsSearchEntry(tab: .battery, title: "Test charging HUD", keywords: ["battery", "test", "charging", "preview"], highlightID: nil),
SettingsSearchEntry(tab: .battery, title: "Test low battery HUD", keywords: ["battery", "test", "low", "preview"], highlightID: nil),
SettingsSearchEntry(tab: .battery, title: "Test full battery HUD", keywords: ["battery", "test", "full", "preview"], highlightID: nil),
SettingsSearchEntry(tab: .battery, title: "Low battery style", keywords: ["battery", "style", "compact", "standard"], highlightID: SettingsTab.battery.highlightID(for: "Low battery style")),
SettingsSearchEntry(tab: .battery, title: "Low battery threshold", keywords: ["battery", "threshold", "percent"], highlightID: SettingsTab.battery.highlightID(for: "Low battery threshold")),
SettingsSearchEntry(tab: .battery, title: "Full battery style", keywords: ["battery", "style", "compact", "standard"], highlightID: SettingsTab.battery.highlightID(for: "Full battery style")),
SettingsSearchEntry(tab: .battery, title: "Full charge threshold", keywords: ["battery", "threshold", "full"], highlightID: SettingsTab.battery.highlightID(for: "Full charge threshold")),
// HUDs // HUDs
SettingsSearchEntry(tab: .devices, title: "Show Bluetooth device connections", keywords: ["bluetooth", "hud"], highlightID: SettingsTab.devices.highlightID(for: "Show Bluetooth device connections")), SettingsSearchEntry(tab: .devices, title: "Show Bluetooth device connections", keywords: ["bluetooth", "hud"], highlightID: SettingsTab.devices.highlightID(for: "Show Bluetooth device connections")),
@ -754,6 +767,10 @@ struct SettingsView: View {
SettingsSearchEntry(tab: .hudAndOSD, title: "Keyboard Backlight OSD", keywords: ["keyboard", "backlight", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Keyboard Backlight OSD")), SettingsSearchEntry(tab: .hudAndOSD, title: "Keyboard Backlight OSD", keywords: ["keyboard", "backlight", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Keyboard Backlight OSD")),
SettingsSearchEntry(tab: .hudAndOSD, title: "Material", keywords: ["material", "frosted", "liquid", "glass", "solid", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Material")), SettingsSearchEntry(tab: .hudAndOSD, title: "Material", keywords: ["material", "frosted", "liquid", "glass", "solid", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Material")),
SettingsSearchEntry(tab: .hudAndOSD, title: "Icon & Progress Color", keywords: ["color", "icon", "white", "black", "gray", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Icon & Progress Color")), SettingsSearchEntry(tab: .hudAndOSD, title: "Icon & Progress Color", keywords: ["color", "icon", "white", "black", "gray", "osd"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Icon & Progress Color")),
SettingsSearchEntry(tab: .hudAndOSD, title: "Volume step", keywords: ["volume", "step", "percent"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Volume step")),
SettingsSearchEntry(tab: .hudAndOSD, title: "Volume fine step", keywords: ["volume", "fine", "step", "percent"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Volume fine step")),
SettingsSearchEntry(tab: .hudAndOSD, title: "Brightness step", keywords: ["brightness", "step", "percent"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Brightness step")),
SettingsSearchEntry(tab: .hudAndOSD, title: "Brightness fine step", keywords: ["brightness", "fine", "step", "percent"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Brightness fine step")),
SettingsSearchEntry(tab: .hudAndOSD, title: "Third-party DDC app integration", keywords: ["ddc", "third party", "external", "display", "betterdisplay", "lunar"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Third-party DDC app integration")), SettingsSearchEntry(tab: .hudAndOSD, title: "Third-party DDC app integration", keywords: ["ddc", "third party", "external", "display", "betterdisplay", "lunar"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Third-party DDC app integration")),
SettingsSearchEntry(tab: .hudAndOSD, title: "Third-party DDC provider", keywords: ["provider", "betterdisplay", "lunar", "integration", "refresh detection"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Third-party DDC provider")), SettingsSearchEntry(tab: .hudAndOSD, title: "Third-party DDC provider", keywords: ["provider", "betterdisplay", "lunar", "integration", "refresh detection"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Third-party DDC provider")),
SettingsSearchEntry(tab: .hudAndOSD, title: "Enable external volume control listener", keywords: ["external volume", "ddc volume", "betterdisplay volume", "lunar volume", "disable native volume"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Enable external volume control listener")), SettingsSearchEntry(tab: .hudAndOSD, title: "Enable external volume control listener", keywords: ["external volume", "ddc volume", "betterdisplay volume", "lunar volume", "disable native volume"], highlightID: SettingsTab.hudAndOSD.highlightID(for: "Enable external volume control listener")),
@ -763,6 +780,7 @@ struct SettingsView: View {
SettingsSearchEntry(tab: .media, title: "Skip buttons", keywords: ["skip", "controls", "±10"], highlightID: SettingsTab.media.highlightID(for: "Skip buttons")), SettingsSearchEntry(tab: .media, title: "Skip buttons", keywords: ["skip", "controls", "±10"], highlightID: SettingsTab.media.highlightID(for: "Skip buttons")),
SettingsSearchEntry(tab: .media, title: "Sneak Peek Style", keywords: ["sneak peek", "preview"], highlightID: SettingsTab.media.highlightID(for: "Sneak Peek Style")), SettingsSearchEntry(tab: .media, title: "Sneak Peek Style", keywords: ["sneak peek", "preview"], highlightID: SettingsTab.media.highlightID(for: "Sneak Peek Style")),
SettingsSearchEntry(tab: .media, title: "Enable lyrics", keywords: ["lyrics", "song text"], highlightID: SettingsTab.media.highlightID(for: "Enable lyrics")), SettingsSearchEntry(tab: .media, title: "Enable lyrics", keywords: ["lyrics", "song text"], highlightID: SettingsTab.media.highlightID(for: "Enable lyrics")),
SettingsSearchEntry(tab: .media, title: "Show live canvas in Dynamic Island", keywords: ["canvas", "live canvas", "album art", "dynamic island", "spotify canvas"], highlightID: SettingsTab.media.highlightID(for: "Show live canvas in Dynamic Island")),
SettingsSearchEntry(tab: .media, title: "Auto-hide inactive notch media player", keywords: ["auto hide", "inactive", "placeholder", "notch media"], highlightID: SettingsTab.media.highlightID(for: "Auto-hide inactive notch media player")), SettingsSearchEntry(tab: .media, title: "Auto-hide inactive notch media player", keywords: ["auto hide", "inactive", "placeholder", "notch media"], highlightID: SettingsTab.media.highlightID(for: "Auto-hide inactive notch media player")),
SettingsSearchEntry(tab: .media, title: "Show Change Media Output control", keywords: ["airplay", "route picker", "media output"], highlightID: SettingsTab.media.highlightID(for: "Show Change Media Output control")), SettingsSearchEntry(tab: .media, title: "Show Change Media Output control", keywords: ["airplay", "route picker", "media output"], highlightID: SettingsTab.media.highlightID(for: "Show Change Media Output control")),
SettingsSearchEntry(tab: .media, title: "Enable album art parallax", keywords: ["parallax", "lock screen", "album art"], highlightID: SettingsTab.media.highlightID(for: "Enable album art parallax")), SettingsSearchEntry(tab: .media, title: "Enable album art parallax", keywords: ["parallax", "lock screen", "album art"], highlightID: SettingsTab.media.highlightID(for: "Enable album art parallax")),
@ -1305,10 +1323,62 @@ struct GeneralSettings: View {
} }
struct Charge: View { struct Charge: View {
@ObservedObject private var batteryStatusViewModel = BatteryStatusViewModel.shared
@Default(.showPowerStatusNotifications) private var showPowerStatusNotifications
@Default(.showChargingBatteryHUD) private var showChargingBatteryHUD
@Default(.showLowBatteryHUD) private var showLowBatteryHUD
@Default(.showFullBatteryHUD) private var showFullBatteryHUD
@Default(.chargingBatteryHUDDuration) private var chargingBatteryHUDDuration
@Default(.lowBatteryHUDDuration) private var lowBatteryHUDDuration
@Default(.fullBatteryHUDDuration) private var fullBatteryHUDDuration
@Default(.lowBatteryHUDThreshold) private var lowBatteryHUDThreshold
@Default(.fullBatteryHUDThreshold) private var fullBatteryHUDThreshold
@Default(.lowBatteryHUDStyle) private var lowBatteryHUDStyle
@Default(.fullBatteryHUDStyle) private var fullBatteryHUDStyle
private func highlightID(_ title: String) -> String { private func highlightID(_ title: String) -> String {
SettingsTab.battery.highlightID(for: title) SettingsTab.battery.highlightID(for: title)
} }
private var chargingDurationBinding: Binding<Double> {
Binding(
get: { Double(chargingBatteryHUDDuration) },
set: { chargingBatteryHUDDuration = Int($0.rounded()) }
)
}
private var lowBatteryDurationBinding: Binding<Double> {
Binding(
get: { Double(lowBatteryHUDDuration) },
set: { lowBatteryHUDDuration = Int($0.rounded()) }
)
}
private var fullBatteryDurationBinding: Binding<Double> {
Binding(
get: { Double(fullBatteryHUDDuration) },
set: { fullBatteryHUDDuration = Int($0.rounded()) }
)
}
private var lowBatteryThresholdBinding: Binding<Double> {
Binding(
get: { Double(lowBatteryHUDThreshold) },
set: { lowBatteryHUDThreshold = Int($0.rounded()) }
)
}
private var fullBatteryThresholdBinding: Binding<Double> {
Binding(
get: { Double(fullBatteryHUDThreshold) },
set: { fullBatteryHUDThreshold = Int($0.rounded()) }
)
}
private func sectionOpacity(_ isEnabled: Bool) -> Double {
isEnabled ? 1 : 0.5
}
var body: some View { var body: some View {
Form { Form {
if BatteryActivityManager.shared.hasBattery() { if BatteryActivityManager.shared.hasBattery() {
@ -1340,6 +1410,157 @@ struct Charge: View {
} header: { } header: {
Text("Battery Information") Text("Battery Information")
} }
Section {
Defaults.Toggle(key: .showChargingBatteryHUD) {
Text("Charging HUD")
}
.settingsHighlight(id: highlightID("Charging HUD"))
Defaults.Toggle(key: .showLowBatteryHUD) {
Text("Low battery HUD")
}
.settingsHighlight(id: highlightID("Low battery HUD"))
Defaults.Toggle(key: .showFullBatteryHUD) {
Text("Fully charged HUD")
}
.settingsHighlight(id: highlightID("Fully charged HUD"))
} header: {
Text("Battery HUDs")
} footer: {
Text("These temporary HUDs recreate the charging, low-battery, and full-battery notch alerts.")
}
Section {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Charging duration")
Spacer()
Text("\(chargingBatteryHUDDuration)s")
.foregroundStyle(.secondary)
}
Slider(value: chargingDurationBinding, in: 1...10, step: 1)
}
.settingsHighlight(id: highlightID("Charging duration"))
.disabled(!showPowerStatusNotifications || !showChargingBatteryHUD)
.opacity(sectionOpacity(showPowerStatusNotifications && showChargingBatteryHUD))
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Low battery duration")
Spacer()
Text("\(lowBatteryHUDDuration)s")
.foregroundStyle(.secondary)
}
Slider(value: lowBatteryDurationBinding, in: 1...10, step: 1)
}
.settingsHighlight(id: highlightID("Low battery duration"))
.disabled(!showPowerStatusNotifications || !showLowBatteryHUD)
.opacity(sectionOpacity(showPowerStatusNotifications && showLowBatteryHUD))
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Full battery duration")
Spacer()
Text("\(fullBatteryHUDDuration)s")
.foregroundStyle(.secondary)
}
Slider(value: fullBatteryDurationBinding, in: 1...10, step: 1)
}
.settingsHighlight(id: highlightID("Full battery duration"))
.disabled(!showPowerStatusNotifications || !showFullBatteryHUD)
.opacity(sectionOpacity(showPowerStatusNotifications && showFullBatteryHUD))
} header: {
Text("HUD Duration")
}
Section {
Button {
batteryStatusViewModel.triggerTestHUD(kind: .charging)
} label: {
Label("Test charging HUD", systemImage: "bolt.fill")
}
.disabled(!showPowerStatusNotifications || !showChargingBatteryHUD)
Button {
batteryStatusViewModel.triggerTestHUD(kind: .lowBattery)
} label: {
Label("Test low battery HUD", systemImage: "battery.25")
}
.disabled(!showPowerStatusNotifications || !showLowBatteryHUD)
Button {
batteryStatusViewModel.triggerTestHUD(kind: .fullBattery)
} label: {
Label("Test full battery HUD", systemImage: "battery.100")
}
.disabled(!showPowerStatusNotifications || !showFullBatteryHUD)
} header: {
Text("HUD Tests")
} footer: {
Text("Runs the real notch animation on the current target display. If an external screen is using Dynamic Island mode, the battery HUD is sent there first.")
}
Section {
VStack(alignment: .leading, spacing: 10) {
Picker("Low battery style", selection: $lowBatteryHUDStyle) {
ForEach(BatteryNotificationStyle.allCases) { style in
Text(style.title)
.tag(style)
}
}
.pickerStyle(.segmented)
Text("Compact matches the charging HUD. Standard uses the expanded DynamicNotch-style card.")
.font(.caption)
.foregroundStyle(.secondary)
}
.settingsHighlight(id: highlightID("Low battery style"))
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Low battery threshold")
Spacer()
Text("\(lowBatteryHUDThreshold)%")
.foregroundStyle(.secondary)
}
Slider(value: lowBatteryThresholdBinding, in: 5...30, step: 1)
}
.settingsHighlight(id: highlightID("Low battery threshold"))
} header: {
Text("Low Battery")
}
.disabled(!showPowerStatusNotifications || !showLowBatteryHUD)
.opacity(sectionOpacity(showPowerStatusNotifications && showLowBatteryHUD))
Section {
VStack(alignment: .leading, spacing: 10) {
Picker("Full battery style", selection: $fullBatteryHUDStyle) {
ForEach(BatteryNotificationStyle.allCases) { style in
Text(style.title)
.tag(style)
}
}
.pickerStyle(.segmented)
Text("Compact keeps the alert inline. Standard uses the taller full-charge HUD with the charging animation.")
.font(.caption)
.foregroundStyle(.secondary)
}
.settingsHighlight(id: highlightID("Full battery style"))
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Full charge threshold")
Spacer()
Text("\(fullBatteryHUDThreshold)%")
.foregroundStyle(.secondary)
}
Slider(value: fullBatteryThresholdBinding, in: 80...100, step: 1)
}
.settingsHighlight(id: highlightID("Full charge threshold"))
} header: {
Text("Full Battery")
}
.disabled(!showPowerStatusNotifications || !showFullBatteryHUD)
.opacity(sectionOpacity(showPowerStatusNotifications && showFullBatteryHUD))
} else { } else {
ContentUnavailableView { ContentUnavailableView {
VStack(spacing: 16) { VStack(spacing: 16) {
@ -1997,6 +2218,10 @@ private struct ExternalDisplayIntegrationsSection: View {
@Default(.enableThirdPartyDDCIntegration) var enableThirdPartyDDCIntegration @Default(.enableThirdPartyDDCIntegration) var enableThirdPartyDDCIntegration
@Default(.thirdPartyDDCProvider) var thirdPartyDDCProvider @Default(.thirdPartyDDCProvider) var thirdPartyDDCProvider
@Default(.enableExternalVolumeControlListener) var enableExternalVolumeControlListener @Default(.enableExternalVolumeControlListener) var enableExternalVolumeControlListener
@Default(.volumeStepPercent) var volumeStepPercent
@Default(.volumeFineStepPercent) var volumeFineStepPercent
@Default(.brightnessStepPercent) var brightnessStepPercent
@Default(.brightnessFineStepPercent) var brightnessFineStepPercent
@ObservedObject private var betterDisplayManager = BetterDisplayManager.shared @ObservedObject private var betterDisplayManager = BetterDisplayManager.shared
@ObservedObject private var lunarManager = LunarManager.shared @ObservedObject private var lunarManager = LunarManager.shared
@ -2067,6 +2292,74 @@ private struct ExternalDisplayIntegrationsSection: View {
var body: some View { var body: some View {
Form { Form {
Section {
Stepper(value: $volumeStepPercent, in: 1...25) {
HStack {
Text("Volume step")
Spacer()
Text("\(volumeStepPercent)%")
.foregroundStyle(.secondary)
.monospacedDigit()
}
}
.settingsHighlight(id: highlightID("Volume step"))
.disabled(enableExternalVolumeControlListener)
Stepper(value: $volumeFineStepPercent, in: 1...25) {
HStack {
Text("Volume fine step")
Spacer()
Text("\(volumeFineStepPercent)%")
.foregroundStyle(.secondary)
.monospacedDigit()
}
}
.settingsHighlight(id: highlightID("Volume fine step"))
.disabled(enableExternalVolumeControlListener)
if enableExternalVolumeControlListener {
Text("Disabled while external display volume integration is active.")
.font(.caption)
.foregroundStyle(.secondary)
}
Stepper(value: $brightnessStepPercent, in: 1...25) {
HStack {
Text("Brightness step")
Spacer()
Text("\(brightnessStepPercent)%")
.foregroundStyle(.secondary)
.monospacedDigit()
}
}
.settingsHighlight(id: highlightID("Brightness step"))
.disabled(enableThirdPartyDDCIntegration)
Stepper(value: $brightnessFineStepPercent, in: 1...25) {
HStack {
Text("Brightness fine step")
Spacer()
Text("\(brightnessFineStepPercent)%")
.foregroundStyle(.secondary)
.monospacedDigit()
}
}
.settingsHighlight(id: highlightID("Brightness fine step"))
.disabled(enableThirdPartyDDCIntegration)
if enableThirdPartyDDCIntegration {
Text("Disabled while external display brightness integration is active.")
.font(.caption)
.foregroundStyle(.secondary)
}
} header: {
Text("Step size")
} footer: {
Text("Percent change per key press. Fine step applies when holding Shift+Option.")
.foregroundStyle(.secondary)
.font(.caption)
}
Section { Section {
Toggle("Enable third-party DDC app integration", isOn: $enableThirdPartyDDCIntegration) Toggle("Enable third-party DDC app integration", isOn: $enableThirdPartyDDCIntegration)
.settingsHighlight(id: highlightID("Third-party DDC app integration")) .settingsHighlight(id: highlightID("Third-party DDC app integration"))
@ -2450,6 +2743,7 @@ struct Media: View {
@Default(.lockScreenGlassStyle) private var lockScreenGlassStyle @Default(.lockScreenGlassStyle) private var lockScreenGlassStyle
@Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode @Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode
@Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenMusicAlbumParallaxEnabled @Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenMusicAlbumParallaxEnabled
@Default(.lockScreenMusicFullscreenArtworkEnabled) private var lockScreenMusicFullscreenArtworkEnabled
@Default(.showStandardMediaControls) private var showStandardMediaControls @Default(.showStandardMediaControls) private var showStandardMediaControls
@Default(.autoHideInactiveNotchMediaPlayer) private var autoHideInactiveNotchMediaPlayer @Default(.autoHideInactiveNotchMediaPlayer) private var autoHideInactiveNotchMediaPlayer
@Default(.parallaxEffectIntensity) private var parallaxEffectIntensity @Default(.parallaxEffectIntensity) private var parallaxEffectIntensity
@ -2505,6 +2799,11 @@ struct Media: View {
.font(.caption) .font(.caption)
} }
} }
if mediaController == .spotify {
SpotifyAuthSettingsSection()
}
Section { Section {
Defaults.Toggle(key: .showStandardMediaControls) { Defaults.Toggle(key: .showStandardMediaControls) {
Text("Show media controls in Dynamic Island") Text("Show media controls in Dynamic Island")
@ -2603,6 +2902,11 @@ struct Media: View {
Text("Enable lyrics") Text("Enable lyrics")
} }
.settingsHighlight(id: highlightID("Enable lyrics")) .settingsHighlight(id: highlightID("Enable lyrics"))
Defaults.Toggle(key: .showLiveCanvasInDynamicIsland) {
Text("Show live canvas in Dynamic Island")
}
.settingsHighlight(id: highlightID("Show live canvas in Dynamic Island"))
.help("Replaces the artwork tile with the live canvas when the current app provides one, and reuses that moving canvas for the surrounding lighting effect.")
//Parallax Effect Intensity to control how much parallax is wanted //Parallax Effect Intensity to control how much parallax is wanted
Slider(value: $parallaxEffectIntensity, in: 0...12, step: 1.0) { Slider(value: $parallaxEffectIntensity, in: 0...12, step: 1.0) {
@ -2696,6 +3000,27 @@ struct Media: View {
.opacity(enableLockScreenMediaWidget ? 1 : 0.5) .opacity(enableLockScreenMediaWidget ? 1 : 0.5)
.settingsHighlight(id: highlightID("Enable media panel blur")) .settingsHighlight(id: highlightID("Enable media panel blur"))
} }
VStack(alignment: .leading, spacing: 4) {
Defaults.Toggle(key: .lockScreenMusicFullscreenArtworkEnabled) {
Text("Fullscreen artwork on right-click")
}
.disabled(!enableLockScreenMediaWidget)
.settingsHighlight(id: highlightID("Fullscreen artwork on right-click"))
Defaults.Toggle(key: .lockScreenUseArtworkLayoutOverFullscreenCanvas) {
Text("Use album art layout over fullscreen canvas")
}
.disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled)
.settingsHighlight(id: highlightID("Use album art layout over fullscreen canvas"))
Defaults.Toggle(key: .lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork) {
Text("Keep album art visible during fullscreen artwork")
}
.disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled)
.settingsHighlight(id: highlightID("Keep album art visible during fullscreen artwork"))
Text("Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. If a canvas is available, Atoll can also keep the same album art + player layout on top of the live canvas.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
} header: { } header: {
Text("Lock Screen Integration") Text("Lock Screen Integration")
} footer: { } footer: {
@ -4576,6 +4901,7 @@ struct LockScreenSettings: View {
@Default(.lockScreenTimerGlassCustomizationMode) private var lockScreenTimerGlassCustomizationMode @Default(.lockScreenTimerGlassCustomizationMode) private var lockScreenTimerGlassCustomizationMode
@Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass @Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass
@Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget @Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget
@Default(.lockScreenMusicFullscreenArtworkEnabled) private var lockScreenMusicFullscreenArtworkEnabled
@Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget @Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget
@Default(.enableLockScreenWeatherWidget) private var enableLockScreenWeatherWidget @Default(.enableLockScreenWeatherWidget) private var enableLockScreenWeatherWidget
@Default(.enableLockScreenFocusWidget) private var enableLockScreenFocusWidget @Default(.enableLockScreenFocusWidget) private var enableLockScreenFocusWidget
@ -4809,6 +5135,27 @@ struct LockScreenSettings: View {
.opacity(enableLockScreenMediaWidget ? 1 : 0.5) .opacity(enableLockScreenMediaWidget ? 1 : 0.5)
.settingsHighlight(id: highlightID("Enable media panel blur")) .settingsHighlight(id: highlightID("Enable media panel blur"))
} }
VStack(alignment: .leading, spacing: 4) {
Defaults.Toggle(key: .lockScreenMusicFullscreenArtworkEnabled) {
Text("Fullscreen artwork on right-click")
}
.disabled(!enableLockScreenMediaWidget)
.settingsHighlight(id: highlightID("Fullscreen artwork on right-click"))
Defaults.Toggle(key: .lockScreenUseArtworkLayoutOverFullscreenCanvas) {
Text("Use album art layout over fullscreen canvas")
}
.disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled)
.settingsHighlight(id: highlightID("Use album art layout over fullscreen canvas"))
Defaults.Toggle(key: .lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork) {
Text("Keep album art visible during fullscreen artwork")
}
.disabled(!enableLockScreenMediaWidget || !lockScreenMusicFullscreenArtworkEnabled)
.settingsHighlight(id: highlightID("Keep album art visible during fullscreen artwork"))
Text("Right-click the album art on the lock screen to set it as the wallpaper. Right-click again or click the background to restore the original wallpaper. If a canvas is available, Atoll can also keep the same album art + player layout on top of the live canvas.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
if !showStandardMediaControls { if !showStandardMediaControls {
Text("Enable Dynamic Island media controls to manage the lock screen panel.") Text("Enable Dynamic Island media controls to manage the lock screen panel.")

View file

@ -0,0 +1,129 @@
/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import AppKit
import Defaults
import SwiftUI
struct SpotifyAuthSettingsSection: View {
@Default(.spotifySPDCCookie) private var spotifySPDCCookie
@ObservedObject private var spotifyAuthManager = SpotifyAuthManager.shared
@State private var showingLoginSheet = false
private var hasCookie: Bool {
!SpotifyAuthManager.sanitizeCookie(spotifySPDCCookie).isEmpty
}
var body: some View {
Section {
VStack(alignment: .leading, spacing: 8) {
Text("Sign in to Spotify to capture the `sp_dc` cookie automatically, or paste it in below.")
.font(.caption)
.foregroundStyle(.secondary)
Button {
showingLoginSheet = true
} label: {
Label("Sign in with Spotify", systemImage: "person.crop.circle.badge.checkmark")
}
.buttonStyle(.borderedProminent)
TextField("sp_dc cookie", text: $spotifySPDCCookie, axis: .vertical)
.textFieldStyle(.roundedBorder)
.font(.caption.monospaced())
.lineLimit(2...4)
.textSelection(.enabled)
}
HStack(spacing: 10) {
Circle()
.fill(spotifyAuthManager.isAuthenticated ? Color.green : (hasCookie ? Color.orange : Color.secondary))
.frame(width: 8, height: 8)
Text(spotifyAuthManager.sessionStatusText)
.foregroundStyle(.secondary)
}
if let authErrorMessage = spotifyAuthManager.authErrorMessage, !authErrorMessage.isEmpty {
Text(authErrorMessage)
.font(.caption)
.foregroundStyle(.red)
}
HStack {
Button("Paste from Clipboard") {
pasteCookieFromClipboard()
}
Button(spotifyAuthManager.isAuthorizing ? "Validating..." : "Validate Cookie") {
spotifySPDCCookie = SpotifyAuthManager.sanitizeCookie(spotifySPDCCookie)
Task {
await spotifyAuthManager.validateSession()
}
}
.disabled(!hasCookie || spotifyAuthManager.isAuthorizing)
Button("Clear") {
spotifySPDCCookie = ""
spotifyAuthManager.clearSession()
}
.disabled(!hasCookie && !spotifyAuthManager.isAuthenticated)
}
DisclosureGroup("Get the cookie manually") {
VStack(alignment: .leading, spacing: 6) {
Text("1. Open Spotify in a browser and log in")
.font(.caption)
Text("2. Developer Tools -> Application/Storage -> Cookies -> https://open.spotify.com")
.font(.caption)
Text("3. Copy the value of `sp_dc` and paste it here")
.font(.caption)
HStack(spacing: 12) {
Link("Open Spotify Web Player", destination: URL(string: "https://open.spotify.com")!)
Link("Method source", destination: URL(string: "https://github.com/Paxsenix0/Spotify-Canvas-API")!)
}
.font(.caption)
}
.padding(.top, 4)
}
.font(.caption)
.foregroundStyle(.secondary)
} header: {
Text("Spotify Canvas Session")
} footer: {
Text("Atoll uses the local `sp_dc` cookie only to request Spotify's internal web-player token and fetch the matching Canvas for the current track.")
.foregroundStyle(.secondary)
.font(.caption)
}
.sheet(isPresented: $showingLoginSheet) {
SpotifyLoginSheet { capturedValue in
let sanitized = SpotifyAuthManager.sanitizeCookie(capturedValue)
spotifySPDCCookie = sanitized
Task {
await spotifyAuthManager.validateSession()
}
}
}
}
private func pasteCookieFromClipboard() {
guard let clipboardText = NSPasteboard.general.string(forType: .string) else { return }
spotifySPDCCookie = SpotifyAuthManager.sanitizeCookie(clipboardText)
}
}

View file

@ -0,0 +1,272 @@
/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import AppKit
import os
import SwiftUI
@preconcurrency import WebKit
private enum SpotifyLoginConstants {
static let loginURL = URL(string: "https://accounts.spotify.com/en/login")!
static let externalLoginURL = URL(string: "https://open.spotify.com/")!
static let cookieInstructionsURL = URL(
string: "https://github.com/Paxsenix0/Spotify-Canvas-API#how-to-get-sp_dc-cookie"
)!
static let cookieDomainSuffix = "spotify.com"
static let cookieName = "sp_dc"
static let userAgent =
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
+ "(KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"
static let blockedOAuthHostSuffixes = ["accounts.google.com", "accounts.youtube.com"]
static let googleSignInRedirectURL = URL(string: "https://accounts.spotify.com/en/login")!
}
private let spotifyLoginLogger = os.Logger(subsystem: "com.Ebullioscopic.Atoll", category: "SpotifyLogin")
struct SpotifyLoginSheet: View {
@Environment(\.dismiss) private var dismiss
let onCapture: (String) -> Void
@State private var statusText: String =
"Sign in with email & password, Apple, or Facebook. Google blocks in-app sign-in — use “Open in Browser” for Google accounts."
@State private var didCapture = false
var body: some View {
VStack(spacing: 0) {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Connect Spotify")
.font(.headline)
Text(statusText)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button {
NSWorkspace.shared.open(SpotifyLoginConstants.externalLoginURL)
} label: {
Label("Open in Browser", systemImage: "safari")
}
.help("Spotify often blocks in-app logins. Sign in via your normal browser, then paste sp_dc in Settings.")
Button("Reset Session") {
NotificationCenter.default.post(name: .spotifyLoginSheetReset, object: nil)
statusText = "Session cleared. Sign in again to capture the cookie."
}
Button("Cancel") { dismiss() }
.keyboardShortcut(.cancelAction)
}
.padding(12)
Divider()
SpotifyLoginWebView { value in
guard !didCapture else { return }
didCapture = true
statusText = "Captured sp_dc cookie. Validating…"
onCapture(value)
dismiss()
} onStatus: { message in
statusText = message
}
}
.frame(minWidth: 520, minHeight: 640)
}
}
extension Notification.Name {
static let spotifyLoginSheetReset = Notification.Name("SpotifyLoginSheetReset")
}
struct SpotifyLoginWebView: NSViewRepresentable {
let onCapture: (String) -> Void
let onStatus: (String) -> Void
func makeCoordinator() -> Coordinator {
Coordinator(onCapture: onCapture, onStatus: onStatus)
}
func makeNSView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .nonPersistent()
configuration.preferences.javaScriptCanOpenWindowsAutomatically = false
let webView = FirstClickWebView(frame: .zero, configuration: configuration)
webView.customUserAgent = SpotifyLoginConstants.userAgent
webView.navigationDelegate = context.coordinator
webView.uiDelegate = context.coordinator
webView.allowsBackForwardNavigationGestures = true
context.coordinator.attach(webView: webView)
webView.load(URLRequest(url: SpotifyLoginConstants.loginURL))
DispatchQueue.main.async { [weak webView] in
webView?.window?.makeFirstResponder(webView)
}
return webView
}
func updateNSView(_ nsView: WKWebView, context: Context) {}
static func dismantleNSView(_ nsView: WKWebView, coordinator: Coordinator) {
coordinator.detach()
}
private final class FirstClickWebView: WKWebView {
override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
}
@MainActor
final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKHTTPCookieStoreObserver {
private let onCapture: (String) -> Void
private let onStatus: (String) -> Void
private weak var webView: WKWebView?
private var resetObserver: NSObjectProtocol?
init(onCapture: @escaping (String) -> Void, onStatus: @escaping (String) -> Void) {
self.onCapture = onCapture
self.onStatus = onStatus
super.init()
resetObserver = NotificationCenter.default.addObserver(
forName: .spotifyLoginSheetReset,
object: nil,
queue: .main
) { [weak self] _ in
Task { @MainActor in self?.resetSession() }
}
}
deinit {
if let resetObserver {
NotificationCenter.default.removeObserver(resetObserver)
}
}
func attach(webView: WKWebView) {
self.webView = webView
webView.configuration.websiteDataStore.httpCookieStore.add(self)
}
func detach() {
webView?.configuration.websiteDataStore.httpCookieStore.remove(self)
webView = nil
}
private func resetSession() {
guard let webView else { return }
let store = webView.configuration.websiteDataStore
let types = WKWebsiteDataStore.allWebsiteDataTypes()
store.removeData(ofTypes: types, modifiedSince: .distantPast) { [weak self] in
Task { @MainActor in
self?.webView?.load(URLRequest(url: SpotifyLoginConstants.loginURL))
}
}
}
func cookiesDidChange(in cookieStore: WKHTTPCookieStore) {
cookieStore.getAllCookies { [weak self] cookies in
Task { @MainActor in
self?.handle(cookies: cookies)
}
}
}
// MARK: - WKNavigationDelegate
func webView(
_ webView: WKWebView,
decidePolicyFor navigationAction: WKNavigationAction,
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
) {
if let url = navigationAction.request.url, Self.isBlockedOAuthHost(url.host) {
decisionHandler(.cancel)
spotifyLoginLogger.debug(
"Blocked embedded OAuth navigation to host=\(url.host ?? "", privacy: .public)"
)
onStatus(
"Google blocks in-app sign-in. Use email & password, Apple, or tap “Open in Browser” to sign in there and paste sp_dc in Settings."
)
webView.load(URLRequest(url: SpotifyLoginConstants.googleSignInRedirectURL))
return
}
decisionHandler(.allow)
}
private static func isBlockedOAuthHost(_ host: String?) -> Bool {
guard let host else { return false }
return SpotifyLoginConstants.blockedOAuthHostSuffixes.contains { host.hasSuffix($0) }
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
let host = webView.url?.host ?? ""
spotifyLoginLogger.debug("didFinish host=\(host, privacy: .public)")
if host.contains("accounts.spotify.com") {
onStatus("Sign in to your Spotify account.")
} else if host.contains("open.spotify.com") {
onStatus("Open Spotify shouldn't load here — capture should fire before this. Falling back.")
} else if host.contains("www.spotify.com") || host.contains("spotify.com") {
onStatus("Logged in. Finishing capture…")
}
webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { [weak self] cookies in
let names = cookies
.filter { $0.domain.hasSuffix("spotify.com") }
.map { "\($0.name)@\($0.domain)" }
.joined(separator: ", ")
spotifyLoginLogger.debug("cookies after load: \(names, privacy: .public)")
Task { @MainActor in
self?.handle(cookies: cookies)
}
}
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
onStatus("Load failed: \(error.localizedDescription)")
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
onStatus("Load failed: \(error.localizedDescription)")
}
// Allow pop-ups (e.g. SSO providers) to load inline in the same webview.
func webView(
_ webView: WKWebView,
createWebViewWith configuration: WKWebViewConfiguration,
for navigationAction: WKNavigationAction,
windowFeatures: WKWindowFeatures
) -> WKWebView? {
if navigationAction.targetFrame == nil, let url = navigationAction.request.url {
if Self.isBlockedOAuthHost(url.host) {
onStatus(
"Google blocks in-app sign-in. Use email & password, Apple, or tap “Open in Browser” to sign in there and paste sp_dc in Settings."
)
return nil
}
webView.load(URLRequest(url: url))
}
return nil
}
// MARK: - Capture
private func handle(cookies: [HTTPCookie]) {
let match = cookies.first { cookie in
cookie.name == SpotifyLoginConstants.cookieName
&& cookie.domain.hasSuffix(SpotifyLoginConstants.cookieDomainSuffix)
}
guard let match, !match.value.isEmpty else { return }
onCapture(match.value)
}
}
}

View file

@ -0,0 +1,56 @@
/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import SwiftUI
/// Standalone inline HUD shown in the closed Dynamic Island when the
/// File Tray (Shelf) has items and no higher-priority live activity is active.
///
/// Layout mirrors the battery inline HUD:
/// [tray icon] ─── [ notch ] ─── [bold green count]
struct ShelfInlineLiveActivity: View {
@EnvironmentObject var vm: DynamicIslandViewModel
@ObservedObject private var shelfState = ShelfStateViewModel.shared
private let sideWidth: CGFloat = 24
var body: some View {
HStack(spacing: 0) {
// LEFT of notch — tray icon (white, same style as the screenshot)
Image(systemName: "tray.fill")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.white)
.frame(width: sideWidth, alignment: .leading)
// Physical notch / pill space
Rectangle()
.fill(Color.black)
.frame(width: vm.closedNotchSize.width)
// RIGHT of notch — file count in white
Text("\(shelfState.items.count)")
.font(.system(.callout, design: .rounded, weight: .bold))
.foregroundStyle(.white)
.contentTransition(.numericText(countsDown: false))
.animation(.smooth(duration: 0.3), value: shelfState.items.count)
.frame(width: sideWidth, alignment: .trailing)
}
.frame(height: vm.effectiveClosedNotchHeight, alignment: .center)
}
}

View file

@ -0,0 +1,82 @@
/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Foundation
import ObjectiveC.runtime
final class CoreBrightnessDisplayClient {
static let shared = CoreBrightnessDisplayClient()
private static let builtInDisplayID: UInt64 = 0
private var clientInstance: NSObject?
private let getSelector = NSSelectorFromString("brightnessForDisplay:")
private let setSelector = NSSelectorFromString("setBrightness:forDisplay:")
private let notificationSelector = NSSelectorFromString("setNotificationBlock:")
var onBrightnessChange: ((Float) -> Void)?
private var available = false
private init() {
var loaded = false
let bundlePaths = [
"/System/Library/PrivateFrameworks/CoreBrightness.framework",
"/System/Library/PrivateFrameworks/CoreBrightness.framework/CoreBrightness"
]
for path in bundlePaths where !loaded {
if let bundle = Bundle(path: path) {
loaded = bundle.load()
}
}
guard loaded, let cls = NSClassFromString("DisplayBrightnessClient") as? NSObject.Type else {
NSLog("⚠️ CoreBrightnessDisplayClient: DisplayBrightnessClient class not found")
return
}
clientInstance = cls.init()
available = clientInstance != nil
}
var isAvailable: Bool { available }
func currentBrightness() -> Float? {
guard let clientInstance,
let getter: BrightnessGetter = methodIMP(on: clientInstance, selector: getSelector, as: BrightnessGetter.self)
else { return nil }
let value = getter(clientInstance, getSelector, Self.builtInDisplayID)
guard value >= 0, value <= 1 else { return nil }
return value
}
func setBrightness(_ value: Float) -> Bool {
guard let clientInstance,
let setter: BrightnessSetter = methodIMP(on: clientInstance, selector: setSelector, as: BrightnessSetter.self)
else { return false }
return setter(clientInstance, setSelector, value, Self.builtInDisplayID).boolValue
}
private typealias BrightnessGetter = @convention(c) (NSObject, Selector, UInt64) -> Float
private typealias BrightnessSetter = @convention(c) (NSObject, Selector, Float, UInt64) -> ObjCBool
private func methodIMP<T>(on object: NSObject, selector: Selector, as type: T.Type) -> T? {
guard let cls = object_getClass(object),
let method = class_getInstanceMethod(cls, selector)
else { return nil }
let imp = method_getImplementation(method)
return unsafeBitCast(imp, to: T.self)
}
}

View file

@ -0,0 +1,149 @@
/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Foundation
import MusicKit
actor AnimatedArtworkManager {
static let shared = AnimatedArtworkManager()
private var cachedSongID: String?
private var cachedVideoURL: URL?
private var cachedKey: String?
func fetchAnimatedArtworkURL(title: String, artist: String) async -> URL? {
let key = "\(title)|\(artist)"
if key == cachedKey, let url = cachedVideoURL {
return url
}
guard await requestMusicAuthorization() else { return nil }
guard let songID = await searchSongID(title: title, artist: artist) else {
return nil
}
guard let videoURL = await fetchEditorialVideoURL(songID: songID) else {
return nil
}
cachedKey = key
cachedSongID = songID
cachedVideoURL = videoURL
return videoURL
}
func clearCache() {
cachedKey = nil
cachedSongID = nil
cachedVideoURL = nil
}
// MARK: - MusicKit Authorization
private func requestMusicAuthorization() async -> Bool {
let status = MusicAuthorization.currentStatus
if status == .authorized { return true }
let newStatus = await MusicAuthorization.request()
return newStatus == .authorized
}
// MARK: - Song Search
private func searchSongID(title: String, artist: String) async -> String? {
let term = "\(title) \(artist)"
var request = MusicCatalogSearchRequest(term: term, types: [Song.self])
request.limit = 5
do {
let response = try await request.response()
let normalizedTitle = title.lowercased()
let normalizedArtist = artist.lowercased()
let match = response.songs.first(where: {
$0.title.lowercased() == normalizedTitle &&
$0.artistName.lowercased() == normalizedArtist
}) ?? response.songs.first(where: {
$0.title.lowercased() == normalizedTitle
}) ?? response.songs.first
guard let song = match else { return nil }
return song.id.rawValue
} catch {
print("[AnimatedArtworkManager] Search failed: \(error)")
return nil
}
}
// MARK: - Editorial Video Fetch
private func fetchEditorialVideoURL(songID: String) async -> URL? {
let storefront: String
if let code = try? await MusicDataRequest.currentCountryCode {
storefront = code
} else {
storefront = "us"
}
return await fetchEditorialVideoURLFallback(songID: songID, storefront: storefront)
}
private func fetchEditorialVideoURLFallback(songID: String, storefront: String) async -> URL? {
guard let url = URL(string: "https://api.music.apple.com/v1/catalog/\(storefront)/songs/\(songID)?extend=editorialVideo") else {
return nil
}
do {
let request = MusicDataRequest(urlRequest: URLRequest(url: url))
let response = try await request.response()
guard let json = try JSONSerialization.jsonObject(with: response.data) as? [String: Any],
let data = json["data"] as? [[String: Any]],
let first = data.first,
let attributes = first["attributes"] as? [String: Any],
let editorialVideo = attributes["editorialVideo"] as? [String: Any]
else { return nil }
if let motionTall = editorialVideo["motionTallVideo3x4"] as? [String: Any],
let videoURLString = motionTall["video"] as? String,
let videoURL = URL(string: videoURLString) {
return videoURL
}
if let motionSquare = editorialVideo["motionSquareVideo1x1"] as? [String: Any],
let videoURLString = motionSquare["video"] as? String,
let videoURL = URL(string: videoURLString) {
return videoURL
}
let hlsPattern = try? NSRegularExpression(pattern: "https://mvod\\.itunes\\.apple\\.com[^\"]+\\.m3u8")
let jsonString = String(data: response.data, encoding: .utf8) ?? ""
let range = NSRange(jsonString.startIndex..., in: jsonString)
if let match = hlsPattern?.firstMatch(in: jsonString, range: range),
let matchRange = Range(match.range, in: jsonString) {
return URL(string: String(jsonString[matchRange]))
}
return nil
} catch {
print("[AnimatedArtworkManager] Editorial video fetch failed: \(error)")
return nil
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -48,6 +48,7 @@ class LockScreenManager: ObservableObject {
private var debounceIdleTask: Task<Void, Never>? private var debounceIdleTask: Task<Void, Never>?
private var collapseTask: Task<Void, Never>? private var collapseTask: Task<Void, Never>?
private var postUnlockMusicHUDTask: Task<Void, Never>? private var postUnlockMusicHUDTask: Task<Void, Never>?
private var lockStatePollTask: Task<Void, Never>?
// MARK: - Helpers // MARK: - Helpers
@ -65,9 +66,11 @@ class LockScreenManager: ObservableObject {
deinit { deinit {
DistributedNotificationCenter.default().removeObserver(self) DistributedNotificationCenter.default().removeObserver(self)
NSWorkspace.shared.notificationCenter.removeObserver(self)
debounceIdleTask?.cancel() debounceIdleTask?.cancel()
collapseTask?.cancel() collapseTask?.cancel()
postUnlockMusicHUDTask?.cancel() postUnlockMusicHUDTask?.cancel()
lockStatePollTask?.cancel()
} }
// MARK: - Setup // MARK: - Setup
@ -89,6 +92,17 @@ class LockScreenManager: ObservableObject {
object: nil object: nil
) )
// Fallback: macOS sometimes delays `com.apple.screenIsUnlocked`, leaving
// lock-screen widgets visible after the user-perceived unlock.
// The workspace session-active notification typically fires earlier; the
// guard at the top of `screenUnlocked` makes the call idempotent.
NSWorkspace.shared.notificationCenter.addObserver(
self,
selector: #selector(screenUnlocked),
name: NSWorkspace.sessionDidBecomeActiveNotification,
object: nil
)
print("LockScreenManager: ✅ Observers registered for lock/unlock events") print("LockScreenManager: ✅ Observers registered for lock/unlock events")
} }
@ -141,6 +155,8 @@ class LockScreenManager: ObservableObject {
print("[\(timestamp())] LockScreenManager: ⏭️ Lock icon disabled in settings") print("[\(timestamp())] LockScreenManager: ⏭️ Lock icon disabled in settings")
} }
startLockStatePolling()
print("[\(timestamp())] LockScreenManager: ✅ Lock screen activated") print("[\(timestamp())] LockScreenManager: ✅ Lock screen activated")
} }
@ -156,6 +172,7 @@ class LockScreenManager: ObservableObject {
lastUpdated = Date() lastUpdated = Date()
updateIdleState(locked: false) updateIdleState(locked: false)
isLocked = false isLocked = false
stopLockStatePolling()
postUnlockMusicHUDTask?.cancel() postUnlockMusicHUDTask?.cancel()
shouldDelayPostUnlockMusicHUD = Defaults[.enableLockScreenLiveActivity] shouldDelayPostUnlockMusicHUD = Defaults[.enableLockScreenLiveActivity]
@ -187,6 +204,7 @@ class LockScreenManager: ObservableObject {
// Hide panel window immediately and synchronously // Hide panel window immediately and synchronously
print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window") print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window")
LockScreenPanelManager.shared.hidePanel() LockScreenPanelManager.shared.hidePanel()
FullScreenArtworkWindowManager.shared.hide()
LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide() LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide()
LockScreenWeatherManager.shared.hideWeatherWidget() LockScreenWeatherManager.shared.hideWeatherWidget()
LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: false) LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: false)
@ -206,6 +224,44 @@ class LockScreenManager: ObservableObject {
print("[\(self.timestamp())] LockScreenManager: ✅ Lock screen deactivated") print("[\(self.timestamp())] LockScreenManager: ✅ Lock screen deactivated")
} }
// MARK: - Lock State Polling
// Defensive fallback against late/missed `com.apple.screenIsUnlocked` and
// `NSWorkspace.sessionDidBecomeActiveNotification` notifications, which
// macOS sometimes delivers well after the user-perceived unlock — leaving
// lock-screen widgets visible for an extra moment. While we believe we are
// locked, poll the canonical session-lock state and fire `screenUnlocked()`
// the moment the OS flips. The handler's duplicate-event guard makes this
// safe to call alongside any later-arriving notification.
private static func isSessionScreenLocked() -> Bool {
guard let session = CGSessionCopyCurrentDictionary() as? [String: Any] else {
return false
}
return session["CGSSessionScreenIsLocked"] as? Bool ?? false
}
private func startLockStatePolling() {
lockStatePollTask?.cancel()
lockStatePollTask = Task { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(500))
if Task.isCancelled { return }
await MainActor.run {
guard let self, self.isLocked else { return }
if !Self.isSessionScreenLocked() {
print("[\(self.timestamp())] LockScreenManager: 🔓 Polling detected unlock ahead of notification")
self.screenUnlocked()
}
}
}
}
}
private func stopLockStatePolling() {
lockStatePollTask?.cancel()
lockStatePollTask = nil
}
// MARK: - Idle State Management // MARK: - Idle State Management
/// Copy EXACT logic from ScreenRecordingManager /// Copy EXACT logic from ScreenRecordingManager

View file

@ -58,13 +58,30 @@ class LockScreenPanelManager {
return formatter.string(from: Date()) return formatter.string(from: Date())
} }
private func publishPanelFrame(_ frame: NSRect?) {
latestFrame = frame
var userInfo: [AnyHashable: Any] = [:]
if let frame {
userInfo["frame"] = NSValue(rect: frame)
}
NotificationCenter.default.post(
name: .atollLockScreenPanelFrameDidChange,
object: self,
userInfo: userInfo
)
}
private func registerScreenChangeObservers() { private func registerScreenChangeObservers() {
screenChangeObserver = NotificationCenter.default.addObserver( screenChangeObserver = NotificationCenter.default.addObserver(
forName: NSApplication.didChangeScreenParametersNotification, forName: NSApplication.didChangeScreenParametersNotification,
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
self?.handleScreenGeometryChange(reason: "screen-parameters") Task { @MainActor [weak self] in
self?.handleScreenGeometryChange(reason: "screen-parameters")
}
} }
let workspaceCenter = NSWorkspace.shared.notificationCenter let workspaceCenter = NSWorkspace.shared.notificationCenter
@ -73,7 +90,9 @@ class LockScreenPanelManager {
object: nil, object: nil,
queue: .main queue: .main
) { [weak self] _ in ) { [weak self] _ in
self?.handleScreenGeometryChange(reason: "screens-did-wake") Task { @MainActor [weak self] in
self?.handleScreenGeometryChange(reason: "screens-did-wake")
}
} }
workspaceObservers = [wakeObserver] workspaceObservers = [wakeObserver]
@ -94,8 +113,9 @@ class LockScreenPanelManager {
} }
let screenFrame = screen.frame let screenFrame = screen.frame
let targetFrame = collapsedFrame(for: screenFrame) let defaultCollapsedFrame = collapsedFrame(for: screenFrame)
collapsedFrame = targetFrame let targetFrame = targetFrame(for: screenFrame, panelSize: LockScreenMusicPanel.collapsedSize)
collapsedFrame = defaultCollapsedFrame
isPanelExpanded = false isPanelExpanded = false
currentAdditionalHeight = 0 currentAdditionalHeight = 0
@ -127,14 +147,14 @@ class LockScreenPanelManager {
} }
window.setFrame(targetFrame, display: true) window.setFrame(targetFrame, display: true)
latestFrame = targetFrame publishPanelFrame(targetFrame)
hideTask?.cancel() hideTask?.cancel()
panelAnimator.isPresented = false panelAnimator.isPresented = false
LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: false) LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: false)
let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator)) let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator))
hosting.frame = NSRect(origin: .zero, size: targetFrame.size) hosting.frame = NSRect(origin: .zero, size: targetFrame.size)
hosting.autoresizingMask = [.width, .height] hosting.autoresizingMask = [.width, .height]
window.contentView = hosting window.contentView = hosting
// Ensure the underlying window content is clipped to rounded corners // Ensure the underlying window content is clipped to rounded corners
@ -162,18 +182,17 @@ class LockScreenPanelManager {
} }
func updatePanelSize(expanded: Bool, additionalHeight: CGFloat = 0, animated: Bool = true) { func updatePanelSize(expanded: Bool, additionalHeight: CGFloat = 0, animated: Bool = true) {
guard let window = panelWindow, let baseFrame = collapsedFrame else { guard let window = panelWindow, let screen = currentScreen() else {
return return
} }
let resizeDuration: CFTimeInterval = 0.28 let resizeDuration: CFTimeInterval = 0.28
let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize
let targetWidth = baseSize.width let targetFrame = targetFrame(
let targetHeight = baseSize.height + additionalHeight for: screen.frame,
let originX = baseFrame.midX - (targetWidth / 2) panelSize: CGSize(width: baseSize.width, height: baseSize.height + additionalHeight)
let originY = baseFrame.origin.y )
let targetFrame = NSRect(x: originX, y: originY, width: targetWidth, height: targetHeight)
if animated { if animated {
NSAnimationContext.runAnimationGroup { context in NSAnimationContext.runAnimationGroup { context in
@ -185,7 +204,7 @@ class LockScreenPanelManager {
window.setFrame(targetFrame, display: true) window.setFrame(targetFrame, display: true)
} }
latestFrame = targetFrame publishPanelFrame(targetFrame)
LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: animated) LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: animated)
@ -229,7 +248,7 @@ class LockScreenPanelManager {
guard let window = panelWindow else { guard let window = panelWindow else {
print("LockScreenPanelManager: no panel to hide") print("LockScreenPanelManager: no panel to hide")
latestFrame = nil publishPanelFrame(nil)
return return
} }
@ -239,7 +258,7 @@ class LockScreenPanelManager {
await MainActor.run { await MainActor.run {
window?.orderOut(nil) window?.orderOut(nil)
window?.contentView = nil window?.contentView = nil
self.latestFrame = nil self.publishPanelFrame(nil)
print("[\(self.timestamp())] LockScreenPanelManager: panel hidden") print("[\(self.timestamp())] LockScreenPanelManager: panel hidden")
} }
} }
@ -267,6 +286,74 @@ class LockScreenPanelManager {
.store(in: &cancellables) .store(in: &cancellables)
} }
private func targetFrame(for screenFrame: NSRect, panelSize: CGSize) -> NSRect {
let defaultCollapsedFrame = collapsedFrame(for: screenFrame)
if FullScreenArtworkWindowManager.shared.isShowingSpotifyCanvasFallback {
return spotifyCanvasFallbackPanelFrame(
screenFrame: screenFrame,
defaultCollapsedFrame: defaultCollapsedFrame,
panelSize: panelSize
)
}
let originX = defaultCollapsedFrame.midX - (panelSize.width / 2)
return NSRect(
x: originX,
y: defaultCollapsedFrame.origin.y,
width: panelSize.width,
height: panelSize.height
)
}
private func spotifyCanvasFallbackPanelFrame(
screenFrame: NSRect,
defaultCollapsedFrame: NSRect,
panelSize: CGSize
) -> NSRect {
let fullscreenArtworkManager = FullScreenArtworkWindowManager.shared
let spacing = fullscreenArtworkManager.spotifyCanvasFallbackInterItemSpacing(screenFrame: screenFrame)
let artworkSide = fullscreenArtworkManager.spotifyCanvasFallbackArtworkSideLength(
screenFrame: screenFrame,
panelSize: panelSize
)
let totalWidth = artworkSide + spacing + panelSize.width
let horizontalMargin: CGFloat = 48
let minGroupOriginX = screenFrame.minX + horizontalMargin
let maxGroupOriginX = max(screenFrame.maxX - horizontalMargin - totalWidth, minGroupOriginX)
let groupOriginX = min(
max(screenFrame.midX - (totalWidth / 2), minGroupOriginX),
maxGroupOriginX
)
let baseCenterY = screenFrame.midY
let largestHalfHeight = max(artworkSide, panelSize.height) / 2
let minCenterY = screenFrame.minY + largestHalfHeight + 56
var maxCenterY = screenFrame.maxY - largestHalfHeight - 84
let widgetClearance: CGFloat = 20
if let weatherFrame = LockScreenWeatherPanelManager.shared.latestFrame,
!weatherFrame.isEmpty {
let weatherTopConstrainedCenterY = weatherFrame.minY - widgetClearance - largestHalfHeight
maxCenterY = min(maxCenterY, weatherTopConstrainedCenterY)
}
if let timerFrame = LockScreenTimerWidgetPanelManager.shared.latestFrame,
!timerFrame.isEmpty {
let timerTopConstrainedCenterY = timerFrame.minY - widgetClearance - largestHalfHeight
maxCenterY = min(maxCenterY, timerTopConstrainedCenterY)
}
let resolvedMaxCenterY = max(maxCenterY, minCenterY)
let centerY = min(max(baseCenterY, minCenterY), resolvedMaxCenterY)
return NSRect(
x: groupOriginX + artworkSide + spacing,
y: centerY - (panelSize.height / 2),
width: panelSize.width,
height: panelSize.height
)
}
private func collapsedFrame(for screenFrame: NSRect) -> NSRect { private func collapsedFrame(for screenFrame: NSRect) -> NSRect {
let collapsedSize = LockScreenMusicPanel.collapsedSize let collapsedSize = LockScreenMusicPanel.collapsedSize
let originX = screenFrame.midX - (collapsedSize.width / 2) let originX = screenFrame.midX - (collapsedSize.width / 2)
@ -289,3 +376,7 @@ class LockScreenPanelManager {
LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main
} }
} }
extension Notification.Name {
static let atollLockScreenPanelFrameDidChange = Notification.Name("atollLockScreenPanelFrameDidChange")
}

View file

@ -279,6 +279,11 @@ final class LockScreenTimerWidgetPanelManager {
originY = min(originY, topLimit) originY = min(originY, topLimit)
} }
if let musicFrame = LockScreenPanelManager.shared.latestFrame {
let minAllowedY = musicFrame.maxY + 12
originY = max(originY, minAllowedY)
}
let minY = screen.frame.minY + 100 let minY = screen.frame.minY + 100
originY = max(originY, minY) originY = max(originY, minY)

View file

@ -49,8 +49,10 @@ final class LockScreenWeatherPanelManager {
func hide() { func hide() {
guard let window else { return } guard let window else { return }
window.alphaValue = 0
window.orderOut(nil) window.orderOut(nil)
window.contentView = nil window.contentView = nil
window.alphaValue = 1
latestFrame = nil latestFrame = nil
lastSnapshot = nil lastSnapshot = nil
lastContentSize = nil lastContentSize = nil

View file

@ -24,7 +24,11 @@ import Darwin
final class MemoryUsageMonitor { final class MemoryUsageMonitor {
static let shared = MemoryUsageMonitor() static let shared = MemoryUsageMonitor()
#if DEBUG
private let thresholdBytes: UInt64 = 4 * 1_024 * 1_024 * 1_024
#else
private let thresholdBytes: UInt64 = 1_024 * 1_024 * 1_024 private let thresholdBytes: UInt64 = 1_024 * 1_024 * 1_024
#endif
private let pollInterval: TimeInterval = 8 // Clamp within 5-10 seconds to limit battery impact private let pollInterval: TimeInterval = 8 // Clamp within 5-10 seconds to limit battery impact
private let restartCooldown: TimeInterval = 300 private let restartCooldown: TimeInterval = 300
private let logSampleInterval: TimeInterval = 300 private let logSampleInterval: TimeInterval = 300

View file

@ -38,6 +38,16 @@ struct LyricLine: Identifiable, Codable {
} }
} }
private struct LyricsLookupKey: Hashable {
let title: String
let artist: String
let album: String
var isValid: Bool {
!title.isEmpty && !artist.isEmpty
}
}
let defaultImage: NSImage = .init( let defaultImage: NSImage = .init(
systemSymbolName: "heart.fill", systemSymbolName: "heart.fill",
accessibilityDescription: "Album Art" accessibilityDescription: "Album Art"
@ -475,10 +485,16 @@ class MusicManager: ObservableObject {
// Task used to periodically sync displayed lyric with playback position // Task used to periodically sync displayed lyric with playback position
private var lyricSyncTask: Task<Void, Never>? private var lyricSyncTask: Task<Void, Never>?
private var lyricsFetchTask: Task<Void, Never>?
private var lyricsFetchKey: LyricsLookupKey?
private var activeLyricsKey: LyricsLookupKey?
private var lyricsCache: [LyricsLookupKey: [LyricLine]] = [:]
private var explicitLookupTask: Task<Void, Never>? private var explicitLookupTask: Task<Void, Never>?
private var explicitLookupKey: String? private var explicitLookupKey: String?
private var artworkData: Data? = nil private(set) var artworkData: Data? = nil
@Published var videoArtworkURL: URL? = nil
private var liveStreamUnknownDurationCount: Int = 0 private var liveStreamUnknownDurationCount: Int = 0
private var liveStreamEdgeObservationCount: Int = 0 private var liveStreamEdgeObservationCount: Int = 0
@ -512,6 +528,13 @@ class MusicManager: ObservableObject {
} }
.store(in: &cancellables) .store(in: &cancellables)
Defaults.publisher(.enableLyrics)
.receive(on: RunLoop.main)
.sink { [weak self] change in
self?.handleLyricsPreferenceChange(isEnabled: change.newValue)
}
.store(in: &cancellables)
// Observe Pear Desktop launch/terminate for auto-detection // Observe Pear Desktop launch/terminate for auto-detection
setupPearDesktopAutoDetection() setupPearDesktopAutoDetection()
@ -580,6 +603,8 @@ class MusicManager: ObservableObject {
public func destroy() { public func destroy() {
debounceIdleTask?.cancel() debounceIdleTask?.cancel()
lyricsFetchTask?.cancel()
lyricSyncTask?.cancel()
explicitLookupTask?.cancel() explicitLookupTask?.cancel()
cancellables.removeAll() cancellables.removeAll()
controllerCancellables.removeAll() controllerCancellables.removeAll()
@ -701,6 +726,7 @@ class MusicManager: ObservableObject {
// Check for artwork changes // Check for artwork changes
let artworkChanged = state.artwork != nil && state.artwork != self.artworkData let artworkChanged = state.artwork != nil && state.artwork != self.artworkData
let hasContentChange = let hasContentChange =
titleChanged titleChanged
|| artistChanged || artistChanged
@ -709,6 +735,11 @@ class MusicManager: ObservableObject {
|| bundleChanged || bundleChanged
|| contentIdentifierChanged || contentIdentifierChanged
|| contentURLChanged || contentURLChanged
let liveArtworkChanged = state.liveArtworkURL != self.videoArtworkURL
if liveArtworkChanged {
self.videoArtworkURL = state.liveArtworkURL
}
// Handle artwork and visual transitions for changed content // Handle artwork and visual transitions for changed content
let shouldAutoPeekOnTrackChange = Defaults[.showSneakPeekOnTrackChange] let shouldAutoPeekOnTrackChange = Defaults[.showSneakPeekOnTrackChange]
@ -735,10 +766,16 @@ class MusicManager: ObservableObject {
self.lastArtworkContentIdentifier = state.contentIdentifier self.lastArtworkContentIdentifier = state.contentIdentifier
self.lastArtworkContentURL = state.contentURL self.lastArtworkContentURL = state.contentURL
// Fetch lyrics for new track whenever content changes self.prepareLyricsForCurrentTrack()
self.fetchLyrics() if let liveArtworkURL = state.liveArtworkURL {
self.videoArtworkURL = liveArtworkURL
} else {
self.fetchVideoArtwork()
}
self.refreshExplicitFlag(for: state) self.refreshExplicitFlag(for: state)
// Only update sneak peek if there's actual content and something changed // Only update sneak peek if there's actual content and something changed
if shouldAutoPeekOnTrackChange && !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying { if shouldAutoPeekOnTrackChange && !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying {
self.updateSneakPeek() self.updateSneakPeek()
@ -1238,53 +1275,108 @@ class MusicManager: ObservableObject {
// MARK: - Lyrics Methods // MARK: - Lyrics Methods
func fetchLyrics() { func fetchLyrics() {
guard Defaults[.enableLyrics] else { return } prepareLyricsForCurrentTrack(forceFetch: true, prioritizeVisibleResult: Defaults[.enableLyrics])
// If the lyrics panel is visible already, provide immediate feedback }
if showLyrics {
Task { @MainActor in private func handleLyricsPreferenceChange(isEnabled: Bool) {
self.currentLyrics = "Loading lyrics..." showLyrics = isEnabled
self.syncedLyrics = []
self.currentLyricIndex = -1 if isEnabled {
} prepareLyricsForCurrentTrack(prioritizeVisibleResult: true)
} else {
stopLyricSync()
}
}
private func prepareLyricsForCurrentTrack(forceFetch: Bool = false, prioritizeVisibleResult: Bool = false) {
guard let lookup = currentLyricsLookupContext() else {
activeLyricsKey = nil
lyricsFetchKey = nil
lyricsFetchTask?.cancel()
lyricsFetchTask = nil
syncedLyrics = []
currentLyrics = ""
currentLyricIndex = -1
stopLyricSync()
return
} }
Task { let key = lookup.key
do { let lyricsEnabled = Defaults[.enableLyrics]
let lyrics = try await fetchLyricsFromAPI(artist: artistName, title: songTitle) let shouldShowLoading = lyricsEnabled && prioritizeVisibleResult
await MainActor.run { let trackChanged = activeLyricsKey != key
self.syncedLyrics = lyrics activeLyricsKey = key
self.currentLyricIndex = -1
if !lyrics.isEmpty {
self.currentLyrics = lyrics[0].text
} else {
self.currentLyrics = ""
}
// If lyrics are enabled, start syncing them to playback position if trackChanged {
if Defaults[.enableLyrics] && !self.syncedLyrics.isEmpty { syncedLyrics = []
self.startLyricSync() currentLyricIndex = -1
} else if self.syncedLyrics.isEmpty { currentLyrics = shouldShowLoading ? "Loading lyrics..." : ""
self.stopLyricSync() stopLyricSync()
} }
if !forceFetch, let cachedLyrics = lyricsCache[key] {
applyLyricsToDisplay(cachedLyrics)
return
}
if lyricsFetchKey == key {
if shouldShowLoading && syncedLyrics.isEmpty {
currentLyrics = "Loading lyrics..."
}
return
}
lyricsFetchTask?.cancel()
lyricsFetchKey = key
if shouldShowLoading || (lyricsEnabled && syncedLyrics.isEmpty) {
currentLyrics = "Loading lyrics..."
}
let requestArtist = lookup.requestArtist
let requestTitle = lookup.requestTitle
let requestAlbum = lookup.requestAlbum
lyricsFetchTask = Task { [weak self] in
guard let self else { return }
do {
let lyrics = try await self.fetchLyricsFromAPI(
artist: requestArtist,
title: requestTitle,
album: requestAlbum
)
guard !Task.isCancelled else { return }
await MainActor.run {
guard self.activeLyricsKey == key else { return }
self.lyricsCache[key] = lyrics
self.lyricsFetchKey = nil
self.lyricsFetchTask = nil
self.applyLyricsToDisplay(lyrics)
} }
} catch { } catch {
print("Failed to fetch lyrics: \(error)") print("Failed to fetch lyrics: \(error)")
await MainActor.run { await MainActor.run {
guard self.activeLyricsKey == key else { return }
self.lyricsFetchKey = nil
self.lyricsFetchTask = nil
self.syncedLyrics = [] self.syncedLyrics = []
self.currentLyrics = ""
self.currentLyricIndex = -1 self.currentLyricIndex = -1
self.currentLyrics = lyricsEnabled ? "No lyrics found" : ""
self.stopLyricSync() self.stopLyricSync()
} }
} }
} }
} }
private func fetchLyricsFromAPI(artist: String, title: String) async throws -> [LyricLine] { private func fetchLyricsFromAPI(artist: String, title: String, album: String) async throws -> [LyricLine] {
guard !artist.isEmpty, !title.isEmpty else { return [] } guard !artist.isEmpty, !title.isEmpty else { return [] }
// Normalize input and percent-encode // Normalize input and percent-encode
let cleanArtist = artist.folding(options: .diacriticInsensitive, locale: .current) let cleanArtist = artist.folding(options: .diacriticInsensitive, locale: .current)
let cleanTitle = title.folding(options: .diacriticInsensitive, locale: .current) let cleanTitle = title.folding(options: .diacriticInsensitive, locale: .current)
let cleanAlbum = album.folding(options: .diacriticInsensitive, locale: .current)
guard let encodedArtist = cleanArtist.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed), guard let encodedArtist = cleanArtist.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let encodedTitle = cleanTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { let encodedTitle = cleanTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
return [] return []
@ -1298,7 +1390,8 @@ class MusicManager: ObservableObject {
if let http = response as? HTTPURLResponse, http.statusCode == 200 { if let http = response as? HTTPURLResponse, http.statusCode == 200 {
// Try parse as array JSON (preferred) // Try parse as array JSON (preferred)
if let jsonArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], if let jsonArray = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
let first = jsonArray.first { let bestMatch = bestLyricsMatch(in: jsonArray, artist: cleanArtist, title: cleanTitle, album: cleanAlbum) {
let first = bestMatch
let plain = (first["plainLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let plain = (first["plainLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let synced = (first["syncedLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let synced = (first["syncedLyrics"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@ -1340,6 +1433,107 @@ class MusicManager: ObservableObject {
} }
} }
private func currentLyricsLookupContext() -> (key: LyricsLookupKey, requestArtist: String, requestTitle: String, requestAlbum: String)? {
let requestArtist = normalizedLyricsRequestComponent(artistName)
let requestTitle = normalizedLyricsTitle(songTitle)
let requestAlbum = normalizedLyricsRequestComponent(album)
let key = LyricsLookupKey(
title: requestTitle.lowercased(),
artist: requestArtist.lowercased(),
album: requestAlbum.lowercased()
)
return key.isValid ? (key, requestArtist, requestTitle, requestAlbum) : nil
}
private func normalizedLyricsRequestComponent(_ value: String) -> String {
value
.trimmingCharacters(in: .whitespacesAndNewlines)
.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
}
private func normalizedLyricsTitle(_ value: String) -> String {
var normalized = normalizedLyricsRequestComponent(value)
let cleanupPatterns = [
"\\s*\\((feat\\.?|ft\\.?|featuring)[^\\)]*\\)",
"\\s*\\[(feat\\.?|ft\\.?|featuring)[^\\]]*\\]",
"\\s*-\\s*(feat\\.?|ft\\.?|featuring)\\s+.*$",
"\\s*\\((remaster(ed)?|live|mono|stereo)[^\\)]*\\)$",
"\\s*\\[(remaster(ed)?|live|mono|stereo)[^\\]]*\\]$"
]
for pattern in cleanupPatterns {
normalized = normalized.replacingOccurrences(
of: pattern,
with: "",
options: [.regularExpression, .caseInsensitive]
)
}
return normalizedLyricsRequestComponent(normalized)
}
private func bestLyricsMatch(in results: [[String: Any]], artist: String, title: String, album: String) -> [String: Any]? {
let normalizedArtist = artist.lowercased()
let normalizedTitle = title.lowercased()
let normalizedAlbum = album.lowercased()
return results.max { lhs, rhs in
lyricsMatchScore(for: lhs, artist: normalizedArtist, title: normalizedTitle, album: normalizedAlbum)
< lyricsMatchScore(for: rhs, artist: normalizedArtist, title: normalizedTitle, album: normalizedAlbum)
}
}
private func lyricsMatchScore(for result: [String: Any], artist: String, title: String, album: String) -> Int {
let resultArtist = ((result["artistName"] as? String) ?? "").lowercased()
let resultTitle = ((result["trackName"] as? String) ?? "").lowercased()
let resultAlbum = ((result["albumName"] as? String) ?? "").lowercased()
var score = 0
if resultTitle == title { score += 8 }
else if resultTitle.contains(title) || title.contains(resultTitle) { score += 4 }
if resultArtist == artist { score += 8 }
else if resultArtist.contains(artist) || artist.contains(resultArtist) { score += 4 }
if !album.isEmpty {
if resultAlbum == album { score += 4 }
else if resultAlbum.contains(album) || album.contains(resultAlbum) { score += 2 }
}
if !(result["syncedLyrics"] as? String ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
score += 3
}
return score
}
private func applyLyricsToDisplay(_ lyrics: [LyricLine]) {
syncedLyrics = lyrics
currentLyricIndex = -1
guard !lyrics.isEmpty else {
currentLyrics = Defaults[.enableLyrics] ? "No lyrics found" : ""
stopLyricSync()
return
}
let playbackPosition = max(estimatedPlaybackPosition(), elapsedTime)
updateCurrentLyric(for: playbackPosition)
if currentLyricIndex == -1, let firstLine = lyrics.first?.text {
currentLyrics = firstLine
}
if Defaults[.enableLyrics] {
startLyricSync()
} else {
stopLyricSync()
}
}
private func parseLRC(_ lrc: String) -> [LyricLine] { private func parseLRC(_ lrc: String) -> [LyricLine] {
let lines = lrc.components(separatedBy: .newlines) let lines = lrc.components(separatedBy: .newlines)
var lyrics: [LyricLine] = [] var lyrics: [LyricLine] = []
@ -1424,6 +1618,32 @@ class MusicManager: ObservableObject {
lyricSyncTask = nil lyricSyncTask = nil
} }
// MARK: - Video Artwork
func fetchVideoArtwork() {
guard Defaults[.lockScreenMusicFullscreenVideoArtwork] else {
videoArtworkURL = nil
return
}
// Se il player non è Apple Music, non toccare videoArtworkURL:
// SpotifyController gestisce il canvas in modo autonomo tramite liveArtworkURL.
guard bundleIdentifier == "com.apple.Music" else {
return
}
let title = songTitle
let artist = artistName
Task {
let url = await AnimatedArtworkManager.shared.fetchAnimatedArtworkURL(
title: title, artist: artist
)
await MainActor.run {
self.videoArtworkURL = url
}
}
}
func toggleLyrics() { func toggleLyrics() {
// Toggle the UI state first so the views can react immediately. // Toggle the UI state first so the views can react immediately.
showLyrics.toggle() showLyrics.toggle()
@ -1518,7 +1738,6 @@ private struct AlbumArtFlipModifier: ViewModifier {
// Use a small tolerance to avoid flickering exactly at 90°/270°. // Use a small tolerance to avoid flickering exactly at 90°/270°.
if cos > 0.001 { return 1 } if cos > 0.001 { return 1 }
if cos < -0.001 { return -1 } if cos < -0.001 { return -1 }
// At the exact edge, prefer the side we're animating toward.
return degrees.truncatingRemainder(dividingBy: 360) >= 0 ? -1 : 1 return degrees.truncatingRemainder(dividingBy: 360) >= 0 ? -1 : 1
} }
} }

View file

@ -0,0 +1,343 @@
/*
* Atoll (DynamicIsland)
* Copyright (C) 2024-2026 Atoll Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import CryptoKit
import Defaults
import Foundation
extension Notification.Name {
static let spotifyCanvasSessionDidChange = Notification.Name("SpotifyCanvasSessionDidChange")
}
@MainActor
final class SpotifyAuthManager: ObservableObject {
static let shared = SpotifyAuthManager()
@Published private(set) var isAuthenticated = false
@Published private(set) var isAuthorizing = false
@Published private(set) var authErrorMessage: String?
@Published private(set) var sessionStatusText = "Spotify cookie not configured."
private let secretsURL = URL(string: "https://raw.githubusercontent.com/xyloflake/spot-secrets-go/main/secrets/secretDict.json")!
private let tokenURL = URL(string: "https://open.spotify.com/api/token")!
private let userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"
private init() {
restorePersistedState()
}
var configuredCookie: String {
Self.sanitizeCookie(Defaults[.spotifySPDCCookie])
}
var hasConfiguredCookie: Bool {
!configuredCookie.isEmpty
}
func validateSession() async {
let sanitizedCookie = configuredCookie
Defaults[.spotifySPDCCookie] = sanitizedCookie
clearCachedToken()
Defaults[.spotifyAuthLastValidatedAt] = 0
authErrorMessage = nil
restorePersistedState()
guard !sanitizedCookie.isEmpty else {
authErrorMessage = "Paste the Spotify sp_dc cookie first."
return
}
isAuthorizing = true
defer {
isAuthorizing = false
restorePersistedState()
}
guard await validAccessToken(forceRefresh: true) != nil else {
if authErrorMessage == nil {
authErrorMessage = "Spotify rejected the sp_dc cookie."
}
return
}
authErrorMessage = nil
NotificationCenter.default.post(name: .spotifyCanvasSessionDidChange, object: nil)
}
func clearSession() {
Defaults[.spotifySPDCCookie] = ""
clearCachedToken()
Defaults[.spotifyAuthLastValidatedAt] = 0
authErrorMessage = nil
restorePersistedState()
NotificationCenter.default.post(name: .spotifyCanvasSessionDidChange, object: nil)
}
func invalidateAccessToken() {
clearCachedToken()
restorePersistedState()
}
func validAccessToken(forceRefresh: Bool = false) async -> String? {
let sanitizedCookie = configuredCookie
if sanitizedCookie != Defaults[.spotifySPDCCookie] {
Defaults[.spotifySPDCCookie] = sanitizedCookie
}
guard !sanitizedCookie.isEmpty else {
restorePersistedState()
return nil
}
let cachedToken = Defaults[.spotifyAuthAccessToken]
let cachedExpiration = Defaults[.spotifyAuthAccessTokenExpiration]
if !forceRefresh, !cachedToken.isEmpty, cachedExpiration > Date().timeIntervalSince1970 + 60 {
restorePersistedState()
return cachedToken
}
do {
let response = try await fetchWebPlayerToken(spDC: sanitizedCookie)
Defaults[.spotifyAuthAccessToken] = response.accessToken
Defaults[.spotifyAuthAccessTokenExpiration] = response.expirationDate.timeIntervalSince1970
Defaults[.spotifyAuthLastValidatedAt] = Date().timeIntervalSince1970
authErrorMessage = nil
restorePersistedState()
return response.accessToken
} catch {
clearCachedToken()
Defaults[.spotifyAuthLastValidatedAt] = 0
authErrorMessage = error.localizedDescription
restorePersistedState()
return nil
}
}
private func fetchWebPlayerToken(spDC: String) async throws -> SpotifyWebPlayerTokenResponse {
let secrets = try await fetchSecrets()
guard let latestSecret = latestSecretEntry(from: secrets) else {
throw SpotifyAuthError.missingSecrets
}
let totp = try Self.generateTOTPCode(from: latestSecret.cipher)
var components = URLComponents(url: tokenURL, resolvingAgainstBaseURL: false)
components?.queryItems = [
URLQueryItem(name: "reason", value: "transport"),
URLQueryItem(name: "productType", value: "web-player"),
URLQueryItem(name: "totp", value: totp),
URLQueryItem(name: "totpServer", value: totp),
URLQueryItem(name: "totpVer", value: latestSecret.version)
]
guard let url = components?.url else {
throw SpotifyAuthError.invalidRequest
}
var request = URLRequest(url: url)
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue("https://open.spotify.com/", forHTTPHeaderField: "Referer")
request.setValue("WebPlayer", forHTTPHeaderField: "App-Platform")
request.setValue("sp_dc=\(spDC)", forHTTPHeaderField: "Cookie")
let (data, response) = try await URLSession.shared.data(for: request)
try Self.validateHTTPResponse(response, data: data)
let tokenResponse = try JSONDecoder().decode(SpotifyWebPlayerTokenResponse.self, from: data)
if tokenResponse.accessToken.isEmpty {
throw SpotifyAuthError.invalidTokenResponse
}
return tokenResponse
}
private func fetchSecrets() async throws -> [String: [Int]] {
var request = URLRequest(url: secretsURL)
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
let (data, response) = try await URLSession.shared.data(for: request)
try Self.validateHTTPResponse(response, data: data)
return try JSONDecoder().decode([String: [Int]].self, from: data)
}
private func latestSecretEntry(from secrets: [String: [Int]]) -> (version: String, cipher: [Int])? {
var latestVersion: String?
var latestCipher: [Int]?
var highestNumericVersion = Int.min
for (version, cipher) in secrets {
guard let numericVersion = Int(version) else { continue }
if numericVersion > highestNumericVersion {
highestNumericVersion = numericVersion
latestVersion = version
latestCipher = cipher
}
}
guard let latestVersion, let latestCipher else {
return nil
}
return (latestVersion, latestCipher)
}
private func clearCachedToken() {
Defaults[.spotifyAuthAccessToken] = ""
Defaults[.spotifyAuthAccessTokenExpiration] = 0
}
private func restorePersistedState() {
let cookie = configuredCookie
let hasToken = !Defaults[.spotifyAuthAccessToken].isEmpty
&& Defaults[.spotifyAuthAccessTokenExpiration] > Date().timeIntervalSince1970 + 60
let hasValidatedCookie = Defaults[.spotifyAuthLastValidatedAt] > 0
isAuthenticated = !cookie.isEmpty && (hasToken || hasValidatedCookie)
if cookie.isEmpty {
sessionStatusText = "Spotify cookie not configured."
} else if hasToken {
sessionStatusText = "Spotify Canvas session ready."
} else if hasValidatedCookie {
sessionStatusText = "Cookie saved. The access token will refresh automatically."
} else {
sessionStatusText = "Cookie pasted. Validate it once to enable Canvas lookup."
}
}
static func sanitizeCookie(_ rawValue: String) -> String {
var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
if let range = value.range(of: "sp_dc=") {
value = String(value[range.upperBound...])
}
if let firstSemicolon = value.firstIndex(of: ";") {
value = String(value[..<firstSemicolon])
}
value = value
.replacingOccurrences(of: "\"", with: "")
.trimmingCharacters(in: .whitespacesAndNewlines)
return value
}
private static func generateTOTPCode(from cipher: [Int]) throws -> String {
let transformed = cipher.enumerated().map { index, value in
value ^ ((index % 33) + 9)
}
let secretString = transformed.map(String.init).joined()
guard let secretData = secretString.data(using: .utf8) else {
throw SpotifyAuthError.invalidSecretMaterial
}
let counter = UInt64(Date().timeIntervalSince1970 / 30)
var counterBytes = counter.bigEndian
let counterData = Data(bytes: &counterBytes, count: MemoryLayout<UInt64>.size)
let mac = HMAC<Insecure.SHA1>.authenticationCode(
for: counterData,
using: SymmetricKey(data: secretData)
)
let hashData = Data(mac)
let offset = Int(hashData.last.map { $0 & 0x0F } ?? 0)
let truncatedRange = offset..<(offset + 4)
guard truncatedRange.upperBound <= hashData.count else {
throw SpotifyAuthError.invalidSecretMaterial
}
let truncatedBytes = Array(hashData[truncatedRange])
let truncated =
((UInt32(truncatedBytes[0]) << 24)
| (UInt32(truncatedBytes[1]) << 16)
| (UInt32(truncatedBytes[2]) << 8)
| UInt32(truncatedBytes[3]))
& 0x7FFF_FFFF
return String(format: "%06u", truncated % 1_000_000)
}
private static func validateHTTPResponse(_ response: URLResponse, data: Data) throws {
guard let httpResponse = response as? HTTPURLResponse else {
throw SpotifyAuthError.invalidServerResponse
}
guard (200..<300).contains(httpResponse.statusCode) else {
if let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let message = object["error"] as? String {
throw SpotifyAuthError.serverError(message)
}
if let body = String(data: data, encoding: .utf8), !body.isEmpty {
throw SpotifyAuthError.serverError(body)
}
throw SpotifyAuthError.httpFailure(httpResponse.statusCode)
}
}
}
private struct SpotifyWebPlayerTokenResponse: Decodable {
let accessToken: String
let accessTokenExpirationTimestampMs: Double?
var expirationDate: Date {
if let accessTokenExpirationTimestampMs {
return Date(timeIntervalSince1970: accessTokenExpirationTimestampMs / 1000)
}
return Date().addingTimeInterval(3300)
}
private enum CodingKeys: String, CodingKey {
case accessToken
case accessTokenExpirationTimestampMs
}
}
private enum SpotifyAuthError: LocalizedError {
case invalidRequest
case invalidServerResponse
case invalidTokenResponse
case invalidSecretMaterial
case missingSecrets
case httpFailure(Int)
case serverError(String)
var errorDescription: String? {
switch self {
case .invalidRequest:
return "Unable to build the Spotify token request."
case .invalidServerResponse:
return "Spotify returned an invalid response."
case .invalidTokenResponse:
return "Spotify did not return a usable access token."
case .invalidSecretMaterial:
return "The Spotify TOTP secret could not be decoded."
case .missingSecrets:
return "No valid Spotify TOTP secret was found."
case let .httpFailure(statusCode):
return "Spotify token request failed with HTTP \(statusCode)."
case let .serverError(message):
return message
}
}
}

View file

@ -38,9 +38,18 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
"headphones" "headphones"
] ]
private let standardVolumeStep: Float = 1.0 / 16.0 private var standardVolumeStep: Float {
private let standardBrightnessStep: Float = 1.0 / 16.0 Float(Defaults[.volumeStepPercent]) / 100.0
private let fineStepDivisor: Float = 4.0 }
private var fineVolumeStep: Float {
Float(Defaults[.volumeFineStepPercent]) / 100.0
}
private var standardBrightnessStep: Float {
Float(Defaults[.brightnessStepPercent]) / 100.0
}
private var fineBrightnessStep: Float {
Float(Defaults[.brightnessFineStepPercent]) / 100.0
}
private var volumeEnabled = false private var volumeEnabled = false
private var brightnessEnabled = false private var brightnessEnabled = false
@ -157,7 +166,7 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
} }
} }
let baseStep = stepSize(for: step, base: standardVolumeStep) let baseStep = volumeStep(for: step)
let delta = direction == .up ? baseStep : -baseStep let delta = direction == .up ? baseStep : -baseStep
volumeController.adjust(by: delta) volumeController.adjust(by: delta)
volumeFeedbackPlayer.playIfNeeded() volumeFeedbackPlayer.playIfNeeded()
@ -185,7 +194,7 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
} }
} }
let baseStep = stepSize(for: step, base: standardBrightnessStep) let baseStep = brightnessStep(for: step)
let delta = direction == .up ? baseStep : -baseStep let delta = direction == .up ? baseStep : -baseStep
if modifiers.contains(.command) && keyboardBacklightEnabled { if modifiers.contains(.command) && keyboardBacklightEnabled {
keyboardBacklightController.adjust(by: delta) keyboardBacklightController.adjust(by: delta)
@ -336,12 +345,21 @@ final class SystemChangesObserver: MediaKeyInterceptorDelegate {
} }
private extension SystemChangesObserver { private extension SystemChangesObserver {
func stepSize(for step: MediaKeyStep, base: Float) -> Float { func volumeStep(for step: MediaKeyStep) -> Float {
switch step { switch step {
case .standard: case .standard:
return base return standardVolumeStep
case .fine: case .fine:
return base / fineStepDivisor return fineVolumeStep
}
}
func brightnessStep(for step: MediaKeyStep) -> Float {
switch step {
case .standard:
return standardBrightnessStep
case .fine:
return fineBrightnessStep
} }
} }
} }

View file

@ -46,7 +46,7 @@ class SystemDisplayManager {
private static func getStandardDisplayBrightness() throws -> Float { private static func getStandardDisplayBrightness() throws -> Float {
var brightness: float_t = 1 var brightness: float_t = 1
let service = IOServiceGetMatchingService(kIOMasterPortDefault, IOServiceMatching("IODisplayConnect")) let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("IODisplayConnect"))
defer { defer {
IOObjectRelease(service) IOObjectRelease(service)
} }

View file

@ -476,6 +476,10 @@ final class SystemBrightnessController {
private let maximumBrightnessAnimationDuration: TimeInterval = 0.3 private let maximumBrightnessAnimationDuration: TimeInterval = 0.3
private let brightnessAnimationDurationScale: TimeInterval = 1.6 private let brightnessAnimationDurationScale: TimeInterval = 1.6
private var lastEmittedBrightness: Float = 0.5 private var lastEmittedBrightness: Float = 0.5
private let coreBrightnessClient = CoreBrightnessDisplayClient.shared
private var pollTimer: Timer?
private let pollInterval: TimeInterval = 0.15
private let pollChangeThreshold: Float = 0.005
private init() { private init() {
registerExternalNotifications() registerExternalNotifications()
@ -484,12 +488,15 @@ final class SystemBrightnessController {
func start() { func start() {
notifyCurrentBrightness() notifyCurrentBrightness()
startPollingIfNeeded()
} }
func stop() { func stop() {
onBrightnessChange = nil onBrightnessChange = nil
brightnessAnimationTimer?.invalidate() brightnessAnimationTimer?.invalidate()
brightnessAnimationTimer = nil brightnessAnimationTimer = nil
pollTimer?.invalidate()
pollTimer = nil
} }
func adjust(by delta: Float) { func adjust(by delta: Float) {
@ -506,6 +513,9 @@ final class SystemBrightnessController {
} }
var currentBrightness: Float { var currentBrightness: Float {
if let level = coreBrightnessClient.currentBrightness() {
return level
}
if let level = getBrightnessViaDisplayServices() { if let level = getBrightnessViaDisplayServices() {
return level return level
} }
@ -583,6 +593,9 @@ final class SystemBrightnessController {
private func applyBrightness(_ value: Float) { private func applyBrightness(_ value: Float) {
let clamped = max(0, min(1, value)) let clamped = max(0, min(1, value))
if coreBrightnessClient.setBrightness(clamped) {
return
}
if setBrightnessViaDisplayServices(clamped) { if setBrightnessViaDisplayServices(clamped) {
return return
} }
@ -667,7 +680,8 @@ final class SystemBrightnessController {
let names = [ let names = [
Notification.Name("com.apple.BezelEngine.BrightnessChanged"), Notification.Name("com.apple.BezelEngine.BrightnessChanged"),
Notification.Name("com.apple.BezelServices.BrightnessChanged"), Notification.Name("com.apple.BezelServices.BrightnessChanged"),
Notification.Name("com.apple.controlcenter.display.brightness") Notification.Name("com.apple.controlcenter.display.brightness"),
Notification.Name("com.apple.CoreBrightness.DisplayBrightnessChanged")
] ]
observers = names.map { name in observers = names.map { name in
DistributedNotificationCenter.default().addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in DistributedNotificationCenter.default().addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
@ -677,8 +691,20 @@ final class SystemBrightnessController {
notificationsInstalled = true notificationsInstalled = true
} }
private func startPollingIfNeeded() {
guard pollTimer == nil else { return }
pollTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
guard let self else { return }
let system = self.currentBrightness
if abs(system - self.lastEmittedBrightness) > self.pollChangeThreshold {
self.emitBrightnessChange(value: system)
}
}
}
deinit { deinit {
brightnessAnimationTimer?.invalidate() brightnessAnimationTimer?.invalidate()
pollTimer?.invalidate()
observers.forEach { DistributedNotificationCenter.default().removeObserver($0) } observers.forEach { DistributedNotificationCenter.default().removeObserver($0) }
} }
} }

View file

@ -17,12 +17,23 @@
*/ */
import Foundation import Foundation
import os
class SystemOSDManager { class SystemOSDManager {
private init() {} private init() {}
// Tracks the PID we most recently suspended. macOS jetsam-exits OSDUIHelper
// when idle and launchd respawns it on the next media-key press as a fresh
// process, so we need to re-SIGSTOP every new incarnation.
private struct SuppressionState {
var task: Task<Void, Never>?
var lastSuspendedPID: Int32 = -1
}
private static let suppressionState = OSAllocatedUnfairLock(initialState: SuppressionState())
/// Re-enables the system HUD by restarting OSDUIHelper /// Re-enables the system HUD by restarting OSDUIHelper
public static func enableSystemHUD() { public static func enableSystemHUD() {
stopSuppressionWatcher()
Task.detached(priority: .background) { Task.detached(priority: .background) {
await enableSystemHUDAsync() await enableSystemHUDAsync()
} }
@ -77,11 +88,14 @@ class SystemOSDManager {
} }
} }
/// Disables the system HUD by stopping OSDUIHelper /// Disables the system HUD by stopping OSDUIHelper, and starts a
/// background watcher that re-suspends any future incarnation launchd
/// spawns (macOS auto-exits OSDUIHelper on idle).
public static func disableSystemHUD() { public static func disableSystemHUD() {
Task.detached(priority: .background) { Task.detached(priority: .background) {
await disableSystemHUDAsync() await disableSystemHUDAsync()
} }
startSuppressionWatcher()
} }
private static func disableSystemHUDAsync() async { private static func disableSystemHUDAsync() async {
@ -93,13 +107,33 @@ class SystemOSDManager {
try kickstart.run() try kickstart.run()
kickstart.waitUntilExit() kickstart.waitUntilExit()
try await Task.sleep(nanoseconds: 500_000_000) // 500ms - async wait instead of usleep // launchctl kickstart returns once the request is queued, not after
// OSDUIHelper has actually forked. At cold boot the helper can take
// a while to appear — a fixed sleep races and the SIGSTOP misses,
// letting the native OSD render on the first volume/brightness key.
// Poll for the PID up to ~5s, then suspend, and retry if launchd
// respawned a fresh copy between kickstart and SIGSTOP.
var attempts = 0
while attempts < 3 {
let appeared = await waitForOSDUIHelper(timeoutMillis: 5000)
if !appeared {
await MainActor.run {
NSLog("⚠️ OSDUIHelper did not appear within timeout; retrying SIGSTOP anyway")
}
}
let task = Process() suspendOSDUIHelper()
task.executableURL = URL(fileURLWithPath: "/usr/bin/killall")
task.arguments = ["-STOP", "OSDUIHelper"] // Settle, then confirm a process is actually present (and thus
try task.run() // suspended). If none is running, launchd hasn't spawned it yet
task.waitUntilExit() // or the prior STOP raced — loop and try again.
try await Task.sleep(nanoseconds: 250_000_000) // 250ms
if let pid = osduiHelperPID() {
suppressionState.withLock { $0.lastSuspendedPID = pid }
break
}
attempts += 1
}
await MainActor.run { await MainActor.run {
print("✅ System HUD disabled") print("✅ System HUD disabled")
@ -111,6 +145,91 @@ class SystemOSDManager {
} }
} }
/// Polls for an OSDUIHelper process, returning true as soon as one appears
/// or false if `timeoutMillis` elapses with no match.
private static func waitForOSDUIHelper(timeoutMillis: Int) async -> Bool {
let pollIntervalNanos: UInt64 = 200_000_000 // 200ms
let maxAttempts = max(1, timeoutMillis / 200)
for _ in 0..<maxAttempts {
if isOSDUIHelperRunning() {
return true
}
try? await Task.sleep(nanoseconds: pollIntervalNanos)
}
return isOSDUIHelperRunning()
}
/// Background loop that catches OSDUIHelper respawns. macOS exits the
/// helper after a short idle period (JETSAM_REASON_MEMORY_IDLE_EXIT) and
/// launchd spins up a brand-new process on the next volume/brightness
/// keypress — that fresh PID renders the native OSD before any one-shot
/// SIGSTOP can hit it. Polling every 150ms is cheap (a single pgrep per
/// tick when nothing changed) and shrinks the visible-OSD window enough
/// to feel instant.
private static func startSuppressionWatcher() {
let newTask = Task.detached(priority: .background) {
while !Task.isCancelled {
let currentPID = osduiHelperPID()
let lastPID = suppressionState.withLock { $0.lastSuspendedPID }
if let pid = currentPID, pid != lastPID {
suspendOSDUIHelper()
suppressionState.withLock { $0.lastSuspendedPID = pid }
}
try? await Task.sleep(nanoseconds: 150_000_000) // 150ms
}
}
let previous = suppressionState.withLock { state -> Task<Void, Never>? in
let prior = state.task
state.task = newTask
return prior
}
previous?.cancel()
}
private static func stopSuppressionWatcher() {
let previous = suppressionState.withLock { state -> Task<Void, Never>? in
let prior = state.task
state.task = nil
state.lastSuspendedPID = -1
return prior
}
previous?.cancel()
}
/// Returns the newest OSDUIHelper PID, or nil if none.
private static func osduiHelperPID() -> Int32? {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/pgrep")
task.arguments = ["-n", "OSDUIHelper"]
let pipe = Pipe()
task.standardOutput = pipe
do {
try task.run()
task.waitUntilExit()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
let trimmed = String(data: data, encoding: .utf8)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return Int32(trimmed)
} catch {
return nil
}
}
/// Sends SIGSTOP to all OSDUIHelper processes. Idempotent.
private static func suspendOSDUIHelper() {
let stop = Process()
stop.executableURL = URL(fileURLWithPath: "/usr/bin/killall")
stop.arguments = ["-STOP", "OSDUIHelper"]
do {
try stop.run()
stop.waitUntilExit()
} catch {
NSLog("Suppression watcher: failed to SIGSTOP OSDUIHelper: \(error)")
}
}
/// Check if OSDUIHelper is currently running /// Check if OSDUIHelper is currently running
public static func isOSDUIHelperRunning() -> Bool { public static func isOSDUIHelperRunning() -> Bool {
let task = Process() let task = Process()

View file

@ -26,6 +26,12 @@ import Foundation
import IOKit.ps import IOKit.ps
import SwiftUI import SwiftUI
enum BatteryTemporaryHUDKind: Equatable {
case charging
case lowBattery
case fullBattery
}
/// A view model that manages and monitors the battery status of the device /// A view model that manages and monitors the battery status of the device
class BatteryStatusViewModel: ObservableObject { class BatteryStatusViewModel: ObservableObject {
@ -34,7 +40,6 @@ class BatteryStatusViewModel: ObservableObject {
private var runLoopSource: Unmanaged<CFRunLoopSource>? private var runLoopSource: Unmanaged<CFRunLoopSource>?
var animations: DynamicIslandAnimations = DynamicIslandAnimations() var animations: DynamicIslandAnimations = DynamicIslandAnimations()
private let lowBatteryAlertSoundPlayer = AudioPlayer() private let lowBatteryAlertSoundPlayer = AudioPlayer()
private let lowBatteryAlertThresholds: [Float] = [20, 15, 10, 5]
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@ -46,6 +51,11 @@ class BatteryStatusViewModel: ObservableObject {
@Published private(set) var isInitial: Bool = false @Published private(set) var isInitial: Bool = false
@Published private(set) var timeToFullCharge: Int = 0 @Published private(set) var timeToFullCharge: Int = 0
@Published private(set) var statusText: String = "" @Published private(set) var statusText: String = ""
@Published private(set) var activeTemporaryHUDKind: BatteryTemporaryHUDKind?
@Published private(set) var activeTemporaryHUDToken: UUID = UUID()
@Published private(set) var activeTemporaryHUDTargetScreenName: String?
@Published private(set) var activeTemporaryHUDLevelOverride: Int?
@Published private(set) var activeTemporaryHUDLowPowerModeOverride: Bool?
private let managerBattery = BatteryActivityManager.shared private let managerBattery = BatteryActivityManager.shared
private var managerBatteryId: Int? private var managerBatteryId: Int?
@ -79,33 +89,39 @@ class BatteryStatusViewModel: ObservableObject {
switch event { switch event {
case .powerSourceChanged(let isPluggedIn): case .powerSourceChanged(let isPluggedIn):
print("🔌 Power source: \(isPluggedIn ? "Connected" : "Disconnected")") print("🔌 Power source: \(isPluggedIn ? "Connected" : "Disconnected")")
let wasPluggedIn = self.isPluggedIn
withAnimation { withAnimation {
self.isPluggedIn = isPluggedIn self.isPluggedIn = isPluggedIn
self.statusText = isPluggedIn ? String(localized: "Plugged In") : String(localized: "Unplugged") self.statusText = isPluggedIn ? String(localized: "Plugged In") : String(localized: "Unplugged")
self.notifyImportanChangeStatus() }
if !wasPluggedIn && isPluggedIn {
presentTemporaryBatteryHUDIfNeeded(kind: .charging)
} }
case .batteryLevelChanged(let level): case .batteryLevelChanged(let level):
print("🔋 Battery level: \(Int(level))%") print("🔋 Battery level: \(Int(level))%")
let previousLevel = self.levelBattery let previousLevel = self.levelBattery
self.handleLowBatteryAlertIfNeeded(previousLevel: previousLevel, newLevel: level)
withAnimation { withAnimation {
self.levelBattery = level self.levelBattery = level
} }
self.handleLowBatteryAlertIfNeeded(previousLevel: previousLevel, newLevel: level)
self.handleFullBatteryAlertIfNeeded(previousLevel: previousLevel, newLevel: level)
case .lowPowerModeChanged(let isEnabled): case .lowPowerModeChanged(let isEnabled):
print("⚡ Low power mode: \(isEnabled ? "Enabled" : "Disabled")") print("⚡ Low power mode: \(isEnabled ? "Enabled" : "Disabled")")
self.notifyImportanChangeStatus() let wasEnabled = self.isInLowPowerMode
withAnimation { withAnimation {
self.isInLowPowerMode = isEnabled self.isInLowPowerMode = isEnabled
self.statusText = String(localized: "Low Power: \(self.isInLowPowerMode ? String(localized: "On") : String(localized: "Off"))") self.statusText = String(localized: "Low Power: \(self.isInLowPowerMode ? String(localized: "On") : String(localized: "Off"))")
} }
if !wasEnabled && isEnabled {
presentTemporaryBatteryHUDIfNeeded(kind: .lowBattery)
}
case .isChargingChanged(let isCharging): case .isChargingChanged(let isCharging):
print("🔌 Charging: \(isCharging ? "Yes" : "No")") print("🔌 Charging: \(isCharging ? "Yes" : "No")")
print("maxCapacity: \(self.maxCapacity)") print("maxCapacity: \(self.maxCapacity)")
print("levelBattery: \(self.levelBattery)") print("levelBattery: \(self.levelBattery)")
self.notifyImportanChangeStatus()
withAnimation { withAnimation {
self.isCharging = isCharging self.isCharging = isCharging
self.statusText = self.statusText =
@ -145,30 +161,131 @@ class BatteryStatusViewModel: ObservableObject {
} }
} }
/// Notifies important changes in the battery status with an optional delay private func presentTemporaryBatteryHUDIfNeeded(kind: BatteryTemporaryHUDKind) {
/// - Parameter delay: The delay before notifying the change, default is 0.0 presentTemporaryBatteryHUDIfNeeded(kind: kind, force: false)
private func notifyImportanChangeStatus(delay: Double = 0.0) { }
Task {
try? await Task.sleep(for: .seconds(delay)) func triggerTestHUD(kind: BatteryTemporaryHUDKind) {
self.coordinator.toggleExpandingView(status: true, type: .battery) let previewLevel: Int
switch kind {
case .charging:
previewLevel = max(12, min(95, Int(levelBattery.rounded())))
case .lowBattery:
previewLevel = max(5, min(20, Defaults[.lowBatteryHUDThreshold]))
case .fullBattery:
previewLevel = 100
} }
presentTemporaryBatteryHUDIfNeeded(
kind: kind,
force: true,
levelOverride: previewLevel,
lowPowerModeOverride: kind == .lowBattery ? isInLowPowerMode : nil
)
}
private func presentTemporaryBatteryHUDIfNeeded(
kind: BatteryTemporaryHUDKind,
force: Bool,
levelOverride: Int? = nil,
lowPowerModeOverride: Bool? = nil
) {
guard force || Defaults[.showPowerStatusNotifications] else { return }
let duration: Int
let isEnabled: Bool
switch kind {
case .charging:
duration = Defaults[.chargingBatteryHUDDuration]
isEnabled = Defaults[.showChargingBatteryHUD]
case .lowBattery:
duration = Defaults[.lowBatteryHUDDuration]
isEnabled = Defaults[.showLowBatteryHUD]
case .fullBattery:
duration = Defaults[.fullBatteryHUDDuration]
isEnabled = Defaults[.showFullBatteryHUD]
}
guard force || isEnabled else { return }
activeTemporaryHUDKind = kind
activeTemporaryHUDToken = UUID()
activeTemporaryHUDTargetScreenName = resolvedTemporaryHUDTargetScreenName()
activeTemporaryHUDLevelOverride = levelOverride
activeTemporaryHUDLowPowerModeOverride = lowPowerModeOverride
coordinator.toggleExpandingView(
status: true,
type: .battery,
autoHideDuration: TimeInterval(max(1, duration))
)
}
private func resolvedTemporaryHUDTargetScreenName() -> String? {
if Defaults[.showOnAllDisplays] {
return nil
}
let preferredNames = [
coordinator.selectedScreen,
coordinator.preferredScreen,
NSScreen.main?.localizedName
]
.compactMap { $0 }
for candidate in preferredNames where NSScreen.screens.contains(where: { $0.localizedName == candidate }) {
return candidate
}
return NSScreen.screens.first?.localizedName
}
private func preferredDynamicIslandTargetScreenName() -> String? {
let mainScreenName = NSScreen.main?.localizedName
let preferredNames = [coordinator.selectedScreen, coordinator.preferredScreen]
for candidate in preferredNames where shouldUseDynamicIslandMode(for: candidate) {
return candidate
}
if let externalDynamicIslandScreen = NSScreen.screens.first(where: {
$0.localizedName != mainScreenName && shouldUseDynamicIslandMode(for: $0.localizedName)
}) {
return externalDynamicIslandScreen.localizedName
}
if let anyDynamicIslandScreen = NSScreen.screens.first(where: {
shouldUseDynamicIslandMode(for: $0.localizedName)
}) {
return anyDynamicIslandScreen.localizedName
}
return nil
} }
private func handleLowBatteryAlertIfNeeded(previousLevel: Float, newLevel: Float) { private func handleLowBatteryAlertIfNeeded(previousLevel: Float, newLevel: Float) {
guard Defaults[.playLowBatteryAlertSound] else { return }
guard !isPluggedIn, !isCharging else { return } guard !isPluggedIn, !isCharging else { return }
guard newLevel < previousLevel else { return } guard newLevel < previousLevel else { return }
let threshold = Float(Defaults[.lowBatteryHUDThreshold])
guard previousLevel > threshold && newLevel <= threshold else { return }
for threshold in lowBatteryAlertThresholds { self.statusText = String(localized: "Low battery")
if previousLevel >= threshold && newLevel < threshold { presentTemporaryBatteryHUDIfNeeded(kind: .lowBattery)
self.statusText = String(localized: "Low battery") if Defaults[.playLowBatteryAlertSound] {
notifyImportanChangeStatus() playLowBatteryAlertSound()
playLowBatteryAlertSound()
break
}
} }
} }
private func handleFullBatteryAlertIfNeeded(previousLevel: Float, newLevel: Float) {
guard newLevel > previousLevel else { return }
let threshold = Float(Defaults[.fullBatteryHUDThreshold])
guard previousLevel < threshold && newLevel >= threshold else { return }
self.statusText = String(localized: "Full charge")
presentTemporaryBatteryHUDIfNeeded(kind: .fullBattery)
}
private func playLowBatteryAlertSound() { private func playLowBatteryAlertSound() {
lowBatteryAlertSoundPlayer.play(fileName: "lowbattery", fileExtension: "mp3") lowBatteryAlertSoundPlayer.play(fileName: "lowbattery", fileExtension: "mp3")
} }

View file

@ -473,6 +473,22 @@ enum ProgressBarStyle: String, CaseIterable, Identifiable, Defaults.Serializable
var id: String { self.rawValue } var id: String { self.rawValue }
} }
enum BatteryNotificationStyle: String, CaseIterable, Identifiable, Defaults.Serializable {
case standard
case compact
var id: String { rawValue }
var title: String {
switch self {
case .standard:
return String(localized: "Standard")
case .compact:
return String(localized: "Compact")
}
}
}
enum MusicAuxiliaryControl: String, CaseIterable, Identifiable, Defaults.Serializable { enum MusicAuxiliaryControl: String, CaseIterable, Identifiable, Defaults.Serializable {
case shuffle case shuffle
case repeatMode case repeatMode
@ -910,6 +926,10 @@ extension Defaults.Keys {
) )
static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true) static let lockScreenPanelUsesBlur = Key<Bool>("lockScreenPanelUsesBlur", default: true)
static let lockScreenMusicMergedAirPlayOutput = Key<Bool>("lockScreenMusicMergedAirPlayOutput", default: true) static let lockScreenMusicMergedAirPlayOutput = Key<Bool>("lockScreenMusicMergedAirPlayOutput", default: true)
static let lockScreenMusicFullscreenArtworkEnabled = Key<Bool>("lockScreenMusicFullscreenArtworkEnabled", default: true)
static let lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork = Key<Bool>("lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork", default: false)
static let lockScreenMusicFullscreenVideoArtwork = Key<Bool>("lockScreenMusicFullscreenVideoArtwork", default: true)
static let lockScreenUseArtworkLayoutOverFullscreenCanvas = Key<Bool>("lockScreenShowCenteredAlbumArtOverFullscreenCanvas", default: true)
static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: false) static let lockScreenTimerWidgetUsesBlur = Key<Bool>("lockScreenTimerWidgetUsesBlur", default: false)
static let lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor) static let lockScreenReminderChipStyle = Key<LockScreenReminderChipStyle>("lockScreenReminderChipStyle", default: .eventColor)
static let lockScreenReminderWidgetHorizontalAlignment = Key<String>("lockScreenReminderWidgetHorizontalAlignment", default: "center") static let lockScreenReminderWidgetHorizontalAlignment = Key<String>("lockScreenReminderWidgetHorizontalAlignment", default: "center")
@ -937,6 +957,16 @@ extension Defaults.Keys {
static let showBatteryPercentage = Key<Bool>("showBatteryPercentage", default: true) static let showBatteryPercentage = Key<Bool>("showBatteryPercentage", default: true)
static let showPowerStatusIcons = Key<Bool>("showPowerStatusIcons", default: true) static let showPowerStatusIcons = Key<Bool>("showPowerStatusIcons", default: true)
static let playLowBatteryAlertSound = Key<Bool>("playLowBatteryAlertSound", default: true) static let playLowBatteryAlertSound = Key<Bool>("playLowBatteryAlertSound", default: true)
static let showChargingBatteryHUD = Key<Bool>("showChargingBatteryHUD", default: true)
static let showLowBatteryHUD = Key<Bool>("showLowBatteryHUD", default: true)
static let showFullBatteryHUD = Key<Bool>("showFullBatteryHUD", default: true)
static let chargingBatteryHUDDuration = Key<Int>("chargingBatteryHUDDuration", default: 3)
static let lowBatteryHUDDuration = Key<Int>("lowBatteryHUDDuration", default: 3)
static let fullBatteryHUDDuration = Key<Int>("fullBatteryHUDDuration", default: 3)
static let lowBatteryHUDThreshold = Key<Int>("lowBatteryHUDThreshold", default: 20)
static let fullBatteryHUDThreshold = Key<Int>("fullBatteryHUDThreshold", default: 100)
static let lowBatteryHUDStyle = Key<BatteryNotificationStyle>("lowBatteryHUDStyle", default: .standard)
static let fullBatteryHUDStyle = Key<BatteryNotificationStyle>("fullBatteryHUDStyle", default: .standard)
static let lockScreenBatteryShowsBatteryGauge = Key<Bool>( static let lockScreenBatteryShowsBatteryGauge = Key<Bool>(
"lockScreenWeatherShowsBatteryGauge", "lockScreenWeatherShowsBatteryGauge",
@ -988,6 +1018,10 @@ extension Defaults.Keys {
// MARK: Media Controller // MARK: Media Controller
static let mediaController = Key<MediaControllerType>("mediaController", default: defaultMediaController) static let mediaController = Key<MediaControllerType>("mediaController", default: defaultMediaController)
static let spotifySPDCCookie = Key<String>("spotifySPDCCookie", default: "")
static let spotifyAuthAccessToken = Key<String>("spotifyAuthAccessToken", default: "")
static let spotifyAuthAccessTokenExpiration = Key<Double>("spotifyAuthAccessTokenExpiration", default: 0)
static let spotifyAuthLastValidatedAt = Key<Double>("spotifyAuthLastValidatedAt", default: 0)
// MARK: Bluetooth Audio Devices // MARK: Bluetooth Audio Devices
static let showBluetoothDeviceConnections = Key<Bool>("showBluetoothDeviceConnections", default: true) static let showBluetoothDeviceConnections = Key<Bool>("showBluetoothDeviceConnections", default: true)
@ -1100,6 +1134,12 @@ extension Defaults.Keys {
static let systemHUDSensitivity = Key<Int>("systemHUDSensitivity", default: 5) static let systemHUDSensitivity = Key<Int>("systemHUDSensitivity", default: 5)
static let playVolumeChangeFeedback = Key<Bool>("playVolumeChangeFeedback", default: false) static let playVolumeChangeFeedback = Key<Bool>("playVolumeChangeFeedback", default: false)
// Step sizes for hardware media keys (percent of full range, 1-25)
static let volumeStepPercent = Key<Int>("volumeStepPercent", default: 6)
static let volumeFineStepPercent = Key<Int>("volumeFineStepPercent", default: 2)
static let brightnessStepPercent = Key<Int>("brightnessStepPercent", default: 6)
static let brightnessFineStepPercent = Key<Int>("brightnessFineStepPercent", default: 2)
// MARK: Custom OSD Window Feature // MARK: Custom OSD Window Feature
static let enableCustomOSD = Key<Bool>("enableCustomOSD", default: false) static let enableCustomOSD = Key<Bool>("enableCustomOSD", default: false)
static let enableVerticalHUD = Key<Bool>("enableVerticalHUD", default: false) static let enableVerticalHUD = Key<Bool>("enableVerticalHUD", default: false)
@ -1190,6 +1230,7 @@ extension Defaults.Keys {
// MARK: Lyrics Feature // MARK: Lyrics Feature
static let enableLyrics = Key<Bool>("enableLyrics", default: false) static let enableLyrics = Key<Bool>("enableLyrics", default: false)
static let showLiveCanvasInDynamicIsland = Key<Bool>("showLiveCanvasInDynamicIsland", default: false)
// MARK: Notes Feature // MARK: Notes Feature
static let enableNotes = Key<Bool>("enableNotes", default: false) static let enableNotes = Key<Bool>("enableNotes", default: false)

View file

@ -44,6 +44,7 @@ struct PlaybackState {
var repeatMode: RepeatMode = .off var repeatMode: RepeatMode = .off
var lastUpdated: Date = Date.distantPast var lastUpdated: Date = Date.distantPast
var artwork: Data? var artwork: Data?
var liveArtworkURL: URL?
} }
extension PlaybackState: Equatable { extension PlaybackState: Equatable {
@ -61,5 +62,6 @@ extension PlaybackState: Equatable {
&& lhs.isShuffled == rhs.isShuffled && lhs.isShuffled == rhs.isShuffled
&& lhs.repeatMode == rhs.repeatMode && lhs.repeatMode == rhs.repeatMode
&& lhs.artwork == rhs.artwork && lhs.artwork == rhs.artwork
&& lhs.liveArtworkURL == rhs.liveArtworkURL
} }
} }

View file

@ -0,0 +1,14 @@
"CFBundleDisplayName" = "Atoll";
"NSAppleEventsUsageDescription" = "Atoll, Spotify ve Apple Music'i kontrol etmek için AppleScript kullanır.";
"NSBluetoothAlwaysUsageDescription" = "Atoll, ses aygıtları bağlandığında bunları algılamak ve HUD'da pil durumlarını göstermek için Bluetooth erişimine ihtiyaç duyar.";
"NSCalendarsFullAccessUsageDescription" = "Takvim etkinliklerini çentik içinde gösterir.";
"NSCalendarsUsageDescription" = "Takvim modu için gereklidir.";
"NSCameraUsageDescription" = "Ayna modu için kameranıza ihtiyaç duyuyoruz.";
"NSDesktopFolderUsageDescription" = "Atoll, ekran görüntülerini masaüstünüze kaydetmek için erişime ihtiyaç duyar.";
"NSDocumentsFolderUsageDescription" = "Atoll, rafa sürüklenen dosyaları kaydetmek ve okumak için erişime ihtiyaç duyar.";
"NSDownloadsFolderUsageDescription" = "Atoll, İndirilenler klasöründeki dosyaları okumak için erişime ihtiyaç duyar.";
"NSLocationUsageDescription" = "Atoll, doğru kilit ekranı hava durumunu göstermek için konumunuzu kullanır.";
"NSLocationWhenInUseUsageDescription" = "Atoll, doğru kilit ekranı hava durumunu göstermek için konumunuzu kullanır.";
"NSRemindersUsageDescription" = "Atoll, anımsatıcılarınızı göstermek için Anımsatıcılar erişimine ihtiyaç duyar.";
"NSAudioCaptureUsageDescription" = "Atoll, gerçek zamanlı müzik dalga biçimi görselleştiricisini göstermek için ses yakalama iznine ihtiyaç duyar.";
"NSScreenCaptureUsageDescription" = "Atoll, yapay zeka asistanı özelliği için ekran görüntüleri yakalamak üzere ekran kaydı iznine ihtiyaç duyar.";

View file

@ -11994,8 +11994,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "Amazon Music"
} }
} }
} }
@ -36021,6 +36021,12 @@
"value": "Включить медиа ", "value": "Включить медиа ",
"state": "translated" "state": "translated"
} }
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "启用媒体 "
}
} }
} }
}, },
@ -60469,8 +60475,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "仅应用图标"
} }
} }
} }
@ -60599,8 +60605,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "仅下载图标"
} }
} }
} }
@ -60729,8 +60735,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "透明度:"
} }
} }
} }
@ -60865,8 +60871,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "打开 Google AI Studio"
} }
} }
} }
@ -60996,8 +61002,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "在日历中打开"
} }
} }
} }
@ -61132,8 +61138,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "打开模型设置"
} }
} }
} }
@ -61152,6 +61158,12 @@
"value": "Открывать вырез при наведении", "value": "Открывать вырез при наведении",
"state": "translated" "state": "translated"
} }
},
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "悬停时打开刘海"
}
} }
} }
}, },
@ -61280,8 +61292,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "打开设置"
} }
} }
} }
@ -61558,8 +61570,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "打开 AI 助手面板,进行文件分析与对话。默认为 Cmd+Shift+A。仅在启用屏幕助手功能时有效。"
} }
} }
} }
@ -61700,8 +61712,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "打开剪贴板历史记录面板。默认为 Cmd+Shift+V(类似于 PC 上的 Windows+V)。仅在启用剪贴板功能时有效。"
} }
} }
} }
@ -61842,8 +61854,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "打开取色器面板进行屏幕颜色捕获。默认为 Cmd+Shift+P。仅在启用取色器功能时有效。"
} }
} }
} }
@ -61973,8 +61985,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "输出设备"
} }
} }
} }
@ -62104,8 +62116,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "输出音量"
} }
} }
} }
@ -62246,8 +62258,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "面板模式会在刘海附近的浮动窗口中显示剪贴板。弹出模式会将剪贴板显示为附加在剪贴板按钮上的下拉菜单。"
} }
} }
} }
@ -62388,8 +62400,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "面板模式会在浮动窗口中显示取色器。弹出模式会将取色器显示为附加在取色器按钮上的下拉菜单。"
} }
} }
} }
@ -62530,8 +62542,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "面板模式会在刘海附近的浮动窗口中显示助手。弹出模式会将助手显示为附加在 AI 按钮上的下拉菜单。"
} }
} }
} }
@ -62790,8 +62802,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "已暂停"
} }
} }
} }
@ -63310,8 +63322,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "已选取 %@"
} }
} }
} }
@ -63440,8 +63452,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "拾取的颜色"
} }
} }
} }
@ -63570,8 +63582,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "拾取状态"
} }
} }
} }
@ -63700,8 +63712,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "从历史记录中固定项目以保存在此处"
} }
} }
} }
@ -63830,8 +63842,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "固定项目以添加收藏"
} }
} }
} }
@ -63960,8 +63972,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "固定项目以将它们添加到收藏夹"
} }
} }
} }
@ -64090,8 +64102,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "已固定项目"
} }
} }
} }
@ -64226,8 +64238,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "请在模型设置中为所选的 AI 提供商配置您的 API 密钥。"
} }
} }
} }
@ -64521,8 +64533,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "弹出模式会将剪贴板显示为附加在剪贴板按钮上的下拉菜单。面板模式会在刘海附近的浮动窗口中显示剪贴板。"
} }
} }
} }
@ -64663,8 +64675,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "弹出模式会将取色器显示为附加在取色器按钮上的下拉菜单。面板模式会在浮动窗口中显示取色器。"
} }
} }
} }
@ -64805,8 +64817,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "弹出模式会将助手显示为附加在 AI 按钮上的下拉菜单。面板模式会在刘海附近的浮动窗口中显示助手。"
} }
} }
} }
@ -65340,8 +65352,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "预设将显示在计时器弹出窗口中,包括配置的名称、持续时间和重音颜色。重新排序以更改显示顺序。"
} }
} }
} }
@ -65471,8 +65483,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "气压"
} }
} }
} }
@ -65731,8 +65743,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "预览缩放:"
} }
} }
} }
@ -89120,8 +89132,8 @@
}, },
"zh-Hans": { "zh-Hans": {
"stringUnit": { "stringUnit": {
"state": "new", "state": "translated",
"value": "" "value": "当 Amazon Music 应用是活动媒体源时使用 macOS 正在播放。播放控制遵循系统正在播放目标。如果 Amazon Music 应用不支持远程拖拽,在时间轴上拖动可能无效。"
} }
} }
} }
@ -92636,6 +92648,266 @@
} }
} }
} }
},
"15 mins": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "15 分钟"
}
}
}
},
"30 mins": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "30 分钟"
}
}
}
},
"1 hour": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "1 小时"
}
}
}
},
"3 hours": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "3 小时"
}
}
}
},
"6 hours": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "6 小时"
}
}
}
},
"12 hours": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "12 小时"
}
}
}
},
"Rest of the day": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "今天余下的时间"
}
}
}
},
"All time": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "所有时间"
}
}
}
},
"Symbol": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "符号"
}
}
}
},
"3D": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "3D"
}
}
}
},
"Left": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "左对齐"
}
}
}
},
"Right": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "右对齐"
}
}
}
},
"Enable Minimalistic UI": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "启用极简 UI"
}
}
}
},
"Hide Dynamic Island during screenshots & recordings": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "截屏和录屏时隐藏灵动岛"
}
}
}
},
"Close gesture": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "关闭手势"
}
}
}
},
"Reverse swipe gestures": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "反向滑动"
}
}
}
},
"Reverse scroll gestures": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "反向滚动"
}
}
}
},
"Extend hover area": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "扩展悬停区域"
}
}
}
},
"External display style": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "外接显示器样式"
}
}
}
},
"Hide until hovered": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "悬停时显示"
}
}
}
},
"Enable Focus Detection": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "启用专注模式检测"
}
}
}
},
"Accessibility permission lets Dynamic Island replace the native volume, brightness, and keyboard HUDs.": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "辅助功能权限允许灵动岛替换原生的音量、亮度和键盘提示框。"
}
}
}
},
"Full Disk Access for global mode": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "全局模式需要完全磁盘访问权限"
}
}
}
},
"Without Full Disk Access, Shelf can only read files from Documents and Downloads. Grant Full Disk Access to make Shelf work globally.": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "如果没有完全磁盘访问权限,暂存器只能读取文档和下载目录的文件。授予完全磁盘访问权限以使暂存器能在全局范围工作。"
}
}
}
},
"Request Full Disk Access": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "请求完全磁盘访问权限"
}
}
}
},
"Open Privacy & Security": {
"localizations": {
"zh-Hans": {
"stringUnit": {
"state": "translated",
"value": "打开隐私与安全性"
}
}
}
} }
}, },
"version": "1.1" "version": "1.1"