diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 1a40e0d..1a73dbe 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -54,6 +54,7 @@ struct ContentView: View { @ObservedObject var extensionNotchExperienceManager = ExtensionNotchExperienceManager.shared @ObservedObject var localSendService = LocalSendService.shared @State private var downloadManager = DownloadManager.shared + @ObservedObject var shelfState = ShelfStateViewModel.shared @Default(.enableStatsFeature) var enableStatsFeature @Default(.showCpuGraph) var showCpuGraph @@ -84,6 +85,15 @@ struct ContentView: View { @Default(.hideNonNotchUntilHover) var hideNonNotchUntilHover @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 var dynamicNotchSize: CGSize { let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize @@ -100,6 +110,39 @@ struct ContentView: View { 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 { return CGSize(width: baseSize.width, height: 250) // Extra height for timer presets } @@ -382,9 +425,78 @@ struct ContentView: View { 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 /// when dynamic island mode is active, standard notch shape otherwise. private var resolvedClipShape: AnyShape { + if let activeClosedBatterySurfaceShape { + return activeClosedBatterySurfaceShape + } if isDynamicIslandMode { return AnyShape(currentPillShape) } @@ -763,47 +875,41 @@ struct ContentView: View { guard let musicSecondary else { return false } switch musicSecondary { case .timer: - return coordinator.expandingView.type == .timer + return currentScreenExpansionType == .timer case .reminder: - return coordinator.expandingView.type == .reminder + return currentScreenExpansionType == .reminder case .recording: - return coordinator.expandingView.type == .recording + return currentScreenExpansionType == .recording case .focus: - return coordinator.expandingView.type == .doNotDisturb + return currentScreenExpansionType == .doNotDisturb case .capsLock: return false case .extensionPayload: return false + case .shelf: + return false } }() - let canShowMusicDuringExpansion = !coordinator.expandingView.show - || coordinator.expandingView.type == .music + let canShowMusicDuringExpansion = !isCurrentScreenExpansionVisible + || currentScreenExpansionType == .music || expansionMatchesSecondary - if coordinator.expandingView.type == .battery && coordinator.expandingView.show && vm.notchState == .closed && Defaults[.showPowerStatusNotifications] { - HStack(spacing: 0) { - HStack { - Text(batteryModel.statusText) - .font(.subheadline) - } - - Rectangle() - .fill(.black) - .frame(width: vm.closedNotchSize.width + 10) - - HStack { - DynamicIslandBatteryView( - batteryWidth: 30, - isCharging: batteryModel.isCharging, - isInLowPowerMode: batteryModel.isInLowPowerMode, - isPluggedIn: batteryModel.isPluggedIn, - levelBattery: batteryModel.levelBattery, - isForNotification: true - ) - } - .frame(width: 76, alignment: .trailing) - } - .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), alignment: .center) + if currentScreenExpansionType == .battery + && isBatteryHUDVisibleOnCurrentScreen + && vm.notchState == .closed + && Defaults[.showPowerStatusNotifications] + && batteryModel.activeTemporaryHUDKind != nil { + BatteryTemporaryActivityView( + kind: batteryModel.activeTemporaryHUDKind ?? .charging, + batteryLevel: displayedBatteryHUDLevel, + isLowPowerMode: displayedBatteryHUDUsesLowPowerMode, + closedNotchWidth: vm.closedNotchSize.width + (isHovering ? 8 : 0), + baseHeight: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0), + isDynamicIslandMode: isDynamicIslandMode, + topCornerRadius: activeCornerRadiusInsets.closed.top, + styleOverride: batteryModel.activeTemporaryHUDKind.map { resolvedBatteryNotificationStyle(for: $0) } + ) + .id(batteryModel.activeTemporaryHUDToken) } 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) .transition( @@ -818,25 +924,25 @@ struct ContentView: View { MusicLiveActivity(secondary: musicSecondary) .id("closed-music-live-activity") .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() - } 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() - } 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() - } 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() .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() .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() - } 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() .id("lock-screen-live-activity") .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() } else if let extensionPayload = extensionStandalonePayload { let layout = extensionStandaloneLayout( @@ -849,7 +955,11 @@ struct ContentView: View { layout: layout, 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 !isCurrentScreenExpansionVisible && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { DynamicIslandFaceAnimation().animation(.interactiveSpring, value: musicManager.isPlayerIdle) } else if vm.notchState == .open { DynamicIslandHeader() @@ -1192,6 +1302,11 @@ struct ContentView: View { 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 } @@ -1212,6 +1327,8 @@ struct ContentView: View { case .extensionPayload(let payload): let maxWidth = baseWidth + centerBaseWidth * 0.6 return ExtensionLayoutMetrics.trailingWidth(for: payload, baseWidth: baseWidth, maxWidth: maxWidth) + case .shelf: + return baseWidth } } @@ -1314,6 +1431,10 @@ struct ContentView: View { accent: payload.descriptor.accentColor.swiftUIColor, 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) @@ -1372,6 +1493,14 @@ struct ContentView: View { spectrumView(forceSpectrum: true, trailingInset: 6) case .extensionPayload(let payload): 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: spectrumView( forceSpectrum: false, @@ -1606,10 +1735,10 @@ struct ContentView: View { return nil } - guard !coordinator.expandingView.show else { + guard !isCurrentScreenExpansionVisible else { ExtensionRoutingDiagnostics.shared.logSuppression( .standalone, - reason: "expanding view \(coordinator.expandingView.type) visible", + reason: "expanding view \(String(describing: currentScreenExpansionType ?? coordinator.expandingView.type)) visible", pendingCount: candidates.count ) return nil @@ -1729,7 +1858,7 @@ struct ContentView: View { && !vm.hideOnClosed && !lockScreenManager.isLocked && !isMusicHUDDeferredAfterUnlock - && !coordinator.expandingView.show + && !isCurrentScreenExpansionVisible && (!musicManager.isPlayerIdle || musicManager.bundleIdentifier != nil) && !coordinator.firstLaunch } @@ -2474,6 +2603,7 @@ private enum MusicSecondaryLiveActivity: Equatable { case focus(FocusModeType) case capsLock(showLabel: Bool) case extensionPayload(ExtensionLiveActivityPayload) + case shelf(count: Int) var id: String { switch self { @@ -2489,6 +2619,8 @@ private enum MusicSecondaryLiveActivity: Equatable { return showLabel ? "caps-lock-label" : "caps-lock-icon" case .extensionPayload(let payload): return "extension-\(payload.id)" + case .shelf(let count): + return "shelf-\(count)" } } } diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 8650f79..25a5e95 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -395,6 +395,43 @@ class AppDelegate: NSObject, NSApplicationDelegate { let inlineSneakPeekWidth: CGFloat = 460 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 var baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index be70f58..c6ebb3a 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -97,6 +97,7 @@ struct ExpandedItem { var type: SneakContentType = .battery var value: CGFloat = 0 var browser: BrowserType = .chromium + var autoHideDuration: TimeInterval? = nil } class DynamicIslandViewCoordinator: ObservableObject { @@ -413,7 +414,8 @@ class DynamicIslandViewCoordinator: ObservableObject { status: Bool, type: SneakContentType, value: CGFloat = 0, - browser: BrowserType = .chromium + browser: BrowserType = .chromium, + autoHideDuration: TimeInterval? = nil ) { Task { @MainActor in withAnimation(.smooth) { @@ -421,6 +423,7 @@ class DynamicIslandViewCoordinator: ObservableObject { self.expandingView.type = type self.expandingView.value = value self.expandingView.browser = browser + self.expandingView.autoHideDuration = autoHideDuration } } } @@ -433,7 +436,7 @@ class DynamicIslandViewCoordinator: ObservableObject { expandingViewTask?.cancel() // Only auto-hide for battery, not for downloads (DownloadManager handles that) if expandingView.type != .download { - let duration: TimeInterval = 3 + let duration = expandingView.autoHideDuration ?? 3 expandingViewTask = Task { [weak self] in try? await Task.sleep(for: .seconds(duration)) guard let self = self, !Task.isCancelled else { return } diff --git a/DynamicIsland/Info.plist b/DynamicIsland/Info.plist index 92fe265..181d8a5 100644 --- a/DynamicIsland/Info.plist +++ b/DynamicIsland/Info.plist @@ -7,6 +7,8 @@ NSAllowsArbitraryLoads + NSAppleMusicUsageDescription + Atoll uses Apple Music to fetch animated artwork for the lock screen wallpaper. NSAudioCaptureUsageDescription Atoll needs audio capture permission to display the real-time music waveform visualizer. NSScreenCaptureUsageDescription diff --git a/DynamicIsland/MediaControllers/SpotifyController.swift b/DynamicIsland/MediaControllers/SpotifyController.swift index 0380d96..5950ada 100644 --- a/DynamicIsland/MediaControllers/SpotifyController.swift +++ b/DynamicIsland/MediaControllers/SpotifyController.swift @@ -20,8 +20,8 @@ * along with this program. If not, see . */ -import Foundation import Combine +import Foundation import SwiftUI class SpotifyController: MediaControllerProtocol { @@ -31,91 +31,117 @@ class SpotifyController: MediaControllerProtocol { @Published private var playbackState: PlaybackState = PlaybackState( bundleIdentifier: SpotifyController.bundleIdentifier ) - + var playbackStatePublisher: AnyPublisher { $playbackState.eraseToAnyPublisher() } - + var isWorking: Bool { - return true // Spotify controller always works + true } - + private var notificationTask: Task? - + private var sessionChangeCancellable: AnyCancellable? + // Constant for time between command and update private let commandUpdateDelay: Duration = .milliseconds(25) private var lastArtworkURL: String? private var artworkFetchTask: Task? - + private var canvasFetchTask: Task? + private var currentCanvasTrackURI: String? + private var lastCanvasRequestTrackURI: String? + private var lastCanvasRequestDate: Date = .distantPast + private let canvasResolver = SpotifyCanvasResolver() + init() { setupPlaybackStateChangeObserver() + setupSessionChangeObserver() Task { if isActive() { await updatePlaybackInfo() } } } - + private func setupPlaybackStateChangeObserver() { notificationTask = Task { @Sendable [weak self] in let notifications = DistributedNotificationCenter.default().notifications( named: NSNotification.Name("com.spotify.client.PlaybackStateChanged") ) - + for await _ in notifications { await self?.updatePlaybackInfo() } } } - + + 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 { notificationTask?.cancel() artworkFetchTask?.cancel() + canvasFetchTask?.cancel() + sessionChangeCancellable?.cancel() } - + // MARK: - Protocol Implementation func play() async { await executeCommand("play") } func pause() async { await executeCommand("pause") } func togglePlay() async { await executeCommand("playpause") } func nextTrack() async { await executeCommand("next track") } - + func previousTrack() async { await executeAndRefresh("previous track") } - + func seek(to time: Double) async { await executeAndRefresh("set player position to \(time)") } - + func toggleShuffle() async { await executeAndRefresh("set shuffling to not shuffling") } - + func toggleRepeat() async { await executeAndRefresh("set repeating to not repeating") } - + func isActive() -> Bool { NSWorkspace.shared.runningApplications.contains { $0.bundleIdentifier == playbackState.bundleIdentifier } } - + func updatePlaybackInfo() async { guard let descriptor = try? await fetchPlaybackInfoAsync() else { return } guard descriptor.numberOfItems >= 11 else { return } - + let isPlaying = descriptor.atIndex(1)?.booleanValue ?? false let currentTrack = descriptor.atIndex(2)?.stringValue ?? "Unknown" let currentTrackArtist = descriptor.atIndex(3)?.stringValue ?? "Unknown" let currentTrackAlbum = descriptor.atIndex(4)?.stringValue ?? "Unknown" 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 isRepeating = descriptor.atIndex(8)?.booleanValue ?? false let artworkURL = descriptor.atIndex(9)?.stringValue ?? "" - let trackIdentifier = descriptor.atIndex(10)?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let trackSpotifyURL = descriptor.atIndex(11)?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - + let trackIdentifier = descriptor.atIndex(10)?.stringValue ?? "" + let spotifyURLString = descriptor.atIndex(11)?.stringValue ?? "" + let trackURI = Self.canonicalTrackURI(from: trackIdentifier, spotifyURLString: spotifyURLString) + var state = PlaybackState( bundleIdentifier: Self.bundleIdentifier, isPlaying: isPlaying, @@ -129,20 +155,40 @@ class SpotifyController: MediaControllerProtocol { repeatMode: isRepeating ? .all : .off, lastUpdated: Date() ) - state.contentIdentifier = trackIdentifier.isEmpty ? nil : trackIdentifier - state.contentURL = trackSpotifyURL.isEmpty ? nil : trackSpotifyURL + state.contentIdentifier = trackIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? 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 { 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) { guard artworkURL != lastArtworkURL || state.artwork == nil else { return } artworkFetchTask?.cancel() let currentState = state + let expectedTrackURI = trackURI artworkFetchTask = Task { do { @@ -150,8 +196,13 @@ class SpotifyController: MediaControllerProtocol { await MainActor.run { [weak self] in guard let self = self else { return } + guard expectedTrackURI.isEmpty || self.currentCanvasTrackURI == expectedTrackURI else { + self.artworkFetchTask = nil + return + } var updatedState = currentState updatedState.artwork = data + updatedState.liveArtworkURL = self.playbackState.liveArtworkURL self.playbackState = updatedState self.lastArtworkURL = artworkURL self.artworkFetchTask = nil @@ -164,9 +215,40 @@ 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 { let script = "tell application \"Spotify\" to \(command)" try? await AppleScriptHelper.executeVoid(script) @@ -177,7 +259,7 @@ class SpotifyController: MediaControllerProtocol { try? await Task.sleep(for: commandUpdateDelay) await updatePlaybackInfo() } - + private func fetchPlaybackInfoAsync() async throws -> NSAppleEventDescriptor? { let script = """ tell application "Spotify" @@ -192,21 +274,294 @@ class SpotifyController: MediaControllerProtocol { set shuffleState to shuffling set repeatState to repeating set artworkURL to artwork url of current track - set trackIdentifier to "" - try - set trackIdentifier to id of current track - 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} + set trackID to id of current track + set spotifyURL to spotify url of current track + return {playerState, currentTrackName, currentTrackArtist, currentTrackAlbum, trackPosition, trackDuration, shuffleState, repeatState, artworkURL, trackID, spotifyURL} on error return {false, "Unknown", "Unknown", "Unknown", 0, 0, false, false, "", "", ""} end try end tell """ - + 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.. 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 + } } diff --git a/DynamicIsland/components/Live activities/DynamicIslandBattery.swift b/DynamicIsland/components/Live activities/DynamicIslandBattery.swift index 10eef4c..af509b6 100644 --- a/DynamicIsland/components/Live activities/DynamicIslandBattery.swift +++ b/DynamicIsland/components/Live activities/DynamicIslandBattery.swift @@ -21,7 +21,7 @@ import Defaults /// A view that displays the battery status with an icon and charging indicator. struct BatteryView: View { - + @Default(.showPowerStatusIcons) var showPowerStatusIcons var levelBattery: Float var isPluggedIn: Bool var isCharging: Bool @@ -79,7 +79,7 @@ struct BatteryView: View { ) .padding(.leading, 2) - if iconStatus != "" && (isForNotification || Defaults[.showPowerStatusIcons]) { + if iconStatus != "" && (isForNotification || showPowerStatusIcons) { ZStack { Image(iconStatus) .resizable() @@ -182,6 +182,7 @@ struct BatteryMenuView: View { /// A view that displays the battery status and allows interaction to show detailed information. struct DynamicIslandBatteryView: View { + @Default(.showBatteryPercentage) var showBatteryPercentage @State var batteryWidth: CGFloat = 26 var isCharging: Bool = false var isInLowPowerMode: Bool = false @@ -199,7 +200,7 @@ struct DynamicIslandBatteryView: View { var body: some View { HStack { - if Defaults[.showBatteryPercentage] { + if showBatteryPercentage { ZStack(alignment: .trailing) { Text("100%") .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 { DynamicIslandBatteryView( batteryWidth: 30, diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 17716a4..f9a86dc 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -36,6 +36,7 @@ struct LockScreenMusicPanel: View { @ObservedObject var musicManager = MusicManager.shared @ObservedObject private var routeManager = AudioRouteManager.shared + @ObservedObject private var fullscreenArtworkManager = FullScreenArtworkWindowManager.shared @StateObject private var volumeModel = MediaOutputVolumeViewModel() @ObservedObject private var airPlayManager = AppleMusicAirPlayManager.shared @ObservedObject private var animator: LockScreenPanelAnimator @@ -46,6 +47,7 @@ struct LockScreenMusicPanel: View { @State private var isExpanded = false @State private var isVolumeSliderVisible = false @State private var isAirPlayPopoverPresented = false + @State private var isArtworkFullscreen = false @State private var collapseWorkItem: DispatchWorkItem? @State private var parallaxResumeWorkItem: DispatchWorkItem? @@ -66,6 +68,9 @@ struct LockScreenMusicPanel: View { @Default(.enableLyrics) private var enableLyrics @Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled @Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth + @Default(.lockScreenMusicFullscreenArtworkEnabled) private var fullscreenArtworkEnabled + @Default(.lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork) private var keepAlbumArtVisibleDuringFullscreenArtwork + @Default(.lockScreenMusicFullscreenVideoArtwork) private var fullscreenVideoArtwork init(animator: LockScreenPanelAnimator) { _animator = ObservedObject(wrappedValue: animator) @@ -118,6 +123,22 @@ struct LockScreenMusicPanel: View { private var usesEnhancedCustomLiquidBorder: Bool { 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 { if isActive && musicManager.hasActiveSession { @@ -132,16 +153,27 @@ struct LockScreenMusicPanel: View { ZStack(alignment: .topLeading) { panelBackgroundLayer 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) .clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) .overlay { - if showPanelBorder { + if showPanelBorder && !usesSpotifyCanvasFallbackContentPresentation { panelBorderOverlay } } - .shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10) - .contentShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) + .shadow( + 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.24), value: shouldShowVolumeSlider) .onAppear { @@ -163,6 +195,11 @@ struct LockScreenMusicPanel: View { parallaxResumeWorkItem = nil isParallaxSuspended = false } + .onReceive(NotificationCenter.default.publisher(for: .atollArtworkWallpaperDismissed)) { _ in + withAnimation(.easeInOut(duration: 0.28)) { + isArtworkFullscreen = false + } + } .onChange(of: isExpanded) { _, expanded in updatePanelSize() } @@ -222,9 +259,13 @@ struct LockScreenMusicPanel: View { collapsedLayout } } - .padding(.horizontal, isExpanded ? 24 : 20) - .padding(.vertical, isExpanded ? 22 : 16) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.horizontal, usesSpotifyCanvasFallbackContentPresentation ? (isExpanded ? 24 : 22) : (isExpanded ? 24 : 20)) + .padding(.vertical, usesSpotifyCanvasFallbackContentPresentation ? (isExpanded ? 18 : 14) : (isExpanded ? 22 : 16)) + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: usesSpotifyCanvasFallbackContentPresentation ? .center : .topLeading + ) } private var collapsedLayout: some View { @@ -239,25 +280,40 @@ struct LockScreenMusicPanel: View { } private var expandedLayout: some View { - HStack(alignment: .center, spacing: expandedContentSpacing) { - albumArtButton(size: 230, cornerRadius: expandedAlbumArtCornerRadius) - .frame(width: 230, height: 230) + Group { + if hidesInlineArtworkForSpotifyCanvasFallback { + 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) { - expandedHeader - progressBar - .padding(.top, 10) - .frame(maxWidth: .infinity) - playbackControls(alignment: .leading) + VStack(alignment: .leading, spacing: 20) { + expandedHeader + progressBar + .padding(.top, 10) + .frame(maxWidth: .infinity) + playbackControls(alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } } - .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxHeight: .infinity, alignment: .center) } private var collapsedHeader: some View { - HStack(alignment: .center, spacing: 16) { - albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius) + HStack(alignment: .center, spacing: hidesInlineArtworkForSpotifyCanvasFallback ? 10 : 16) { + if !hidesInlineArtworkForSpotifyCanvasFallback { + albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius) + } GeometryReader { geo in VStack(alignment: .leading, spacing: 1) { @@ -329,30 +385,48 @@ struct LockScreenMusicPanel: View { } private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View { - Button(action: toggleExpanded) { - ZStack(alignment: .bottomTrailing) { - albumArtImage(size: size, cornerRadius: cornerRadius) - if showAppIcon, let icon = lockScreenAppIcon { - icon - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: appIconSize, height: appIconSize) + let artworkCornerRadius = resolvedArtworkCornerRadius(from: cornerRadius) - .shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4) - .offset(x: appIconOffset, y: appIconOffset) - .transition(.scale.combined(with: .opacity)) - } + return ZStack(alignment: .bottomTrailing) { + if isArtworkFullscreen && !keepAlbumArtVisibleDuringFullscreenArtwork { + 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) .scaleEffect(musicManager.isPlaying ? 1 : 0.85) .animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying) + .animation(.easeInOut(duration: 0.28), value: isArtworkFullscreen) + .onTapGesture { + toggleExpanded() + } + .onRightClick { + expandArtworkToFullscreen() + } } @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() { cancelCollapseTimer() guard isExpanded else { return } @@ -484,7 +593,7 @@ struct LockScreenMusicPanel: View { private func playbackControls(alignment: Alignment) -> some View { let spacing: CGFloat = isExpanded ? 24 : 20 - let verticalSpacing: CGFloat = (shouldShowVolumeSlider || enableLyrics) ? 14 : 10 + let verticalSpacing: CGFloat = (shouldShowVolumeSlider || shouldShowInlineLyrics) ? 14 : 10 return VStack(spacing: verticalSpacing) { controlsRow(alignment: alignment, spacing: spacing) @@ -505,7 +614,7 @@ struct LockScreenMusicPanel: View { } } - if enableLyrics { + if shouldShowInlineLyrics { lyricsSection(alignment: alignment) .transition(.move(edge: .top).combined(with: .opacity)) } @@ -949,7 +1058,7 @@ struct LockScreenMusicPanel: View { } private var lyricsExtraHeight: CGFloat { - lyricsHeight(forExpanded: isExpanded, enabled: enableLyrics) + lyricsHeight(forExpanded: isExpanded, enabled: shouldShowInlineLyrics) } private var accessorySectionExtraHeight: CGFloat { @@ -979,7 +1088,7 @@ struct LockScreenMusicPanel: View { private func panelAdditionalHeight(forExpanded expanded: Bool) -> CGFloat { sliderHeight(forExpanded: expanded, visible: shouldShowVolumeSlider) + accessorySectionExtraHeight + - lyricsHeight(forExpanded: expanded, enabled: enableLyrics) + lyricsHeight(forExpanded: expanded, enabled: shouldShowInlineLyrics) } private func updatePanelSize(animated: Bool = true) { @@ -1034,7 +1143,9 @@ struct LockScreenMusicPanel: View { @ViewBuilder private var panelBackgroundLayer: some View { - if usesCustomLiquidGlass { + if usesSpotifyCanvasFallbackContentPresentation { + canvasFallbackScrim + } else if usesCustomLiquidGlass { customLiquidPanelBackdrop } else if usesStandardLiquidGlass { 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 private var standardLiquidPanelBackdrop: some View { if #available(macOS 26.0, *) { @@ -1132,7 +1268,8 @@ struct LockScreenMusicPanel: View { Image(nsImage: musicManager.albumArt) .resizable() .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 @@ -1170,6 +1307,13 @@ struct LockScreenMusicPanel: View { 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, *) private func clearLiquidGlassSurface(cornerRadius: CGFloat) -> some View { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) @@ -1384,3 +1528,87 @@ private struct GlassTextBackdrop: View { .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") +} diff --git a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift index 362c853..0d6ce7c 100644 --- a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift @@ -45,10 +45,6 @@ struct LockScreenTimerWidget: View { } } - private func displayFont(size: CGFloat) -> Font { - .system(size: size, weight: .semibold, design: .rounded) - } - private var hasHoursComponent: Bool { abs(timerManager.remainingTime) >= 3600 } @@ -57,12 +53,6 @@ struct LockScreenTimerWidget: View { 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 { if hasDoubleDigitHours { return 200 } if hasHoursComponent { return 185 } @@ -74,10 +64,6 @@ struct LockScreenTimerWidget: View { return .system(size: baseSize, weight: .bold, design: .rounded) } - private var timerLabel: String { - timerManager.timerName.isEmpty ? "Timer" : timerManager.timerName - } - private var countdownText: String { timerManager.formattedRemainingTime() } @@ -195,8 +181,7 @@ struct LockScreenTimerWidget: View { controlButtons .padding(.trailing, 12) - titleSection - .frame(maxWidth: .infinity) + Spacer() countdownSection .padding(.leading, 12) @@ -261,19 +246,6 @@ struct LockScreenTimerWidget: View { .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 { Capsule() .fill(accentGradient) diff --git a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift index 5a31718..e37bf27 100644 --- a/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenWeatherWidget.swift @@ -1261,7 +1261,15 @@ struct LockScreenWeatherWidget: View { private func chargingStatusFallback(for info: LockScreenWeatherSnapshot.ChargingInfo) -> String { 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") } @@ -1456,7 +1464,17 @@ struct LockScreenWeatherWidget: View { } 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 { diff --git a/DynamicIsland/components/Notch/DynamicIslandHeader.swift b/DynamicIsland/components/Notch/DynamicIslandHeader.swift index c9eda7b..6e0618f 100644 --- a/DynamicIsland/components/Notch/DynamicIslandHeader.swift +++ b/DynamicIsland/components/Notch/DynamicIslandHeader.swift @@ -36,10 +36,12 @@ struct DynamicIslandHeader: View { @Default(.showClipboardIcon) var showClipboardIcon @Default(.showColorPickerIcon) var showColorPickerIcon @Default(.clipboardDisplayMode) var clipboardDisplayMode + @Default(.showBatteryIndicator) var showBatteryIndicator + @Default(.enableMinimalisticUI) var enableMinimalisticUI var body: some View { HStack(spacing: 0) { - if !Defaults[.enableMinimalisticUI] { + if !enableMinimalisticUI { HStack { let shouldShowTabs = coordinator.alwaysShowTabs || vm.notchState == .open || !shelfState.items.isEmpty 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) Rectangle() .fill(NSScreen.screens @@ -67,7 +69,7 @@ struct DynamicIslandHeader: View { } HStack(spacing: 4) { - if vm.notchState == .open && !Defaults[.enableMinimalisticUI] { + if vm.notchState == .open && !enableMinimalisticUI { if Defaults[.showMirror] { Button(action: { vm.toggleCameraPreview() @@ -230,7 +232,7 @@ struct DynamicIslandHeader: View { - if Defaults[.showBatteryIndicator] { + if showBatteryIndicator { DynamicIslandBatteryView( batteryWidth: 30, isCharging: batteryModel.isCharging, diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 5b020a9..91255d5 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -113,6 +113,7 @@ struct MinimalisticMusicPlayerView: View { // Compact progress bar progressBar .padding(.top, 6) + .clipped() // Compact playback controls if shouldShowControlHUDRow { @@ -133,7 +134,7 @@ struct MinimalisticMusicPlayerView: View { reminderList } .padding(.horizontal, 12) - .padding(.top, -15) + .padding(.top, 12) .padding(.bottom, ReminderLiveActivityManager.baselineMinimalisticBottomPadding) .frame(maxWidth: .infinity) .frame(height: calculateDynamicHeight(), alignment: .top) @@ -254,7 +255,7 @@ struct MinimalisticMusicPlayerView: View { height += 6 + 4 // progress bar + top padding // 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) if enableLyrics { @@ -706,7 +707,7 @@ private struct MinimalisticReminderDetailsView: View { // MARK: - Playback Controls (Larger) private var playbackControls: some View { - HStack(spacing: 16) { + HStack(spacing: 10) { ForEach(Array(displayedSlots.enumerated()), id: \.offset) { _, slot in slotView(for: slot) } @@ -834,10 +835,10 @@ private struct MinimalisticReminderDetailsView: View { private var playPauseButton: some View { MinimalisticSquircircleButton( icon: musicManager.isPlaying ? (musicManager.isLiveStream ? "stop.fill" : "pause.fill") : "play.fill", - fontSize: 28, + fontSize: 26, fontWeight: .semibold, - frameSize: CGSize(width: 60, height: 60), - cornerRadius: 24, + frameSize: CGSize(width: 54, height: 54), + cornerRadius: 22, foregroundColor: .white, pressEffect: .none, symbolEffectStyle: .replace, @@ -867,8 +868,8 @@ private struct MinimalisticReminderDetailsView: View { icon: icon, fontSize: size, fontWeight: .medium, - frameSize: CGSize(width: 40, height: 40), - cornerRadius: 16, + frameSize: CGSize(width: 36, height: 36), + cornerRadius: 14, foregroundColor: isActive ? resolvedActiveColor : .white.opacity(0.85), pressEffect: pressEffect, symbolEffectStyle: symbolEffect, @@ -991,8 +992,8 @@ private struct MinimalisticReminderDetailsView: View { icon: routeManager.activeDevice?.iconName ?? "speaker.wave.2", fontSize: 18, fontWeight: .medium, - frameSize: CGSize(width: 40, height: 40), - cornerRadius: 16, + frameSize: CGSize(width: 36, height: 36), + cornerRadius: 14, foregroundColor: .white.opacity(0.85), symbolEffectStyle: .replace ) { @@ -1051,8 +1052,8 @@ private struct MinimalisticReminderDetailsView: View { icon: "airplayaudio", fontSize: 18, fontWeight: .medium, - frameSize: CGSize(width: 40, height: 40), - cornerRadius: 16, + frameSize: CGSize(width: 36, height: 36), + cornerRadius: 14, foregroundColor: .white.opacity(0.85), symbolEffectStyle: .replace ) { @@ -1113,8 +1114,17 @@ private struct MinimalisticReminderDetailsView: View { struct MinimalisticAlbumArtView: View { @ObservedObject var musicManager = MusicManager.shared @ObservedObject var vm: DynamicIslandViewModel + @Default(.showLiveCanvasInDynamicIsland) private var showLiveCanvasInDynamicIsland 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 { ZStack(alignment: .bottomTrailing) { if Defaults[.lightingEffect] { @@ -1128,16 +1138,27 @@ struct MinimalisticAlbumArtView: View { Color.clear .aspectRatio(1, contentMode: .fit) .background( - Image(nsImage: musicManager.albumArt) - .resizable() - .scaledToFill() + DynamicIslandArtworkSourceView( + cornerRadius: albumArtCornerRadius, + contentMode: .fill + ) ) .clipped() - .clipShape(RoundedRectangle(cornerRadius: 12)) - .scaleEffect(x: 1.3, y: 1.4) + .clipShape(RoundedRectangle(cornerRadius: albumArtCornerRadius)) + .scaleEffect(x: 1.08, y: 1.1) .rotationEffect(.degrees(92)) - .blur(radius: 35) - .opacity(min(0.6, 1 - max(musicManager.albumArt.getBrightness(), 0.3))) + .blur(radius: 16) + .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 { @@ -1147,13 +1168,10 @@ struct MinimalisticAlbumArtView: View { Color.clear .aspectRatio(1, contentMode: .fit) .background( - - Image(nsImage: musicManager.albumArt) - .resizable() - .aspectRatio(contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: musicManager.albumArt.size.width/musicManager.albumArt.size.height > 1.0 ? 4 : 12)) - - + DynamicIslandArtworkSourceView( + cornerRadius: albumArtCornerRadius, + contentMode: .fit + ) ) .clipped() .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 9d10cd5..da388b2 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -23,6 +23,129 @@ import Combine import Defaults 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 @@ -43,8 +166,23 @@ struct MusicPlayerView: View { struct AlbumArtView: View { @ObservedObject var musicManager = MusicManager.shared @ObservedObject var vm: DynamicIslandViewModel + @Default(.showLiveCanvasInDynamicIsland) private var showLiveCanvasInDynamicIsland 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 { ZStack(alignment: .bottomTrailing) { if Defaults[.lightingEffect] { @@ -58,24 +196,26 @@ struct AlbumArtView: View { Color.clear .aspectRatio(1, contentMode: .fit) .background( - Image(nsImage: musicManager.albumArt) - .resizable() - .aspectRatio(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 - - - ) - ) + DynamicIslandArtworkSourceView( + cornerRadius: albumArtCornerRadius, + contentMode: .fill + ) ) .clipped() .scaleEffect(x: 1.3, y: 1.4) .rotationEffect(.degrees(92)) .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 { @@ -111,18 +251,10 @@ struct AlbumArtView: View { Color.clear .aspectRatio(1, contentMode: .fit) .overlay { - Image(nsImage: musicManager.albumArt) - .resizable() - .aspectRatio(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 + DynamicIslandArtworkSourceView( + cornerRadius: albumArtCornerRadius, + contentMode: .fit + ) } .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) .clipped() diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 2a4464b..d6d5cb5 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -113,27 +113,27 @@ private enum SettingsTab: String, CaseIterable, Identifiable { var systemImage: String { switch self { - case .general: return "gear" - case .liveActivities: return "waveform.path.ecg" - case .appearance: return "paintpalette" - case .lockScreen: return "lock.laptopcomputer" - case .media: return "play.laptopcomputer" - case .devices: return "headphones" - case .extensions: return "puzzlepiece.extension" - case .timer: return "timer" - case .calendar: return "calendar" - case .hudAndOSD: return "dial.medium.fill" - case .battery: return "battery.100.bolt" - case .stats: return "chart.xyaxis.line" - case .clipboard: return "clipboard" - case .screenAssistant: return "brain.head.profile" - case .colorPicker: return "eyedropper" - case .downloads: return "square.and.arrow.down" - case .shelf: return "books.vertical" - case .shortcuts: return "keyboard" - case .notes: return "note.text" - case .terminal: return "apple.terminal" - case .about: return "info.circle" + case .general: return String(localized: "gear") + case .liveActivities: return String(localized: "waveform.path.ecg") + case .appearance: return String(localized: "paintpalette") + case .lockScreen: return String(localized: "lock.laptopcomputer") + case .media: return String(localized: "play.laptopcomputer") + case .devices: return String(localized: "headphones") + case .extensions: return String(localized: "puzzlepiece.extension") + case .timer: return String(localized: "timer") + case .calendar: return String(localized: "calendar") + case .hudAndOSD: return String(localized: "dial.medium.fill") + case .battery: return String(localized: "battery.100.bolt") + case .stats: return String(localized: "chart.xyaxis.line") + case .clipboard: return String(localized: "clipboard") + case .screenAssistant: return String(localized: "brain.head.profile") + case .colorPicker: return String(localized: "eyedropper") + case .downloads: return String(localized: "square.and.arrow.down") + case .shelf: return String(localized: "books.vertical") + case .shortcuts: return String(localized: "keyboard") + case .notes: return String(localized: "note.text") + case .terminal: return String(localized: "apple.terminal") + case .about: return String(localized: "info.circle") } } @@ -627,10 +627,10 @@ struct SettingsView: View { } VStack(alignment: .leading, spacing: 2) { - Text(suggestion.title) + Text(LocalizedStringKey(suggestion.title)) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(Color.primary) - Text(suggestion.tab.title) + Text(LocalizedStringKey(suggestion.tab.title)) .font(.system(size: 11)) .foregroundStyle(Color.secondary) } @@ -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 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: "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 SettingsSearchEntry(tab: .devices, title: "Show Bluetooth device connections", keywords: ["bluetooth", "hud"], highlightID: SettingsTab.devices.highlightID(for: "Show Bluetooth device connections")), @@ -763,6 +776,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: "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: "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: "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")), @@ -1305,10 +1319,62 @@ struct GeneralSettings: 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 { SettingsTab.battery.highlightID(for: title) } + private var chargingDurationBinding: Binding { + Binding( + get: { Double(chargingBatteryHUDDuration) }, + set: { chargingBatteryHUDDuration = Int($0.rounded()) } + ) + } + + private var lowBatteryDurationBinding: Binding { + Binding( + get: { Double(lowBatteryHUDDuration) }, + set: { lowBatteryHUDDuration = Int($0.rounded()) } + ) + } + + private var fullBatteryDurationBinding: Binding { + Binding( + get: { Double(fullBatteryHUDDuration) }, + set: { fullBatteryHUDDuration = Int($0.rounded()) } + ) + } + + private var lowBatteryThresholdBinding: Binding { + Binding( + get: { Double(lowBatteryHUDThreshold) }, + set: { lowBatteryHUDThreshold = Int($0.rounded()) } + ) + } + + private var fullBatteryThresholdBinding: Binding { + Binding( + get: { Double(fullBatteryHUDThreshold) }, + set: { fullBatteryHUDThreshold = Int($0.rounded()) } + ) + } + + private func sectionOpacity(_ isEnabled: Bool) -> Double { + isEnabled ? 1 : 0.5 + } + var body: some View { Form { if BatteryActivityManager.shared.hasBattery() { @@ -1340,6 +1406,157 @@ struct Charge: View { } header: { 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 { ContentUnavailableView { VStack(spacing: 16) { @@ -2009,12 +2226,12 @@ private struct ExternalDisplayIntegrationsSection: View { case .betterDisplay: if betterDisplayManager.isRunning { return "Running" } if betterDisplayManager.isDetected { return "Not running" } - return "Not detected" + return String(localized: "Not detected") case .lunar: if lunarManager.isConnected { return "Connected" } if lunarManager.isRunning { return "Running" } if lunarManager.isDetected { return "Not running" } - return "Not detected" + return String(localized: "Not detected") } } @@ -2036,23 +2253,23 @@ private struct ExternalDisplayIntegrationsSection: View { switch thirdPartyDDCProvider { case .betterDisplay: if !betterDisplayManager.isDetected { - return "Install [BetterDisplay](https://betterdisplay.pro) to control external display brightness (and optional volume) through Atoll's HUD." + return String(localized: "Install [BetterDisplay](https://betterdisplay.pro) to control external display brightness (and optional volume) through Atoll's HUD.") } if !betterDisplayManager.isRunning { - return "BetterDisplay is installed but not currently running. Launch BetterDisplay to enable integration." + return String(localized: "BetterDisplay is installed but not currently running. Launch BetterDisplay to enable integration.") } - return "BetterDisplay OSD events will be routed through Atoll's active HUD style. Brightness is always routed; volume is routed when external volume control listener is enabled below. Make sure BetterDisplay's OSD integration is enabled in Settings › Application › Integration." + return String(localized: "BetterDisplay OSD events will be routed through Atoll's active HUD style. Brightness is always routed; volume is routed when external volume control listener is enabled below. Make sure BetterDisplay's OSD integration is enabled in Settings › Application › Integration.") case .lunar: if !lunarManager.isDetected { - return "Install [Lunar](https://lunar.fyi) to control external display brightness, contrast, and optional volume through Atoll's HUD via DDC." + return String(localized: "Install [Lunar](https://lunar.fyi) to control external display brightness, contrast, and optional volume through Atoll's HUD via DDC.") } if !lunarManager.isRunning { - return "Lunar is installed but not currently running. Launch Lunar to enable integration." + return String(localized: "Lunar is installed but not currently running. Launch Lunar to enable integration.") } if lunarManager.isConnected { - return "Connected to Lunar's DDC socket. Brightness and contrast adjustments are shown through Atoll's HUD; volume follows when external volume control listener is enabled below." + return String(localized: "Connected to Lunar's DDC socket. Brightness and contrast adjustments are shown through Atoll's HUD; volume follows when external volume control listener is enabled below.") } - return "Lunar is running but the socket connection is not yet established. It will connect automatically." + return String(localized: "Lunar is running but the socket connection is not yet established. It will connect automatically.") } } @@ -2450,6 +2667,7 @@ struct Media: View { @Default(.lockScreenGlassStyle) private var lockScreenGlassStyle @Default(.lockScreenGlassCustomizationMode) private var lockScreenGlassCustomizationMode @Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenMusicAlbumParallaxEnabled + @Default(.lockScreenMusicFullscreenArtworkEnabled) private var lockScreenMusicFullscreenArtworkEnabled @Default(.showStandardMediaControls) private var showStandardMediaControls @Default(.autoHideInactiveNotchMediaPlayer) private var autoHideInactiveNotchMediaPlayer @Default(.parallaxEffectIntensity) private var parallaxEffectIntensity @@ -2505,6 +2723,11 @@ struct Media: View { .font(.caption) } } + + if mediaController == .spotify { + SpotifyAuthSettingsSection() + } + Section { Defaults.Toggle(key: .showStandardMediaControls) { Text("Show media controls in Dynamic Island") @@ -2603,6 +2826,11 @@ struct Media: View { Text("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 Slider(value: $parallaxEffectIntensity, in: 0...12, step: 1.0) { @@ -2691,6 +2919,27 @@ struct Media: View { .opacity(enableLockScreenMediaWidget ? 1 : 0.5) .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: { Text("Lock Screen Integration") } footer: { @@ -2791,14 +3040,14 @@ struct CalendarSettings: View { var title: String { switch self { - case .mins15: return "15 mins" - case .mins30: return "30 mins" - case .hour1: return "1 hour" - case .hours3: return "3 hours" - case .hours6: return "6 hours" - case .hours12: return "12 hours" - case .restOfDay: return "Rest of the day" - case .allTime: return "All time" + case .mins15: return String(localized: "15 mins") + case .mins30: return String(localized: "30 mins") + case .hour1: return String(localized: "1 hour") + case .hours3: return String(localized: "3 hours") + case .hours6: return String(localized: "6 hours") + case .hours12: return String(localized: "12 hours") + case .restOfDay: return String(localized: "Rest of the day") + case .allTime: return String(localized: "All time") } } } @@ -3289,9 +3538,9 @@ private extension DevicesSettingsView { var title: String { switch self { case .symbol: - return "Symbol" + return String(localized: "Symbol") case .threeD: - return "3D" + return String(localized: "3D") } } } @@ -4571,6 +4820,7 @@ struct LockScreenSettings: View { @Default(.lockScreenTimerGlassCustomizationMode) private var lockScreenTimerGlassCustomizationMode @Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass @Default(.enableLockScreenMediaWidget) private var enableLockScreenMediaWidget + @Default(.lockScreenMusicFullscreenArtworkEnabled) private var lockScreenMusicFullscreenArtworkEnabled @Default(.enableLockScreenTimerWidget) private var enableLockScreenTimerWidget @Default(.enableLockScreenWeatherWidget) private var enableLockScreenWeatherWidget @Default(.enableLockScreenFocusWidget) private var enableLockScreenFocusWidget @@ -4626,14 +4876,14 @@ struct LockScreenSettings: View { var title: String { switch self { - case .mins15: return "15 mins" - case .mins30: return "30 mins" - case .hour1: return "1 hour" - case .hours3: return "3 hours" - case .hours6: return "6 hours" - case .hours12: return "12 hours" - case .restOfDay: return "Rest of the day" - case .allTime: return "All time" + case .mins15: return String(localized: "15 mins") + case .mins30: return String(localized: "30 mins") + case .hour1: return String(localized: "1 hour") + case .hours3: return String(localized: "3 hours") + case .hours6: return String(localized: "6 hours") + case .hours12: return String(localized: "12 hours") + case .restOfDay: return String(localized: "Rest of the day") + case .allTime: return String(localized: "All time") } } } @@ -4647,9 +4897,9 @@ struct LockScreenSettings: View { var title: String { switch self { - case .leading: return "Left" - case .center: return "Center" - case .trailing: return "Right" + case .leading: return String(localized: "Left") + case .center: return String(localized: "Center") + case .trailing: return String(localized: "Right") } } } @@ -4804,6 +5054,27 @@ struct LockScreenSettings: View { .opacity(enableLockScreenMediaWidget ? 1 : 0.5) .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 { Text("Enable Dynamic Island media controls to manage the lock screen panel.") @@ -6701,11 +6972,11 @@ struct StatsSettings: View { private var formattedUpdateInterval: String { let seconds = Int(statsUpdateInterval.rounded()) if seconds >= 60 { - return "60 s (1 min)" + return String(localized: "60 s (1 min)") } else if seconds == 1 { - return "1 s" + return String(localized: "1 s") } else { - return "\(seconds) s" + return String(localized: "\(seconds) s") } } @@ -7097,16 +7368,16 @@ struct ClipboardSettings: View { let interval = Date().timeIntervalSince(date) if interval < 60 { - return "Just now" + return String(localized: "Just now") } else if interval < 3600 { let minutes = Int(interval / 60) - return "\(minutes)m ago" + return String(localized: "\(minutes)m ago") } else if interval < 86400 { let hours = Int(interval / 3600) - return "\(hours)h ago" + return String(localized: "\(hours)h ago") } else { let days = Int(interval / 86400) - return "\(days)d ago" + return String(localized: "\(days)d ago") } } } @@ -7275,16 +7546,16 @@ struct ScreenAssistantSettings: View { let interval = Date().timeIntervalSince(date) if interval < 60 { - return "Just now" + return String(localized: "Just now") } else if interval < 3600 { let minutes = Int(interval / 60) - return "\(minutes)m ago" + return String(localized: "\(minutes)m ago") } else if interval < 86400 { let hours = Int(interval / 3600) - return "\(hours)h ago" + return String(localized: "\(hours)h ago") } else { let days = Int(interval / 86400) - return "\(days)d ago" + return String(localized: "\(days)d ago") } } } @@ -7649,21 +7920,21 @@ struct SettingsPermissionCallout: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - Label(title, systemImage: icon) + Label(LocalizedStringKey(title), systemImage: icon) .font(.subheadline.weight(.semibold)) .foregroundStyle(iconColor) - Text(message) + Text(LocalizedStringKey(message)) .font(.caption) .foregroundStyle(.secondary) HStack(spacing: 8) { - Button(requestButtonTitle) { + Button(LocalizedStringKey(requestButtonTitle)) { requestAction() } .buttonStyle(.borderedProminent) - Button(openSettingsButtonTitle) { + Button(LocalizedStringKey(openSettingsButtonTitle)) { openSettingsAction() } .buttonStyle(.bordered) diff --git a/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift b/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift new file mode 100644 index 0000000..890e14a --- /dev/null +++ b/DynamicIsland/components/Settings/SpotifyAuthSettingsSection.swift @@ -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 . + */ + +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) + } +} diff --git a/DynamicIsland/components/Settings/SpotifyLoginSheet.swift b/DynamicIsland/components/Settings/SpotifyLoginSheet.swift new file mode 100644 index 0000000..7eecc8d --- /dev/null +++ b/DynamicIsland/components/Settings/SpotifyLoginSheet.swift @@ -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 . + */ + +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) + } + } +} diff --git a/DynamicIsland/components/Shelf/Views/ShelfInlineLiveActivity.swift b/DynamicIsland/components/Shelf/Views/ShelfInlineLiveActivity.swift new file mode 100644 index 0000000..b190182 --- /dev/null +++ b/DynamicIsland/components/Shelf/Views/ShelfInlineLiveActivity.swift @@ -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 . + */ + +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) + } +} diff --git a/DynamicIsland/managers/AnimatedArtworkManager.swift b/DynamicIsland/managers/AnimatedArtworkManager.swift new file mode 100644 index 0000000..2993e63 --- /dev/null +++ b/DynamicIsland/managers/AnimatedArtworkManager.swift @@ -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 . + */ + +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 + } + } +} diff --git a/DynamicIsland/managers/FullScreenArtworkWindowManager.swift b/DynamicIsland/managers/FullScreenArtworkWindowManager.swift new file mode 100644 index 0000000..ef80189 --- /dev/null +++ b/DynamicIsland/managers/FullScreenArtworkWindowManager.swift @@ -0,0 +1,1801 @@ +/* + * 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 . + */ + +import AppKit +import AVFoundation +import Combine +import CoreImage +import Defaults +import SkyLightWindow +import SwiftUI + +// MARK: - Click Receiver Window + +private class ClickReceiverWindow: NSWindow { + var onClick: (() -> Void)? + + override func mouseDown(with event: NSEvent) { + // Keep left click inert so the fallback only dismisses on right click. + } + + override func rightMouseDown(with event: NSEvent) { + onClick?() + } +} + +private final class LoopingVideoView: NSView { + private var playerLayer: AVPlayerLayer? + private var queuePlayer: AVQueuePlayer? + private var playerLooper: AVPlayerLooper? + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + } + + override func layout() { + super.layout() + playerLayer?.frame = bounds + } + + func play(url: URL) { + stop() + + let playerItem = AVPlayerItem(url: url) + let player = AVQueuePlayer() + player.isMuted = true + player.actionAtItemEnd = .none + + let layer = AVPlayerLayer(player: player) + layer.videoGravity = .resizeAspectFill + layer.frame = bounds + layer.backgroundColor = NSColor.clear.cgColor + + self.layer?.sublayers?.forEach { $0.removeFromSuperlayer() } + self.layer?.addSublayer(layer) + + playerLayer = layer + queuePlayer = player + playerLooper = AVPlayerLooper(player: player, templateItem: playerItem) + player.play() + } + + func stop() { + queuePlayer?.pause() + playerLooper = nil + queuePlayer = nil + playerLayer?.removeFromSuperlayer() + playerLayer = nil + } + + func pause() { + queuePlayer?.pause() + } + + func resume() { + queuePlayer?.play() + } +} + +private final class WallpaperTransitionImageView: NSView { + private let imageLayer = CALayer() + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + imageLayer.contentsGravity = .resizeAspectFill + imageLayer.masksToBounds = true + imageLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + layer?.addSublayer(imageLayer) + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + imageLayer.contentsGravity = .resizeAspectFill + imageLayer.masksToBounds = true + imageLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + layer?.addSublayer(imageLayer) + } + + override func layout() { + super.layout() + imageLayer.frame = bounds + } + + func updateImage(_ image: NSImage) { + imageLayer.contents = image.cgImage(forProposedRect: nil, context: nil, hints: nil) + } +} + +private final class SpotifyCanvasFallbackArtworkOverlayView: NSView { + var onDismiss: (() -> Void)? + + private let imageLayer = CALayer() + private let borderLayer = CAShapeLayer() + private var artwork: NSImage + + init(artwork: NSImage) { + self.artwork = artwork + super.init(frame: .zero) + + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + layer?.shadowColor = NSColor.black.withAlphaComponent(0.34).cgColor + layer?.shadowOpacity = 1 + layer?.shadowRadius = 26 + layer?.shadowOffset = CGSize(width: 0, height: -22) + + imageLayer.contentsGravity = .resizeAspectFill + imageLayer.masksToBounds = true + imageLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2 + imageLayer.contents = artwork.cgImage(forProposedRect: nil, context: nil, hints: nil) + + borderLayer.fillColor = NSColor.clear.cgColor + borderLayer.strokeColor = NSColor.white.withAlphaComponent(0.18).cgColor + borderLayer.lineWidth = 1.1 + + layer?.addSublayer(imageLayer) + layer?.addSublayer(borderLayer) + } + + required init?(coder: NSCoder) { + nil + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func layout() { + super.layout() + + let radius = cornerRadius + imageLayer.frame = bounds + imageLayer.cornerRadius = radius + borderLayer.frame = bounds + borderLayer.path = CGPath( + roundedRect: bounds.insetBy(dx: 0.55, dy: 0.55), + cornerWidth: radius, + cornerHeight: radius, + transform: nil + ) + + layer?.shadowPath = CGPath( + roundedRect: bounds, + cornerWidth: radius, + cornerHeight: radius, + transform: nil + ) + } + + override func mouseDown(with event: NSEvent) { + if event.clickCount >= 2 { + onDismiss?() + } + } + + override func rightMouseDown(with event: NSEvent) { + onDismiss?() + } + + func updateArtwork(_ artwork: NSImage) { + self.artwork = artwork + imageLayer.contents = artwork.cgImage(forProposedRect: nil, context: nil, hints: nil) + needsLayout = true + } + + private var aspectRatio: CGFloat { + let height = max(artwork.size.height, 1) + return artwork.size.width / height + } + + private var cornerRadius: CGFloat { + aspectRatio > 1 ? 16 : 34 + } +} + +private struct FullScreenLyricsOverlayContent: View { + let text: String + let fontSize: CGFloat + + private var isPlaceholder: Bool { + text == "Loading lyrics..." || text == "No lyrics found" + } + + var body: some View { + VStack { + Text(text) + .font(.system(size: fontSize, weight: .semibold, design: .rounded)) + .foregroundColor(.white.opacity(isPlaceholder ? 0.72 : 0.94)) + .multilineTextAlignment(.center) + .lineLimit(4) + .minimumScaleFactor(0.62) + .allowsTightening(true) + .shadow(color: .black.opacity(0.42), radius: 18, x: 0, y: 6) + .frame(maxWidth: .infinity, alignment: .center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.horizontal, 18) + .padding(.vertical, 8) + .background(Color.clear) + } +} + +private struct SpotifyCanvasFallbackLayoutFrames { + let artworkFrame: NSRect + let panelFrame: NSRect + let groupFrame: NSRect + let lyricsFrame: NSRect? +} + +// MARK: - Window Manager + +@MainActor +final class FullScreenArtworkWindowManager: ObservableObject { + private enum ArtworkOverlayMode { + case none + case spotifyFallback + } + + static let shared = FullScreenArtworkWindowManager() + + @Published private(set) var isShowing = false + @Published private(set) var isShowingSpotifyCanvasFallback = false + var onDismiss: (() -> Void)? + + private let wallpaperPlistURL: URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/com.apple.wallpaper/Store/Index.plist") + }() + private let backupPlistURL: URL = { + FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_wallpaper_backup.plist") + }() + private let aerialManifestURL: URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/com.apple.wallpaper/aerials/manifest/entries.json") + }() + private let aerialVideosDirectoryURL: URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/com.apple.wallpaper/aerials/videos", isDirectory: true) + }() + private let aerialThumbnailsDirectoryURL: URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/com.apple.wallpaper/aerials/thumbnails", isDirectory: true) + }() + private let customLiveWallpaperAssetID = "6D6834A4-2F0F-479A-B053-7D4DC5CB8EB7" + private let liveWallpaperManifestBackupURL: URL = { + FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_aerial_manifest_backup.json") + }() + private let liveWallpaperVideoBackupURL: URL = { + FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_aerial_video_backup.mov") + }() + private let liveWallpaperThumbnailBackupURL: URL = { + FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_aerial_thumbnail_backup.png") + }() + private var artworkFileURL: URL? + private var cachedArtworkPNG: URL? + private var cachedArtworkFingerprint: String? + private var cachedBlurredArtworkPNG: URL? + private var cachedBlurredArtworkFingerprint: String? + private var cachedBlurredArtworkPixelSize: CGSize = .zero + private var activeSongTitle: String? + private var activeArtist: String? + private var trackChangeCancellable: AnyCancellable? + private var artworkCacheCancellable: AnyCancellable? + private var videoArtworkCancellable: AnyCancellable? + private var playbackStateCancellable: AnyCancellable? + private var hasSuspendedWallpaperAgent = false + private var appTerminationObserver: NSObjectProtocol? + private var clickWindow: ClickReceiverWindow? + private var clickWindowDelegated = false + private var videoWindow: NSWindow? + private var videoView: LoopingVideoView? + private var activeVideoWindowURL: URL? + private var videoWindowHideTask: Task? + private var artworkOverlayWindow: NSWindow? + private var artworkOverlayWindowDelegated = false + private var lyricsOverlayWindow: NSWindow? + private var lyricsOverlayWindowDelegated = false + private var currentArtworkOverlayMode: ArtworkOverlayMode = .none + private var wallpaperTransitionWindow: NSWindow? + private var wallpaperTransitionView: WallpaperTransitionImageView? + private var wallpaperTransitionHideTask: Task? + private var panelFrameChangeObserver: NSObjectProtocol? + private var liveWallpaperTask: Task? + private var deferredTrackRefreshTask: Task? + private var isLiveWallpaperAllowed = false + private var activeLiveWallpaperFingerprint: String? + private var activeWallpaperKey: String? + private var pendingFallbackStaticURL: URL? + private var pendingFallbackWallpaperKey: String? + private var fallbackRightClickMonitor: Any? + private var artworkLayoutOverCanvasPreferenceCancellable: AnyCancellable? + private var lyricsTextCancellable: AnyCancellable? + private var lyricsPreferenceCancellable: AnyCancellable? + private let spotifyCanvasFallbackHorizontalMargin: CGFloat = 48 + + private init() { + observeArtworkChanges() + observeVideoArtworkChanges() + observePanelFrameChanges() + observeArtworkLayoutOverCanvasPreference() + observeLyricsChanges() + observeLyricsPreference() + observePlaybackStateChanges() + observeAppTermination() + } + + func show(artwork: NSImage, videoURL: URL? = nil, allowLiveWallpaper: Bool = false) { + guard !isShowing else { return } + guard let screen = NSScreen.main else { return } + applyPresentation( + artwork: artwork, + videoURL: videoURL, + allowLiveWallpaper: allowLiveWallpaper, + on: screen, + backupWallpaperConfiguration: true + ) + observeTrackChanges() + } + + func hide() { + guard isShowing else { return } + resumeWallpaperAgentIfNeeded() + isShowing = false + isLiveWallpaperAllowed = false + activeLiveWallpaperFingerprint = nil + activeWallpaperKey = nil + pendingFallbackStaticURL = nil + pendingFallbackWallpaperKey = nil + let shouldRestoreStandardPanelLayout = isShowingSpotifyCanvasFallback + isShowingSpotifyCanvasFallback = false + removeFallbackRightClickMonitor() + trackChangeCancellable?.cancel() + trackChangeCancellable = nil + liveWallpaperTask?.cancel() + liveWallpaperTask = nil + deferredTrackRefreshTask?.cancel() + deferredTrackRefreshTask = nil + + hideVideoWindow() + hideArtworkOverlay() + hideLyricsOverlay() + hideWallpaperTransition() + hideClickReceiver() + if shouldRestoreStandardPanelLayout, LockScreenManager.shared.isLocked { + LockScreenPanelManager.shared.applyOffsetAdjustment(animated: true) + } + restoreWallpaper() + + activeSongTitle = nil + activeArtist = nil + + let callback = onDismiss + onDismiss = nil + callback?() + + print("[FullScreenArtworkWindowManager] Original wallpaper restored") + } + + // MARK: - Artwork Pre-Cache + + private func observeArtworkChanges() { + artworkCacheCancellable = MusicManager.shared.$albumArt + .debounce(for: .milliseconds(180), scheduler: DispatchQueue.main) + .sink { [weak self] newArt in + guard let self else { return } + guard let fingerprint = self.imageFingerprint(for: newArt) else { return } + guard fingerprint != self.cachedArtworkFingerprint else { return } + let url = self.artworkCacheFileURL(for: fingerprint) + Task.detached(priority: .utility) { + guard let pngData = self.encodeToPNG(newArt) else { return } + try? pngData.write(to: url, options: .atomic) + await MainActor.run { + self.cachedArtworkPNG = url + self.cachedArtworkFingerprint = fingerprint + if self.isShowing { + self.refreshPresentationForCurrentTrack() + } + } + } + } + } + + private func observeVideoArtworkChanges() { + videoArtworkCancellable = MusicManager.shared.$videoArtworkURL + .debounce(for: .milliseconds(120), scheduler: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, self.isShowing, self.isLiveWallpaperAllowed else { return } + self.refreshPresentationForCurrentTrack() + } + } + + private func observePlaybackStateChanges() { + playbackStateCancellable = MusicManager.shared.$isPlaying + .removeDuplicates() + .receive(on: DispatchQueue.main) + .sink { [weak self] isPlaying in + self?.applyCanvasPlaybackState(isPlaying: isPlaying) + } + } + + private static let wallpaperRenderProcessNames: [String] = [ + "WallpaperAgent", + "WallpaperAerialsExtension", + "WallpaperVideoExtension", + "wallpaperexportd" + ] + + private func applyCanvasPlaybackState(isPlaying: Bool) { + if let videoView, videoWindow != nil { + if isPlaying { + videoView.resume() + } else { + videoView.pause() + } + } + + let canSuspend = isShowing && isLiveWallpaperAllowed && activeLiveWallpaperFingerprint != nil + print("[FullScreenArtworkWindowManager] playback state -> isPlaying=\(isPlaying) showing=\(isShowing) liveAllowed=\(isLiveWallpaperAllowed) fingerprint=\(activeLiveWallpaperFingerprint ?? "nil")") + + if isPlaying { + resumeWallpaperAgentIfNeeded() + } else if canSuspend { + suspendWallpaperAgent() + } + } + + private func suspendWallpaperAgent() { + guard !hasSuspendedWallpaperAgent else { return } + var anySucceeded = false + for name in Self.wallpaperRenderProcessNames { + if signalProcess(name: name, signalFlag: "-STOP") { + anySucceeded = true + } + } + hasSuspendedWallpaperAgent = anySucceeded + print("[FullScreenArtworkWindowManager] SIGSTOP wallpaper processes -> anySucceeded=\(anySucceeded)") + } + + private func resumeWallpaperAgentIfNeeded() { + guard hasSuspendedWallpaperAgent else { return } + for name in Self.wallpaperRenderProcessNames { + _ = signalProcess(name: name, signalFlag: "-CONT") + } + hasSuspendedWallpaperAgent = false + print("[FullScreenArtworkWindowManager] SIGCONT wallpaper processes") + } + + @discardableResult + private func signalProcess(name: String, signalFlag: String) -> Bool { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/killall") + task.arguments = [signalFlag, name] + task.standardError = Pipe() + task.standardOutput = Pipe() + do { + try task.run() + task.waitUntilExit() + return task.terminationStatus == 0 + } catch { + print("[FullScreenArtworkWindowManager] killall \(signalFlag) \(name) threw: \(error)") + return false + } + } + + private func observeAppTermination() { + appTerminationObserver = NotificationCenter.default.addObserver( + forName: NSApplication.willTerminateNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.resumeWallpaperAgentIfNeeded() + } + } + + private func applyPresentation( + artwork: NSImage, + videoURL: URL?, + allowLiveWallpaper: Bool, + on screen: NSScreen, + backupWallpaperConfiguration: Bool + ) { + let artworkID = "\(MusicManager.shared.songTitle)|\(MusicManager.shared.artistName)" + guard let artworkFingerprint = imageFingerprint(for: artwork) else { return } + isLiveWallpaperAllowed = allowLiveWallpaper + let shouldUseStaticFallbackWallpaper = shouldUseSpotifyStaticFallbackWallpaper(videoURL: videoURL) + let shouldUseFallbackLayout = shouldUseSpotifyFallbackLayout(videoURL: videoURL) + let effectiveVideoURL = resolvedVideoURL(videoURL: videoURL) + let overlayMode = artworkOverlayMode(shouldUseFallbackLayout: shouldUseFallbackLayout) + let previousFallbackState = isShowingSpotifyCanvasFallback + + guard let rawArtworkFileURL = persistedArtworkFileURL(for: artwork, fingerprint: artworkFingerprint) else { return } + let expectCanvas = effectiveVideoURL != nil + let blurredFallbackURL = blurredWallpaperFileURL(for: artwork, fingerprint: artworkFingerprint, screen: screen) ?? rawArtworkFileURL + let wallpaperURL = (shouldUseStaticFallbackWallpaper || expectCanvas) + ? blurredFallbackURL + : rawArtworkFileURL + let wallpaperKey = wallpaperIdentityKey( + fingerprint: artworkFingerprint, + usesFallback: shouldUseStaticFallbackWallpaper || expectCanvas, + screen: screen + ) + let desiredLiveWallpaperFingerprint = effectiveVideoURL?.absoluteString + let requiresWallpaperRefresh = backupWallpaperConfiguration + || activeWallpaperKey != wallpaperKey + || activeLiveWallpaperFingerprint != desiredLiveWallpaperFingerprint + + artworkFileURL = wallpaperURL + + if backupWallpaperConfiguration { + backupWallpaperConfig() + } + + if requiresWallpaperRefresh { + if expectCanvas { + pendingFallbackStaticURL = blurredFallbackURL + pendingFallbackWallpaperKey = wallpaperKey + activeWallpaperKey = nil + activeLiveWallpaperFingerprint = nil + } else { + showWallpaperTransition(on: screen, imageURL: wallpaperURL) + + guard applyArtworkToPlist(imageURL: wallpaperURL) else { + print("[FullScreenArtworkWindowManager] Failed to patch plist") + hideWallpaperTransition() + return + } + + restartWallpaperAgent() + activeWallpaperKey = wallpaperKey + activeLiveWallpaperFingerprint = nil + pendingFallbackStaticURL = nil + pendingFallbackWallpaperKey = nil + } + } + + isShowing = true + isShowingSpotifyCanvasFallback = shouldUseFallbackLayout + activeSongTitle = MusicManager.shared.songTitle + activeArtist = MusicManager.shared.artistName + + if overlayMode == .spotifyFallback { + installFallbackRightClickMonitorIfNeeded() + } else { + removeFallbackRightClickMonitor() + } + + if overlayMode == .none { + hideArtworkOverlay() + } else { + showArtworkOverlay(on: screen, artwork: artwork, mode: overlayMode) + } + + updateLyricsOverlayIfNeeded(on: screen) + + if previousFallbackState != shouldUseFallbackLayout, LockScreenManager.shared.isLocked { + LockScreenPanelManager.shared.applyOffsetAdjustment(animated: true) + } + + showClickReceiver(on: screen) + scheduleLiveWallpaperPreparation(for: effectiveVideoURL, artwork: artwork, identifier: artworkID) + + if effectiveVideoURL == nil { + hideVideoWindow() + scheduleWallpaperTransitionHide(after: .milliseconds(900)) + } + + print("[FullScreenArtworkWindowManager] Artwork applied as wallpaper") + } + + private func refreshPresentationForCurrentTrack() { + guard isShowing else { return } + guard MusicManager.shared.hasActiveSession else { return } + guard let screen = NSScreen.main else { return } + + applyPresentation( + artwork: MusicManager.shared.albumArt, + videoURL: isLiveWallpaperAllowed ? MusicManager.shared.videoArtworkURL : nil, + allowLiveWallpaper: isLiveWallpaperAllowed, + on: screen, + backupWallpaperConfiguration: false + ) + } + + private func observePanelFrameChanges() { + panelFrameChangeObserver = NotificationCenter.default.addObserver( + forName: .atollLockScreenPanelFrameDidChange, + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.updateArtworkOverlayFrameIfNeeded() + self?.updateLyricsOverlayIfNeeded() + } + } + } + + private func observeArtworkLayoutOverCanvasPreference() { + artworkLayoutOverCanvasPreferenceCancellable = Defaults.publisher(.lockScreenUseArtworkLayoutOverFullscreenCanvas) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, self.isShowing else { return } + self.refreshPresentationForCurrentTrack() + } + } + + private func observeLyricsChanges() { + lyricsTextCancellable = MusicManager.shared.$currentLyrics + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, self.isShowing else { return } + self.updateLyricsOverlayIfNeeded() + } + } + + private func observeLyricsPreference() { + lyricsPreferenceCancellable = Defaults.publisher(.enableLyrics) + .receive(on: DispatchQueue.main) + .sink { [weak self] _ in + guard let self, self.isShowing else { return } + self.updateLyricsOverlayIfNeeded() + } + } + + private func shouldUseSpotifyStaticFallbackWallpaper(videoURL: URL?) -> Bool { + guard MusicManager.shared.bundleIdentifier == SpotifyController.bundleIdentifier else { + return false + } + + return videoURL == nil + } + + private func shouldUseSpotifyFallbackLayout(videoURL: URL?) -> Bool { + guard MusicManager.shared.bundleIdentifier == SpotifyController.bundleIdentifier else { + return false + } + + if videoURL == nil { + return true + } + + return Defaults[.lockScreenUseArtworkLayoutOverFullscreenCanvas] + } + + private func resolvedVideoURL(videoURL: URL?) -> URL? { + guard isLiveWallpaperAllowed else { return nil } + return videoURL + } + + private func artworkOverlayMode(shouldUseFallbackLayout: Bool) -> ArtworkOverlayMode { + if shouldUseFallbackLayout { + return .spotifyFallback + } + + return .none + } + + func spotifyCanvasFallbackInterItemSpacing(screenFrame: NSRect) -> CGFloat { + min(max(screenFrame.width * 0.018, 28), 42) + } + + func spotifyCanvasFallbackArtworkSideLength(screenFrame: NSRect, panelSize: CGSize) -> CGFloat { + let preferred = min(max(screenFrame.height * 0.28, 250), 330) + let spacing = spotifyCanvasFallbackInterItemSpacing(screenFrame: screenFrame) + let maxByWidth = max( + min(screenFrame.width - (spotifyCanvasFallbackHorizontalMargin * 2) - spacing - panelSize.width, 360), + 180 + ) + let maxByHeight = max(min(screenFrame.height * 0.42, 360), 180) + return min(preferred, maxByWidth, maxByHeight) + } + + private func persistedArtworkFileURL(for artwork: NSImage, fingerprint: String) -> URL? { + if let cached = cachedArtworkPNG, + cachedArtworkFingerprint == fingerprint, + FileManager.default.fileExists(atPath: cached.path) { + return cached + } + + guard let encoded = encodeToPNG(artwork) else { return nil } + + let tempURL = artworkCacheFileURL(for: fingerprint) + + do { + try encoded.write(to: tempURL, options: .atomic) + cachedArtworkPNG = tempURL + cachedArtworkFingerprint = fingerprint + return tempURL + } catch { + return nil + } + } + + private func blurredWallpaperFileURL(for artwork: NSImage, fingerprint: String, screen: NSScreen) -> URL? { + let pixelSize = wallpaperPixelSize(for: screen) + + if let cached = cachedBlurredArtworkPNG, + cachedBlurredArtworkFingerprint == fingerprint, + cachedBlurredArtworkPixelSize == pixelSize, + FileManager.default.fileExists(atPath: cached.path) { + return cached + } + + guard let encoded = encodeBlurredWallpaperPNG(from: artwork, targetPixelSize: pixelSize) else { + return nil + } + + let tempURL = blurredArtworkCacheFileURL(for: fingerprint, pixelSize: pixelSize) + + do { + try encoded.write(to: tempURL, options: .atomic) + cachedBlurredArtworkPNG = tempURL + cachedBlurredArtworkFingerprint = fingerprint + cachedBlurredArtworkPixelSize = pixelSize + return tempURL + } catch { + return nil + } + } + + private func wallpaperPixelSize(for screen: NSScreen) -> CGSize { + let scale = max(screen.backingScaleFactor, 1) + let frame = screen.frame + return CGSize( + width: max(frame.width * scale, 1), + height: max(frame.height * scale, 1) + ) + } + + private func artworkCacheFileURL(for fingerprint: String) -> URL { + let normalizedFingerprint = normalizedFingerprintComponent(fingerprint) + return FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_artwork_wallpaper_\(normalizedFingerprint).png") + } + + private func blurredArtworkCacheFileURL(for fingerprint: String, pixelSize: CGSize) -> URL { + let normalizedFingerprint = normalizedFingerprintComponent(fingerprint) + let width = Int(pixelSize.width.rounded()) + let height = Int(pixelSize.height.rounded()) + return FileManager.default.temporaryDirectory + .appendingPathComponent("atoll_artwork_wallpaper_blurred_\(normalizedFingerprint)_\(width)x\(height).png") + } + + private func normalizedFingerprintComponent(_ fingerprint: String) -> String { + fingerprint.replacingOccurrences(of: "-", with: "_") + } + + private func wallpaperIdentityKey(fingerprint: String, usesFallback: Bool, screen: NSScreen) -> String { + if usesFallback { + let pixelSize = wallpaperPixelSize(for: screen) + let width = Int(pixelSize.width.rounded()) + let height = Int(pixelSize.height.rounded()) + return "fallback:\(fingerprint):\(width)x\(height)" + } + + return "artwork:\(fingerprint)" + } + + private nonisolated func encodeToPNG(_ image: NSImage) -> Data? { + guard let tiffData = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiffData) + else { return nil } + return bitmap.representation(using: .png, properties: [:]) + } + + private nonisolated func imageFingerprint(for image: NSImage) -> String? { + guard let pngData = encodeToPNG(image) else { return nil } + var hasher = Hasher() + hasher.combine(pngData) + hasher.combine(Int(image.size.width.rounded())) + hasher.combine(Int(image.size.height.rounded())) + return String(hasher.finalize()) + } + + private func encodeBlurredWallpaperPNG(from artwork: NSImage, targetPixelSize: CGSize) -> Data? { + guard let tiffData = artwork.tiffRepresentation, + let sourceImage = CIImage(data: tiffData) + else { return nil } + + let targetRect = CGRect(origin: .zero, size: targetPixelSize) + let sourceExtent = sourceImage.extent + guard sourceExtent.width > 0, sourceExtent.height > 0 else { return nil } + + let scale = max( + targetRect.width / sourceExtent.width, + targetRect.height / sourceExtent.height + ) + let scaledWidth = sourceExtent.width * scale + let scaledHeight = sourceExtent.height * scale + let offsetX = (targetRect.width - scaledWidth) / 2 + let offsetY = (targetRect.height - scaledHeight) / 2 + + let scaledImage = sourceImage + .transformed(by: CGAffineTransform(scaleX: scale, y: scale)) + .transformed(by: CGAffineTransform(translationX: offsetX, y: offsetY)) + .cropped(to: targetRect) + + let blurredImage = scaledImage + .clampedToExtent() + .applyingFilter("CIGaussianBlur", parameters: [kCIInputRadiusKey: 28]) + .cropped(to: targetRect) + .applyingFilter( + "CIColorControls", + parameters: [ + kCIInputSaturationKey: 0.92, + kCIInputBrightnessKey: -0.03, + kCIInputContrastKey: 0.98 + ] + ) + + let context = CIContext(options: nil) + guard let cgImage = context.createCGImage(blurredImage, from: targetRect) else { return nil } + + let renderedImage = NSImage(cgImage: cgImage, size: NSSize(width: targetPixelSize.width, height: targetPixelSize.height)) + let composedImage = NSImage(size: renderedImage.size) + + composedImage.lockFocus() + renderedImage.draw(in: NSRect(origin: .zero, size: renderedImage.size)) + + NSColor(calibratedWhite: 0.02, alpha: 0.08).setFill() + NSBezierPath(rect: NSRect(origin: .zero, size: renderedImage.size)).fill() + + let gradient = NSGradient(colors: [ + NSColor.white.withAlphaComponent(0.05), + NSColor.clear, + NSColor.black.withAlphaComponent(0.16) + ]) + gradient?.draw(in: NSRect(origin: .zero, size: renderedImage.size), angle: -90) + composedImage.unlockFocus() + + guard let finalTIFF = composedImage.tiffRepresentation, + let finalBitmap = NSBitmapImageRep(data: finalTIFF) + else { return nil } + + return finalBitmap.representation(using: .png, properties: [:]) + } + + private func applyDeferredStaticFallback() { + guard let url = pendingFallbackStaticURL else { return } + let key = pendingFallbackWallpaperKey + pendingFallbackStaticURL = nil + pendingFallbackWallpaperKey = nil + + if let screen = NSScreen.main { + showWallpaperTransition(on: screen, imageURL: url) + } + guard applyArtworkToPlist(imageURL: url) else { + print("[FullScreenArtworkWindowManager] Failed to apply deferred static fallback") + hideWallpaperTransition() + return + } + restartWallpaperAgent() + activeWallpaperKey = key + activeLiveWallpaperFingerprint = nil + hideVideoWindow() + scheduleWallpaperTransitionHide(after: .milliseconds(900)) + } + + private func scheduleLiveWallpaperPreparation(for videoURL: URL?, artwork: NSImage, identifier: String) { + liveWallpaperTask?.cancel() + liveWallpaperTask = nil + + guard isLiveWallpaperAllowed else { + scheduleWallpaperTransitionHide(after: .milliseconds(260)) + return + } + + guard let videoURL else { + scheduleWallpaperTransitionHide(after: .milliseconds(260)) + return + } + + let nextFingerprint = videoURL.absoluteString + if activeLiveWallpaperFingerprint == nextFingerprint { + scheduleWallpaperTransitionHide(after: .milliseconds(220)) + scheduleHideVideoWindow(after: .milliseconds(220), expectedURL: videoURL) + return + } + + if let screen = NSScreen.main { + showVideoWindow(on: screen, videoURL: videoURL) + } + scheduleWallpaperTransitionHide(after: .milliseconds(420)) + + let assetID = customLiveWallpaperAssetID + let manifestURL = aerialManifestURL + let videosDirectoryURL = aerialVideosDirectoryURL + let thumbnailsDirectoryURL = aerialThumbnailsDirectoryURL + let manifestBackupURL = liveWallpaperManifestBackupURL + let videoBackupURL = liveWallpaperVideoBackupURL + let thumbnailBackupURL = liveWallpaperThumbnailBackupURL + let thumbnailData = encodeToPNG(artwork) + let title = activeSongTitle ?? MusicManager.shared.songTitle + let artist = activeArtist ?? MusicManager.shared.artistName + let displayName = [title, artist] + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: " - ") + + liveWallpaperTask = Task(priority: .utility) { [weak self] in + let prepared = await Self.prepareCustomLiveWallpaperAsset( + from: videoURL, + assetID: assetID, + displayName: displayName.isEmpty ? "Atoll Canvas" : displayName, + manifestURL: manifestURL, + videosDirectoryURL: videosDirectoryURL, + thumbnailsDirectoryURL: thumbnailsDirectoryURL, + thumbnailData: thumbnailData, + manifestBackupURL: manifestBackupURL, + videoBackupURL: videoBackupURL, + thumbnailBackupURL: thumbnailBackupURL + ) + + guard !Task.isCancelled else { return } + + await MainActor.run { + guard let self, self.isShowing, self.isLiveWallpaperAllowed else { return } + let currentIdentifier = "\(MusicManager.shared.songTitle)|\(MusicManager.shared.artistName)" + let currentVideoURL = self.isLiveWallpaperAllowed ? MusicManager.shared.videoArtworkURL : nil + guard currentIdentifier == identifier else { return } + guard currentVideoURL?.absoluteString == nextFingerprint else { return } + guard !self.shouldUseSpotifyStaticFallbackWallpaper(videoURL: currentVideoURL) else { return } + guard prepared else { + self.applyDeferredStaticFallback() + return + } + guard self.applyAerialToPlist(assetID: assetID) else { + self.applyDeferredStaticFallback() + return + } + + self.activeLiveWallpaperFingerprint = nextFingerprint + self.pendingFallbackStaticURL = nil + self.pendingFallbackWallpaperKey = nil + self.restartWallpaperAgent() + self.scheduleHideVideoWindow(after: .milliseconds(950), expectedURL: videoURL) + print("[FullScreenArtworkWindowManager] Live wallpaper applied") + } + } + } + + private nonisolated static func prepareCustomLiveWallpaperAsset( + from sourceURL: URL, + assetID: String, + displayName: String, + manifestURL: URL, + videosDirectoryURL: URL, + thumbnailsDirectoryURL: URL, + thumbnailData: Data?, + manifestBackupURL: URL, + videoBackupURL: URL, + thumbnailBackupURL: URL + ) async -> Bool { + let fm = FileManager.default + let videoDestinationURL = videosDirectoryURL.appendingPathComponent("\(assetID).mov") + let thumbnailDestinationURL = thumbnailsDirectoryURL.appendingPathComponent("\(assetID).png") + + do { + try fm.createDirectory(at: videosDirectoryURL, withIntermediateDirectories: true) + try fm.createDirectory(at: thumbnailsDirectoryURL, withIntermediateDirectories: true) + } catch { + return false + } + + backupItemIfNeeded(at: manifestURL, backupURL: manifestBackupURL) + backupItemIfNeeded(at: videoDestinationURL, backupURL: videoBackupURL) + backupItemIfNeeded(at: thumbnailDestinationURL, backupURL: thumbnailBackupURL) + + guard await materializeVideo(from: sourceURL, to: videoDestinationURL) else { + return false + } + + if let thumbnailData { + try? thumbnailData.write(to: thumbnailDestinationURL, options: .atomic) + } + + return updateAerialManifest( + manifestURL: manifestURL, + assetID: assetID, + videoURL: videoDestinationURL, + thumbnailURL: fm.fileExists(atPath: thumbnailDestinationURL.path) ? thumbnailDestinationURL : nil, + displayName: displayName + ) + } + + private nonisolated static func materializeVideo(from sourceURL: URL, to destinationURL: URL) async -> Bool { + let fm = FileManager.default + try? fm.removeItem(at: destinationURL) + + if !sourceURL.isFileURL { + guard let downloadedURL = await downloadRemoteVideo(from: sourceURL) else { + return false + } + defer { try? fm.removeItem(at: downloadedURL) } + return await materializeVideo(from: downloadedURL, to: destinationURL) + } + + if sourceURL.isFileURL, sourceURL.pathExtension.lowercased() == "mov" { + do { + try fm.copyItem(at: sourceURL, to: destinationURL) + return validateVideo(at: destinationURL) + } catch { + return false + } + } + + let asset = AVURLAsset(url: sourceURL) + let presets = [AVAssetExportPresetPassthrough, AVAssetExportPresetHighestQuality] + for preset in presets { + try? fm.removeItem(at: destinationURL) + guard let exportSession = AVAssetExportSession(asset: asset, presetName: preset) else { + continue + } + + exportSession.outputURL = destinationURL + exportSession.outputFileType = .mov + exportSession.shouldOptimizeForNetworkUse = true + + let exported = await export(exportSession) + if exported, validateVideo(at: destinationURL) { + return true + } + } + + return false + } + + private nonisolated static func downloadRemoteVideo(from sourceURL: URL) async -> URL? { + do { + let (temporaryURL, response) = try await URLSession.shared.download(from: sourceURL) + guard let httpResponse = response as? HTTPURLResponse, + (200..<300).contains(httpResponse.statusCode) + else { + return nil + } + + let destinationURL = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension(sourceURL.pathExtension.isEmpty ? "mp4" : sourceURL.pathExtension) + try? FileManager.default.removeItem(at: destinationURL) + try FileManager.default.moveItem(at: temporaryURL, to: destinationURL) + return destinationURL + } catch { + return nil + } + } + + private nonisolated static func export(_ exportSession: AVAssetExportSession) async -> Bool { + await withCheckedContinuation { continuation in + exportSession.exportAsynchronously { + continuation.resume(returning: exportSession.status == .completed) + } + } + } + + private nonisolated static func validateVideo(at url: URL) -> Bool { + guard FileManager.default.fileExists(atPath: url.path) else { return false } + let asset = AVURLAsset(url: url) + return !asset.tracks(withMediaType: .video).isEmpty + } + + private nonisolated static func updateAerialManifest( + manifestURL: URL, + assetID: String, + videoURL: URL, + thumbnailURL: URL?, + displayName: String + ) -> Bool { + guard let manifestData = try? Data(contentsOf: manifestURL), + var root = try? JSONSerialization.jsonObject(with: manifestData) as? [String: Any], + var assets = root["assets"] as? [[String: Any]] + else { + return false + } + + guard let existingIndex = assets.firstIndex(where: { ($0["id"] as? String) == assetID }) else { + return false + } + + var customAsset = assets[existingIndex] + customAsset["accessibilityLabel"] = displayName + customAsset["previewImage"] = thumbnailURL?.absoluteString ?? "" + customAsset["url-4K-SDR-240FPS"] = videoURL.absoluteString + customAsset["pointsOfInterest"] = [:] + assets[existingIndex] = customAsset + + root["assets"] = assets + + guard let encoded = try? JSONSerialization.data(withJSONObject: root, options: []) else { + return false + } + + do { + try encoded.write(to: manifestURL, options: .atomic) + return true + } catch { + return false + } + } + + private nonisolated static func backupItemIfNeeded(at sourceURL: URL, backupURL: URL) { + let fm = FileManager.default + guard fm.fileExists(atPath: sourceURL.path) else { return } + guard !fm.fileExists(atPath: backupURL.path) else { return } + try? fm.copyItem(at: sourceURL, to: backupURL) + } + + private func restoreLiveWallpaperResources() { + let fm = FileManager.default + let assetID = customLiveWallpaperAssetID + let videoDestinationURL = aerialVideosDirectoryURL.appendingPathComponent("\(assetID).mov") + let thumbnailDestinationURL = aerialThumbnailsDirectoryURL.appendingPathComponent("\(assetID).png") + + if fm.fileExists(atPath: liveWallpaperManifestBackupURL.path) { + try? fm.removeItem(at: aerialManifestURL) + try? fm.copyItem(at: liveWallpaperManifestBackupURL, to: aerialManifestURL) + try? fm.removeItem(at: liveWallpaperManifestBackupURL) + } + + if fm.fileExists(atPath: liveWallpaperVideoBackupURL.path) { + try? fm.removeItem(at: videoDestinationURL) + try? fm.copyItem(at: liveWallpaperVideoBackupURL, to: videoDestinationURL) + try? fm.removeItem(at: liveWallpaperVideoBackupURL) + } + + if fm.fileExists(atPath: liveWallpaperThumbnailBackupURL.path) { + try? fm.removeItem(at: thumbnailDestinationURL) + try? fm.copyItem(at: liveWallpaperThumbnailBackupURL, to: thumbnailDestinationURL) + try? fm.removeItem(at: liveWallpaperThumbnailBackupURL) + } + } + + // MARK: - Click Receiver + + private func installFallbackRightClickMonitorIfNeeded() { + guard fallbackRightClickMonitor == nil else { return } + + fallbackRightClickMonitor = NSEvent.addLocalMonitorForEvents(matching: [.rightMouseDown]) { [weak self] event in + guard let self, self.isShowing, self.isShowingSpotifyCanvasFallback else { + return event + } + + self.hide() + return nil + } + } + + private func removeFallbackRightClickMonitor() { + guard let fallbackRightClickMonitor else { return } + NSEvent.removeMonitor(fallbackRightClickMonitor) + self.fallbackRightClickMonitor = nil + } + + private func showClickReceiver(on screen: NSScreen) { + let screenFrame = screen.frame + + let window: ClickReceiverWindow + if let existing = clickWindow { + window = existing + } else { + let newWindow = ClickReceiverWindow( + contentRect: screenFrame, + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + newWindow.isMovable = false + newWindow.hasShadow = false + + ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface) + clickWindow = newWindow + window = newWindow + clickWindowDelegated = false + } + + window.setFrame(screenFrame, display: true) + window.onClick = { [weak self] in + self?.hide() + } + + if !clickWindowDelegated { + SkyLightOperator.shared.delegateWindow(window) + clickWindowDelegated = true + } + + window.orderFrontRegardless() + + for other in NSApp.windows where other !== window && other.level.rawValue >= Int(CGShieldingWindowLevel()) && other.isVisible { + window.order(.below, relativeTo: other.windowNumber) + } + } + + private func hideClickReceiver() { + clickWindow?.orderOut(nil) + clickWindow?.onClick = nil + } + + private func showArtworkOverlay(on screen: NSScreen, artwork: NSImage, mode: ArtworkOverlayMode) { + currentArtworkOverlayMode = mode + guard let targetFrame = artworkOverlayFrame(on: screen) else { return } + + let window: NSWindow + if let existing = artworkOverlayWindow { + window = existing + } else { + let newWindow = NSWindow( + contentRect: targetFrame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.ignoresMouseEvents = false + newWindow.hasShadow = false + newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + + ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface) + artworkOverlayWindow = newWindow + window = newWindow + artworkOverlayWindowDelegated = false + } + + window.setFrame(targetFrame, display: true) + + let contentView: SpotifyCanvasFallbackArtworkOverlayView + if let existing = window.contentView as? SpotifyCanvasFallbackArtworkOverlayView { + contentView = existing + contentView.frame = NSRect(origin: .zero, size: targetFrame.size) + contentView.updateArtwork(artwork) + } else { + let newContentView = SpotifyCanvasFallbackArtworkOverlayView(artwork: artwork) + newContentView.frame = NSRect(origin: .zero, size: targetFrame.size) + window.contentView = newContentView + contentView = newContentView + } + + contentView.onDismiss = { [weak self] in + self?.hide() + } + + if !artworkOverlayWindowDelegated { + SkyLightOperator.shared.delegateWindow(window) + artworkOverlayWindowDelegated = true + } + + window.orderFrontRegardless() + } + + private func hideArtworkOverlay() { + currentArtworkOverlayMode = .none + if let contentView = artworkOverlayWindow?.contentView as? SpotifyCanvasFallbackArtworkOverlayView { + contentView.onDismiss = nil + } + artworkOverlayWindow?.orderOut(nil) + artworkOverlayWindow?.contentView = nil + } + + private func updateArtworkOverlayFrameIfNeeded() { + guard isShowing, currentArtworkOverlayMode != .none else { return } + guard let window = artworkOverlayWindow else { return } + guard let screen = NSScreen.main, let frame = artworkOverlayFrame(on: screen) else { return } + window.setFrame(frame, display: true) + } + + private func artworkOverlayFrame(on screen: NSScreen) -> NSRect? { + switch currentArtworkOverlayMode { + case .none: + return nil + case .spotifyFallback: + return spotifyFallbackArtworkOverlayFrame(on: screen) + } + } + + private func spotifyFallbackArtworkOverlayFrame(on screen: NSScreen) -> NSRect? { + guard let frames = spotifyCanvasFallbackLayoutFrames(on: screen) else { return nil } + return frames.artworkFrame + } + + private func updateLyricsOverlayIfNeeded(on screen: NSScreen? = NSScreen.main) { + guard isShowing, isShowingSpotifyCanvasFallback, Defaults[.enableLyrics] else { + hideLyricsOverlay() + return + } + + let line = MusicManager.shared.currentLyrics.trimmingCharacters(in: .whitespacesAndNewlines) + guard !line.isEmpty else { + hideLyricsOverlay() + return + } + + guard let screen, let frame = spotifyCanvasFallbackLayoutFrames(on: screen)?.lyricsFrame else { + hideLyricsOverlay() + return + } + + let fontSize = min(max(frame.height * 0.24, 24), 40) + let content = FullScreenLyricsOverlayContent(text: line, fontSize: fontSize) + + let window: NSWindow + let hostingView: NSHostingView + if let existingWindow = lyricsOverlayWindow, + let existingView = existingWindow.contentView as? NSHostingView { + window = existingWindow + hostingView = existingView + } else { + let newWindow = NSWindow( + contentRect: frame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.ignoresMouseEvents = true + newWindow.hasShadow = false + newWindow.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + + let newHostingView = NSHostingView(rootView: content) + newHostingView.frame = NSRect(origin: .zero, size: frame.size) + newHostingView.wantsLayer = true + newHostingView.layer?.backgroundColor = NSColor.clear.cgColor + newWindow.contentView = newHostingView + + ScreenCaptureVisibilityManager.shared.register(newWindow, scope: .entireInterface) + lyricsOverlayWindow = newWindow + window = newWindow + hostingView = newHostingView + lyricsOverlayWindowDelegated = false + } + + window.setFrame(frame, display: true) + hostingView.frame = NSRect(origin: .zero, size: frame.size) + hostingView.rootView = content + + if !lyricsOverlayWindowDelegated { + SkyLightOperator.shared.delegateWindow(window) + lyricsOverlayWindowDelegated = true + } + + window.orderFrontRegardless() + } + + private func hideLyricsOverlay() { + lyricsOverlayWindow?.orderOut(nil) + } + + private func spotifyCanvasFallbackLayoutFrames(on screen: NSScreen) -> SpotifyCanvasFallbackLayoutFrames? { + let screenFrame = screen.frame + guard let panelFrame = LockScreenPanelManager.shared.latestFrame else { return nil } + + let overlaySide = spotifyCanvasFallbackArtworkSideLength( + screenFrame: screenFrame, + panelSize: panelFrame.size + ) + let spacing = spotifyCanvasFallbackInterItemSpacing(screenFrame: screenFrame) + let targetX = panelFrame.minX - spacing - overlaySide + let targetY = panelFrame.midY - (overlaySide / 2) + let artworkFrame = NSRect(x: targetX, y: targetY, width: overlaySide, height: overlaySide) + let groupFrame = artworkFrame.union(panelFrame) + let lyricsGap = min(max(screenFrame.height * 0.022, 18), 28) + let bottomMargin = min(max(screenFrame.height * 0.16, 140), 220) + let availableHeight = groupFrame.minY - lyricsGap - (screenFrame.minY + bottomMargin) + let desiredHeight = min(max(screenFrame.height * 0.13, 108), 156) + let lyricsHeight = min(desiredHeight, max(availableHeight, 0)) + let lyricsFrame: NSRect? + + if lyricsHeight >= 56 { + lyricsFrame = NSRect( + x: groupFrame.minX, + y: groupFrame.minY - lyricsGap - lyricsHeight, + width: groupFrame.width, + height: lyricsHeight + ) + } else { + lyricsFrame = nil + } + + return SpotifyCanvasFallbackLayoutFrames( + artworkFrame: artworkFrame, + panelFrame: panelFrame, + groupFrame: groupFrame, + lyricsFrame: lyricsFrame + ) + } + + private func showWallpaperTransition(on screen: NSScreen, imageURL: URL) { + guard let image = NSImage(contentsOf: imageURL) else { return } + + wallpaperTransitionHideTask?.cancel() + wallpaperTransitionHideTask = nil + + let screenFrame = screen.frame + + let window: NSWindow + let view: WallpaperTransitionImageView + + if let existingWindow = wallpaperTransitionWindow, let existingView = wallpaperTransitionView { + window = existingWindow + view = existingView + } else { + let newWindow = NSWindow( + contentRect: screenFrame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.ignoresMouseEvents = true + newWindow.hasShadow = false + newWindow.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) + 1) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + + let newView = WallpaperTransitionImageView(frame: screenFrame) + newWindow.contentView = newView + + wallpaperTransitionWindow = newWindow + wallpaperTransitionView = newView + window = newWindow + view = newView + } + + window.setFrame(screenFrame, display: true) + view.frame = NSRect(origin: .zero, size: screenFrame.size) + view.updateImage(image) + window.orderFrontRegardless() + } + + private func scheduleWallpaperTransitionHide(after delay: Duration) { + wallpaperTransitionHideTask?.cancel() + wallpaperTransitionHideTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: delay) + self?.hideWallpaperTransition() + } + } + + private func hideWallpaperTransition() { + wallpaperTransitionHideTask?.cancel() + wallpaperTransitionHideTask = nil + wallpaperTransitionWindow?.orderOut(nil) + } + + private func showVideoWindow(on screen: NSScreen, videoURL: URL) { + videoWindowHideTask?.cancel() + videoWindowHideTask = nil + + let screenFrame = screen.frame + + let window: NSWindow + let view: LoopingVideoView + + if let existingWindow = videoWindow, let existingView = videoView { + window = existingWindow + view = existingView + } else { + let newWindow = NSWindow( + contentRect: screenFrame, + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + newWindow.isReleasedWhenClosed = false + newWindow.isOpaque = false + newWindow.backgroundColor = .clear + newWindow.ignoresMouseEvents = true + newWindow.hasShadow = false + newWindow.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) + 1) + newWindow.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + + let newView = LoopingVideoView(frame: screenFrame) + newWindow.contentView = newView + + videoWindow = newWindow + videoView = newView + window = newWindow + view = newView + } + + window.setFrame(screenFrame, display: true) + view.frame = NSRect(origin: .zero, size: screenFrame.size) + if activeVideoWindowURL != videoURL { + view.play(url: videoURL) + activeVideoWindowURL = videoURL + } + window.orderFrontRegardless() + applyCanvasPlaybackState(isPlaying: MusicManager.shared.isPlaying) + } + + private func scheduleHideVideoWindow(after delay: Duration, expectedURL: URL?) { + videoWindowHideTask?.cancel() + let expectedURLString = expectedURL?.absoluteString + + videoWindowHideTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: delay) + guard let self else { return } + + if let expectedURLString, + self.activeVideoWindowURL?.absoluteString != expectedURLString { + return + } + + self.hideVideoWindow() + } + } + + private func hideVideoWindow() { + videoWindowHideTask?.cancel() + videoWindowHideTask = nil + activeVideoWindowURL = nil + videoView?.stop() + videoWindow?.orderOut(nil) + } + + // MARK: - Backup / Restore + + private var hasValidBackup: Bool { + FileManager.default.fileExists(atPath: backupPlistURL.path) + } + + private func backupWallpaperConfig() { + guard !hasValidBackup else { return } + try? FileManager.default.copyItem(at: wallpaperPlistURL, to: backupPlistURL) + } + + private func applyArtworkToPlist(imageURL: URL) -> Bool { + guard let plistData = try? Data(contentsOf: wallpaperPlistURL), + var plist = try? PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] + else { return false } + + let desktopBlock = makeImageWallpaperBlock(imageURL: imageURL) + let idleBlock = makeImageWallpaperBlock(imageURL: imageURL) + + patchWallpaperEntries(&plist, desktopBlock: desktopBlock, idleBlock: idleBlock) + + guard let newData = try? PropertyListSerialization.data(fromPropertyList: plist, format: .binary, options: 0) + else { return false } + + return (try? newData.write(to: wallpaperPlistURL, options: .atomic)) != nil + } + + private func applyAerialToPlist(assetID: String) -> Bool { + guard let plistData = try? Data(contentsOf: wallpaperPlistURL), + var plist = try? PropertyListSerialization.propertyList(from: plistData, format: nil) as? [String: Any] + else { return false } + + let desktopTemplate = wallpaperBlock( + named: "Desktop", + in: plist["AllSpacesAndDisplays"] as? [String: Any], + fallback: plist["SystemDefault"] as? [String: Any] + ) + let idleTemplate = wallpaperBlock( + named: "Idle", + in: plist["AllSpacesAndDisplays"] as? [String: Any], + fallback: plist["SystemDefault"] as? [String: Any] + ) + + let desktopBlock = makeAerialWallpaperBlock(assetID: assetID, existingBlock: desktopTemplate) + let idleBlock = makeAerialWallpaperBlock(assetID: assetID, existingBlock: idleTemplate) + + patchWallpaperEntries(&plist, desktopBlock: desktopBlock, idleBlock: idleBlock) + + guard let newData = try? PropertyListSerialization.data(fromPropertyList: plist, format: .binary, options: 0) + else { return false } + + return (try? newData.write(to: wallpaperPlistURL, options: .atomic)) != nil + } + + private func wallpaperBlock(named name: String, in primary: [String: Any]?, fallback: [String: Any]?) -> [String: Any]? { + if let primaryBlock = primary?[name] as? [String: Any] { + return primaryBlock + } + return fallback?[name] as? [String: Any] + } + + private func makeImageWallpaperBlock(imageURL: URL) -> [String: Any] { + let config: [String: Any] = [ + "type": "imageFile", + "url": ["relative": imageURL.absoluteString] + ] + let configData = (try? PropertyListSerialization.data(fromPropertyList: config, format: .binary, options: 0)) ?? Data() + + let imageChoice: [String: Any] = [ + "Provider": "com.apple.wallpaper.choice.image", + "Files": [] as [Any], + "Configuration": configData + ] + + let contentBlock: [String: Any] = [ + "Choices": [imageChoice], + "Shuffle": "$null" + ] + + return [ + "Content": contentBlock, + "LastSet": Date(), + "LastUse": Date() + ] + } + + private func makeAerialWallpaperBlock(assetID: String, existingBlock: [String: Any]?) -> [String: Any] { + let config: [String: Any] = [ + "assetID": assetID + ] + let configData = (try? PropertyListSerialization.data(fromPropertyList: config, format: .binary, options: 0)) ?? Data() + + let aerialChoice: [String: Any] = [ + "Provider": "com.apple.wallpaper.choice.aerials", + "Files": [] as [Any], + "Configuration": configData + ] + + var contentBlock: [String: Any] = [ + "Choices": [aerialChoice], + "Shuffle": "$null" + ] + + if let existingContent = existingBlock?["Content"] as? [String: Any], + let encodedOptionValues = existingContent["EncodedOptionValues"] { + contentBlock["EncodedOptionValues"] = encodedOptionValues + } + + return [ + "Content": contentBlock, + "LastSet": Date(), + "LastUse": Date() + ] + } + + private func patchWallpaperEntries(_ plist: inout [String: Any], desktopBlock: [String: Any], idleBlock: [String: Any]) { + var allSpaces = (plist["AllSpacesAndDisplays"] as? [String: Any]) ?? [:] + allSpaces["Desktop"] = desktopBlock + allSpaces["Idle"] = idleBlock + allSpaces["Type"] = "individual" + plist["AllSpacesAndDisplays"] = allSpaces + + var systemDefault = (plist["SystemDefault"] as? [String: Any]) ?? [:] + systemDefault["Desktop"] = desktopBlock + systemDefault["Idle"] = idleBlock + systemDefault["Type"] = "individual" + plist["SystemDefault"] = systemDefault + + if var displays = plist["Displays"] as? [String: Any] { + for key in displays.keys { + if var display = displays[key] as? [String: Any] { + display["Desktop"] = desktopBlock + display["Idle"] = idleBlock + if display["Type"] != nil { + display["Type"] = "individual" + } + displays[key] = display + } + } + plist["Displays"] = displays + } + + if var spaces = plist["Spaces"] as? [String: Any] { + for spaceKey in spaces.keys { + if var space = spaces[spaceKey] as? [String: Any] { + if var defaultEntry = space["Default"] as? [String: Any] { + defaultEntry["Desktop"] = desktopBlock + defaultEntry["Idle"] = idleBlock + if defaultEntry["Type"] != nil { + defaultEntry["Type"] = "individual" + } + space["Default"] = defaultEntry + } + if var spaceDisplays = space["Displays"] as? [String: Any] { + for displayKey in spaceDisplays.keys { + if var display = spaceDisplays[displayKey] as? [String: Any] { + display["Desktop"] = desktopBlock + display["Idle"] = idleBlock + if display["Type"] != nil { + display["Type"] = "individual" + } + spaceDisplays[displayKey] = display + } + } + space["Displays"] = spaceDisplays + } + spaces[spaceKey] = space + } + } + plist["Spaces"] = spaces + } + } + + private func restartWallpaperAgent() { + let task = Process() + task.executableURL = URL(fileURLWithPath: "/usr/bin/killall") + task.arguments = ["WallpaperAgent"] + try? task.run() + } + + private func restoreWallpaper() { + let fm = FileManager.default + guard hasValidBackup else { return } + + do { + try fm.removeItem(at: wallpaperPlistURL) + try fm.copyItem(at: backupPlistURL, to: wallpaperPlistURL) + try fm.removeItem(at: backupPlistURL) + } catch { + print("[FullScreenArtworkWindowManager] Failed to restore plist: \(error)") + } + + restoreLiveWallpaperResources() + restartWallpaperAgent() + } + + // MARK: - Track Change Observer + + private func observeTrackChanges() { + trackChangeCancellable?.cancel() + trackChangeCancellable = MusicManager.shared.$songTitle + .combineLatest(MusicManager.shared.$artistName) + .dropFirst() + .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main) + .receive(on: DispatchQueue.main) + .sink { [weak self] title, artist in + guard let self, self.isShowing else { return } + if title != self.activeSongTitle || artist != self.activeArtist { + self.refreshPresentationForCurrentTrack() + self.scheduleDeferredTrackRefresh(expectedTitle: title, expectedArtist: artist) + } + } + } + + private func scheduleDeferredTrackRefresh(expectedTitle: String, expectedArtist: String) { + deferredTrackRefreshTask?.cancel() + deferredTrackRefreshTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(700)) + guard let self, self.isShowing else { return } + guard MusicManager.shared.songTitle == expectedTitle, + MusicManager.shared.artistName == expectedArtist + else { return } + + self.refreshPresentationForCurrentTrack() + } + } +} diff --git a/DynamicIsland/managers/LockScreenManager.swift b/DynamicIsland/managers/LockScreenManager.swift index 6e98688..95da4fc 100644 --- a/DynamicIsland/managers/LockScreenManager.swift +++ b/DynamicIsland/managers/LockScreenManager.swift @@ -48,6 +48,7 @@ class LockScreenManager: ObservableObject { private var debounceIdleTask: Task? private var collapseTask: Task? private var postUnlockMusicHUDTask: Task? + private var lockStatePollTask: Task? // MARK: - Helpers @@ -65,9 +66,11 @@ class LockScreenManager: ObservableObject { deinit { DistributedNotificationCenter.default().removeObserver(self) + NSWorkspace.shared.notificationCenter.removeObserver(self) debounceIdleTask?.cancel() collapseTask?.cancel() postUnlockMusicHUDTask?.cancel() + lockStatePollTask?.cancel() } // MARK: - Setup @@ -80,7 +83,7 @@ class LockScreenManager: ObservableObject { name: .init("com.apple.screenIsLocked"), object: nil ) - + // Observe screen unlocked event DistributedNotificationCenter.default().addObserver( self, @@ -88,7 +91,18 @@ class LockScreenManager: ObservableObject { name: .init("com.apple.screenIsUnlocked"), 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") } @@ -141,9 +155,11 @@ class LockScreenManager: ObservableObject { print("[\(timestamp())] LockScreenManager: ⏭️ Lock icon disabled in settings") } + startLockStatePolling() + print("[\(timestamp())] LockScreenManager: ✅ Lock screen activated") } - + @objc private func screenUnlocked() { guard isLocked else { print("[\(timestamp())] LockScreenManager: 🔁 Unlock event ignored (already unlocked)") @@ -156,6 +172,7 @@ class LockScreenManager: ObservableObject { lastUpdated = Date() updateIdleState(locked: false) isLocked = false + stopLockStatePolling() postUnlockMusicHUDTask?.cancel() shouldDelayPostUnlockMusicHUD = Defaults[.enableLockScreenLiveActivity] @@ -187,6 +204,7 @@ class LockScreenManager: ObservableObject { // Hide panel window immediately and synchronously print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window") LockScreenPanelManager.shared.hidePanel() + FullScreenArtworkWindowManager.shared.hide() LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide() LockScreenWeatherManager.shared.hideWeatherWidget() LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: false) @@ -206,8 +224,46 @@ class LockScreenManager: ObservableObject { 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 - + /// Copy EXACT logic from ScreenRecordingManager private func updateIdleState(locked: Bool) { if locked { diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift index c259971..e076fe7 100644 --- a/DynamicIsland/managers/LockScreenPanelManager.swift +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -58,13 +58,30 @@ class LockScreenPanelManager { 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() { screenChangeObserver = NotificationCenter.default.addObserver( forName: NSApplication.didChangeScreenParametersNotification, object: nil, queue: .main ) { [weak self] _ in - self?.handleScreenGeometryChange(reason: "screen-parameters") + Task { @MainActor [weak self] in + self?.handleScreenGeometryChange(reason: "screen-parameters") + } } let workspaceCenter = NSWorkspace.shared.notificationCenter @@ -73,7 +90,9 @@ class LockScreenPanelManager { object: nil, queue: .main ) { [weak self] _ in - self?.handleScreenGeometryChange(reason: "screens-did-wake") + Task { @MainActor [weak self] in + self?.handleScreenGeometryChange(reason: "screens-did-wake") + } } workspaceObservers = [wakeObserver] @@ -94,8 +113,9 @@ class LockScreenPanelManager { } let screenFrame = screen.frame - let targetFrame = collapsedFrame(for: screenFrame) - collapsedFrame = targetFrame + let defaultCollapsedFrame = collapsedFrame(for: screenFrame) + let targetFrame = targetFrame(for: screenFrame, panelSize: LockScreenMusicPanel.collapsedSize) + collapsedFrame = defaultCollapsedFrame isPanelExpanded = false currentAdditionalHeight = 0 @@ -127,14 +147,14 @@ class LockScreenPanelManager { } window.setFrame(targetFrame, display: true) - latestFrame = targetFrame + publishPanelFrame(targetFrame) hideTask?.cancel() panelAnimator.isPresented = false LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: false) - let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator)) - hosting.frame = NSRect(origin: .zero, size: targetFrame.size) - hosting.autoresizingMask = [.width, .height] + let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator)) + hosting.frame = NSRect(origin: .zero, size: targetFrame.size) + hosting.autoresizingMask = [.width, .height] window.contentView = hosting // 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) { - guard let window = panelWindow, let baseFrame = collapsedFrame else { + guard let window = panelWindow, let screen = currentScreen() else { return } let resizeDuration: CFTimeInterval = 0.28 let baseSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize - let targetWidth = baseSize.width - let targetHeight = baseSize.height + additionalHeight - let originX = baseFrame.midX - (targetWidth / 2) - let originY = baseFrame.origin.y - let targetFrame = NSRect(x: originX, y: originY, width: targetWidth, height: targetHeight) + let targetFrame = targetFrame( + for: screen.frame, + panelSize: CGSize(width: baseSize.width, height: baseSize.height + additionalHeight) + ) if animated { NSAnimationContext.runAnimationGroup { context in @@ -185,7 +204,7 @@ class LockScreenPanelManager { window.setFrame(targetFrame, display: true) } - latestFrame = targetFrame + publishPanelFrame(targetFrame) LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: animated) @@ -229,7 +248,7 @@ class LockScreenPanelManager { guard let window = panelWindow else { print("LockScreenPanelManager: no panel to hide") - latestFrame = nil + publishPanelFrame(nil) return } @@ -239,7 +258,7 @@ class LockScreenPanelManager { await MainActor.run { window?.orderOut(nil) window?.contentView = nil - self.latestFrame = nil + self.publishPanelFrame(nil) print("[\(self.timestamp())] LockScreenPanelManager: panel hidden") } } @@ -267,6 +286,74 @@ class LockScreenPanelManager { .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 { let collapsedSize = LockScreenMusicPanel.collapsedSize let originX = screenFrame.midX - (collapsedSize.width / 2) @@ -289,3 +376,7 @@ class LockScreenPanelManager { LockScreenDisplayContextProvider.shared.contextSnapshot()?.screen ?? NSScreen.main } } + +extension Notification.Name { + static let atollLockScreenPanelFrameDidChange = Notification.Name("atollLockScreenPanelFrameDidChange") +} diff --git a/DynamicIsland/managers/LockScreenTimerWidgetManager.swift b/DynamicIsland/managers/LockScreenTimerWidgetManager.swift index 0337d89..d907c01 100644 --- a/DynamicIsland/managers/LockScreenTimerWidgetManager.swift +++ b/DynamicIsland/managers/LockScreenTimerWidgetManager.swift @@ -279,6 +279,11 @@ final class LockScreenTimerWidgetPanelManager { 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 originY = max(originY, minY) diff --git a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift index 9b6b5b1..493b5b9 100644 --- a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift @@ -49,8 +49,10 @@ final class LockScreenWeatherPanelManager { func hide() { guard let window else { return } + window.alphaValue = 0 window.orderOut(nil) window.contentView = nil + window.alphaValue = 1 latestFrame = nil lastSnapshot = nil lastContentSize = nil diff --git a/DynamicIsland/managers/MemoryUsageMonitor.swift b/DynamicIsland/managers/MemoryUsageMonitor.swift index 34c9898..d03b785 100644 --- a/DynamicIsland/managers/MemoryUsageMonitor.swift +++ b/DynamicIsland/managers/MemoryUsageMonitor.swift @@ -24,7 +24,11 @@ import Darwin final class 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 +#endif private let pollInterval: TimeInterval = 8 // Clamp within 5-10 seconds to limit battery impact private let restartCooldown: TimeInterval = 300 private let logSampleInterval: TimeInterval = 300 diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index d3f723d..db6ac60 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -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( systemSymbolName: "heart.fill", accessibilityDescription: "Album Art" @@ -475,10 +485,16 @@ class MusicManager: ObservableObject { // Task used to periodically sync displayed lyric with playback position private var lyricSyncTask: Task? + private var lyricsFetchTask: Task? + private var lyricsFetchKey: LyricsLookupKey? + private var activeLyricsKey: LyricsLookupKey? + private var lyricsCache: [LyricsLookupKey: [LyricLine]] = [:] private var explicitLookupTask: Task? 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 liveStreamEdgeObservationCount: Int = 0 @@ -512,6 +528,13 @@ class MusicManager: ObservableObject { } .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 setupPearDesktopAutoDetection() @@ -580,6 +603,8 @@ class MusicManager: ObservableObject { public func destroy() { debounceIdleTask?.cancel() + lyricsFetchTask?.cancel() + lyricSyncTask?.cancel() explicitLookupTask?.cancel() cancellables.removeAll() controllerCancellables.removeAll() @@ -701,6 +726,7 @@ class MusicManager: ObservableObject { // Check for artwork changes let artworkChanged = state.artwork != nil && state.artwork != self.artworkData + let hasContentChange = titleChanged || artistChanged @@ -709,6 +735,11 @@ class MusicManager: ObservableObject { || bundleChanged || contentIdentifierChanged || contentURLChanged + let liveArtworkChanged = state.liveArtworkURL != self.videoArtworkURL + + if liveArtworkChanged { + self.videoArtworkURL = state.liveArtworkURL + } // Handle artwork and visual transitions for changed content let shouldAutoPeekOnTrackChange = Defaults[.showSneakPeekOnTrackChange] @@ -735,10 +766,16 @@ class MusicManager: ObservableObject { self.lastArtworkContentIdentifier = state.contentIdentifier self.lastArtworkContentURL = state.contentURL - // Fetch lyrics for new track whenever content changes - self.fetchLyrics() + self.prepareLyricsForCurrentTrack() + if let liveArtworkURL = state.liveArtworkURL { + self.videoArtworkURL = liveArtworkURL + } else { + self.fetchVideoArtwork() + } + self.refreshExplicitFlag(for: state) + // Only update sneak peek if there's actual content and something changed if shouldAutoPeekOnTrackChange && !state.title.isEmpty && !state.artist.isEmpty && state.isPlaying { self.updateSneakPeek() @@ -1238,53 +1275,108 @@ class MusicManager: ObservableObject { // MARK: - Lyrics Methods func fetchLyrics() { - guard Defaults[.enableLyrics] else { return } - // If the lyrics panel is visible already, provide immediate feedback - if showLyrics { - Task { @MainActor in - self.currentLyrics = "Loading lyrics..." - self.syncedLyrics = [] - self.currentLyricIndex = -1 - } + prepareLyricsForCurrentTrack(forceFetch: true, prioritizeVisibleResult: Defaults[.enableLyrics]) + } + + private func handleLyricsPreferenceChange(isEnabled: Bool) { + showLyrics = isEnabled + + 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 { - do { - let lyrics = try await fetchLyricsFromAPI(artist: artistName, title: songTitle) - await MainActor.run { - self.syncedLyrics = lyrics - self.currentLyricIndex = -1 - if !lyrics.isEmpty { - self.currentLyrics = lyrics[0].text - } else { - self.currentLyrics = "" - } + let key = lookup.key + let lyricsEnabled = Defaults[.enableLyrics] + let shouldShowLoading = lyricsEnabled && prioritizeVisibleResult + let trackChanged = activeLyricsKey != key + activeLyricsKey = key - // If lyrics are enabled, start syncing them to playback position - if Defaults[.enableLyrics] && !self.syncedLyrics.isEmpty { - self.startLyricSync() - } else if self.syncedLyrics.isEmpty { - self.stopLyricSync() - } + if trackChanged { + syncedLyrics = [] + currentLyricIndex = -1 + currentLyrics = shouldShowLoading ? "Loading lyrics..." : "" + 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 { print("Failed to fetch lyrics: \(error)") await MainActor.run { + guard self.activeLyricsKey == key else { return } + self.lyricsFetchKey = nil + self.lyricsFetchTask = nil self.syncedLyrics = [] - self.currentLyrics = "" self.currentLyricIndex = -1 + self.currentLyrics = lyricsEnabled ? "No lyrics found" : "" 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 [] } // Normalize input and percent-encode let cleanArtist = artist.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), let encodedTitle = cleanTitle.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else { return [] @@ -1298,7 +1390,8 @@ class MusicManager: ObservableObject { if let http = response as? HTTPURLResponse, http.statusCode == 200 { // Try parse as array JSON (preferred) 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 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] { let lines = lrc.components(separatedBy: .newlines) var lyrics: [LyricLine] = [] @@ -1424,6 +1618,32 @@ class MusicManager: ObservableObject { 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() { // Toggle the UI state first so the views can react immediately. showLyrics.toggle() @@ -1518,7 +1738,6 @@ private struct AlbumArtFlipModifier: ViewModifier { // Use a small tolerance to avoid flickering exactly at 90°/270°. 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 } } diff --git a/DynamicIsland/managers/SpotifyAuthManager.swift b/DynamicIsland/managers/SpotifyAuthManager.swift new file mode 100644 index 0000000..f733452 --- /dev/null +++ b/DynamicIsland/managers/SpotifyAuthManager.swift @@ -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 . + */ + +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[.. 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.size) + + let mac = HMAC.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 + } + } +} diff --git a/DynamicIsland/managers/SystemOSDManager.swift b/DynamicIsland/managers/SystemOSDManager.swift index 52e8fc8..9281fda 100644 --- a/DynamicIsland/managers/SystemOSDManager.swift +++ b/DynamicIsland/managers/SystemOSDManager.swift @@ -17,12 +17,23 @@ */ import Foundation +import os class SystemOSDManager { 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? + var lastSuspendedPID: Int32 = -1 + } + private static let suppressionState = OSAllocatedUnfairLock(initialState: SuppressionState()) + /// Re-enables the system HUD by restarting OSDUIHelper public static func enableSystemHUD() { + stopSuppressionWatcher() Task.detached(priority: .background) { 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() { Task.detached(priority: .background) { await disableSystemHUDAsync() } + startSuppressionWatcher() } private static func disableSystemHUDAsync() async { @@ -92,15 +106,35 @@ class SystemOSDManager { kickstart.arguments = ["kickstart", "gui/\(getuid())/com.apple.OSDUIHelper"] try kickstart.run() kickstart.waitUntilExit() - - try await Task.sleep(nanoseconds: 500_000_000) // 500ms - async wait instead of usleep - - let task = Process() - task.executableURL = URL(fileURLWithPath: "/usr/bin/killall") - task.arguments = ["-STOP", "OSDUIHelper"] - try task.run() - task.waitUntilExit() - + + // 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") + } + } + + suspendOSDUIHelper() + + // Settle, then confirm a process is actually present (and thus + // suspended). If none is running, launchd hasn't spawned it yet + // 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 { print("✅ System HUD disabled") } @@ -110,7 +144,92 @@ 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.. Task? in + let prior = state.task + state.task = newTask + return prior + } + previous?.cancel() + } + + private static func stopSuppressionWatcher() { + let previous = suppressionState.withLock { state -> Task? 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 public static func isOSDUIHelperRunning() -> Bool { let task = Process() diff --git a/DynamicIsland/models/BatteryStatusViewModel.swift b/DynamicIsland/models/BatteryStatusViewModel.swift index 0b9e792..b83fb42 100644 --- a/DynamicIsland/models/BatteryStatusViewModel.swift +++ b/DynamicIsland/models/BatteryStatusViewModel.swift @@ -26,6 +26,12 @@ import Foundation import IOKit.ps import SwiftUI +enum BatteryTemporaryHUDKind: Equatable { + case charging + case lowBattery + case fullBattery +} + /// A view model that manages and monitors the battery status of the device class BatteryStatusViewModel: ObservableObject { @@ -34,7 +40,6 @@ class BatteryStatusViewModel: ObservableObject { private var runLoopSource: Unmanaged? var animations: DynamicIslandAnimations = DynamicIslandAnimations() private let lowBatteryAlertSoundPlayer = AudioPlayer() - private let lowBatteryAlertThresholds: [Float] = [20, 15, 10, 5] @ObservedObject var coordinator = DynamicIslandViewCoordinator.shared @@ -46,6 +51,11 @@ class BatteryStatusViewModel: ObservableObject { @Published private(set) var isInitial: Bool = false @Published private(set) var timeToFullCharge: Int = 0 @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 var managerBatteryId: Int? @@ -79,33 +89,39 @@ class BatteryStatusViewModel: ObservableObject { switch event { case .powerSourceChanged(let isPluggedIn): print("🔌 Power source: \(isPluggedIn ? "Connected" : "Disconnected")") + let wasPluggedIn = self.isPluggedIn withAnimation { self.isPluggedIn = isPluggedIn self.statusText = isPluggedIn ? String(localized: "Plugged In") : String(localized: "Unplugged") - self.notifyImportanChangeStatus() + } + if !wasPluggedIn && isPluggedIn { + presentTemporaryBatteryHUDIfNeeded(kind: .charging) } case .batteryLevelChanged(let level): print("🔋 Battery level: \(Int(level))%") let previousLevel = self.levelBattery - self.handleLowBatteryAlertIfNeeded(previousLevel: previousLevel, newLevel: level) withAnimation { self.levelBattery = level } + self.handleLowBatteryAlertIfNeeded(previousLevel: previousLevel, newLevel: level) + self.handleFullBatteryAlertIfNeeded(previousLevel: previousLevel, newLevel: level) case .lowPowerModeChanged(let isEnabled): print("⚡ Low power mode: \(isEnabled ? "Enabled" : "Disabled")") - self.notifyImportanChangeStatus() + let wasEnabled = self.isInLowPowerMode withAnimation { self.isInLowPowerMode = isEnabled self.statusText = String(localized: "Low Power: \(self.isInLowPowerMode ? String(localized: "On") : String(localized: "Off"))") } + if !wasEnabled && isEnabled { + presentTemporaryBatteryHUDIfNeeded(kind: .lowBattery) + } case .isChargingChanged(let isCharging): print("🔌 Charging: \(isCharging ? "Yes" : "No")") print("maxCapacity: \(self.maxCapacity)") print("levelBattery: \(self.levelBattery)") - self.notifyImportanChangeStatus() withAnimation { self.isCharging = isCharging self.statusText = @@ -145,30 +161,131 @@ class BatteryStatusViewModel: ObservableObject { } } - /// Notifies important changes in the battery status with an optional delay - /// - Parameter delay: The delay before notifying the change, default is 0.0 - private func notifyImportanChangeStatus(delay: Double = 0.0) { - Task { - try? await Task.sleep(for: .seconds(delay)) - self.coordinator.toggleExpandingView(status: true, type: .battery) + private func presentTemporaryBatteryHUDIfNeeded(kind: BatteryTemporaryHUDKind) { + presentTemporaryBatteryHUDIfNeeded(kind: kind, force: false) + } + + func triggerTestHUD(kind: BatteryTemporaryHUDKind) { + 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) { - guard Defaults[.playLowBatteryAlertSound] else { return } guard !isPluggedIn, !isCharging else { return } guard newLevel < previousLevel else { return } + let threshold = Float(Defaults[.lowBatteryHUDThreshold]) + guard previousLevel > threshold && newLevel <= threshold else { return } - for threshold in lowBatteryAlertThresholds { - if previousLevel >= threshold && newLevel < threshold { - self.statusText = String(localized: "Low battery") - notifyImportanChangeStatus() - playLowBatteryAlertSound() - break - } + self.statusText = String(localized: "Low battery") + presentTemporaryBatteryHUDIfNeeded(kind: .lowBattery) + if Defaults[.playLowBatteryAlertSound] { + playLowBatteryAlertSound() } } + 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() { lowBatteryAlertSoundPlayer.play(fileName: "lowbattery", fileExtension: "mp3") } diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 198cd85..86b4f34 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -473,6 +473,22 @@ enum ProgressBarStyle: String, CaseIterable, Identifiable, Defaults.Serializable 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 { case shuffle case repeatMode @@ -910,6 +926,10 @@ extension Defaults.Keys { ) static let lockScreenPanelUsesBlur = Key("lockScreenPanelUsesBlur", default: true) static let lockScreenMusicMergedAirPlayOutput = Key("lockScreenMusicMergedAirPlayOutput", default: true) + static let lockScreenMusicFullscreenArtworkEnabled = Key("lockScreenMusicFullscreenArtworkEnabled", default: true) + static let lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork = Key("lockScreenKeepAlbumArtVisibleDuringFullscreenArtwork", default: false) + static let lockScreenMusicFullscreenVideoArtwork = Key("lockScreenMusicFullscreenVideoArtwork", default: true) + static let lockScreenUseArtworkLayoutOverFullscreenCanvas = Key("lockScreenShowCenteredAlbumArtOverFullscreenCanvas", default: true) static let lockScreenTimerWidgetUsesBlur = Key("lockScreenTimerWidgetUsesBlur", default: false) static let lockScreenReminderChipStyle = Key("lockScreenReminderChipStyle", default: .eventColor) static let lockScreenReminderWidgetHorizontalAlignment = Key("lockScreenReminderWidgetHorizontalAlignment", default: "center") @@ -937,7 +957,17 @@ extension Defaults.Keys { static let showBatteryPercentage = Key("showBatteryPercentage", default: true) static let showPowerStatusIcons = Key("showPowerStatusIcons", default: true) static let playLowBatteryAlertSound = Key("playLowBatteryAlertSound", default: true) - + static let showChargingBatteryHUD = Key("showChargingBatteryHUD", default: true) + static let showLowBatteryHUD = Key("showLowBatteryHUD", default: true) + static let showFullBatteryHUD = Key("showFullBatteryHUD", default: true) + static let chargingBatteryHUDDuration = Key("chargingBatteryHUDDuration", default: 3) + static let lowBatteryHUDDuration = Key("lowBatteryHUDDuration", default: 3) + static let fullBatteryHUDDuration = Key("fullBatteryHUDDuration", default: 3) + static let lowBatteryHUDThreshold = Key("lowBatteryHUDThreshold", default: 20) + static let fullBatteryHUDThreshold = Key("fullBatteryHUDThreshold", default: 100) + static let lowBatteryHUDStyle = Key("lowBatteryHUDStyle", default: .standard) + static let fullBatteryHUDStyle = Key("fullBatteryHUDStyle", default: .standard) + static let lockScreenBatteryShowsBatteryGauge = Key( "lockScreenWeatherShowsBatteryGauge", default: BatteryActivityManager.shared.hasBattery() @@ -988,6 +1018,10 @@ extension Defaults.Keys { // MARK: Media Controller static let mediaController = Key("mediaController", default: defaultMediaController) + static let spotifySPDCCookie = Key("spotifySPDCCookie", default: "") + static let spotifyAuthAccessToken = Key("spotifyAuthAccessToken", default: "") + static let spotifyAuthAccessTokenExpiration = Key("spotifyAuthAccessTokenExpiration", default: 0) + static let spotifyAuthLastValidatedAt = Key("spotifyAuthLastValidatedAt", default: 0) // MARK: Bluetooth Audio Devices static let showBluetoothDeviceConnections = Key("showBluetoothDeviceConnections", default: true) @@ -1190,6 +1224,7 @@ extension Defaults.Keys { // MARK: Lyrics Feature static let enableLyrics = Key("enableLyrics", default: false) + static let showLiveCanvasInDynamicIsland = Key("showLiveCanvasInDynamicIsland", default: false) // MARK: Notes Feature static let enableNotes = Key("enableNotes", default: false) diff --git a/DynamicIsland/models/PlaybackState.swift b/DynamicIsland/models/PlaybackState.swift index 516e6c2..312a708 100644 --- a/DynamicIsland/models/PlaybackState.swift +++ b/DynamicIsland/models/PlaybackState.swift @@ -44,6 +44,7 @@ struct PlaybackState { var repeatMode: RepeatMode = .off var lastUpdated: Date = Date.distantPast var artwork: Data? + var liveArtworkURL: URL? } extension PlaybackState: Equatable { @@ -61,5 +62,6 @@ extension PlaybackState: Equatable { && lhs.isShuffled == rhs.isShuffled && lhs.repeatMode == rhs.repeatMode && lhs.artwork == rhs.artwork + && lhs.liveArtworkURL == rhs.liveArtworkURL } } diff --git a/Localizable.xcstrings b/Localizable.xcstrings index cdc5a64..dd41f8c 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -11994,8 +11994,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "Amazon Music" } } } @@ -36021,6 +36021,12 @@ "value": "Включить медиа ", "state": "translated" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启用媒体 " + } } } }, @@ -60469,8 +60475,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "仅应用图标" } } } @@ -60599,8 +60605,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "仅下载图标" } } } @@ -60729,8 +60735,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "透明度:" } } } @@ -60865,8 +60871,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "打开 Google AI Studio" } } } @@ -60996,8 +61002,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "在日历中打开" } } } @@ -61132,8 +61138,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "打开模型设置" } } } @@ -61152,6 +61158,12 @@ "value": "Открывать вырез при наведении", "state": "translated" } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "悬停时打开刘海" + } } } }, @@ -61280,8 +61292,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "打开设置" } } } @@ -61558,8 +61570,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "打开 AI 助手面板,进行文件分析与对话。默认为 Cmd+Shift+A。仅在启用屏幕助手功能时有效。" } } } @@ -61700,8 +61712,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "打开剪贴板历史记录面板。默认为 Cmd+Shift+V(类似于 PC 上的 Windows+V)。仅在启用剪贴板功能时有效。" } } } @@ -61842,8 +61854,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "打开取色器面板进行屏幕颜色捕获。默认为 Cmd+Shift+P。仅在启用取色器功能时有效。" } } } @@ -61973,8 +61985,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "输出设备" } } } @@ -62104,8 +62116,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "输出音量" } } } @@ -62246,8 +62258,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "面板模式会在刘海附近的浮动窗口中显示剪贴板。弹出模式会将剪贴板显示为附加在剪贴板按钮上的下拉菜单。" } } } @@ -62388,8 +62400,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "面板模式会在浮动窗口中显示取色器。弹出模式会将取色器显示为附加在取色器按钮上的下拉菜单。" } } } @@ -62530,8 +62542,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "面板模式会在刘海附近的浮动窗口中显示助手。弹出模式会将助手显示为附加在 AI 按钮上的下拉菜单。" } } } @@ -62790,8 +62802,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "已暂停" } } } @@ -63310,8 +63322,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "已选取 %@" } } } @@ -63440,8 +63452,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "拾取的颜色" } } } @@ -63570,8 +63582,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "拾取状态" } } } @@ -63700,8 +63712,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "从历史记录中固定项目以保存在此处" } } } @@ -63830,8 +63842,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "固定项目以添加收藏" } } } @@ -63960,8 +63972,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "固定项目以将它们添加到收藏夹" } } } @@ -64090,8 +64102,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "已固定项目" } } } @@ -64226,8 +64238,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "请在模型设置中为所选的 AI 提供商配置您的 API 密钥。" } } } @@ -64521,8 +64533,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "弹出模式会将剪贴板显示为附加在剪贴板按钮上的下拉菜单。面板模式会在刘海附近的浮动窗口中显示剪贴板。" } } } @@ -64663,8 +64675,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "弹出模式会将取色器显示为附加在取色器按钮上的下拉菜单。面板模式会在浮动窗口中显示取色器。" } } } @@ -64805,8 +64817,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "弹出模式会将助手显示为附加在 AI 按钮上的下拉菜单。面板模式会在刘海附近的浮动窗口中显示助手。" } } } @@ -65340,8 +65352,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "预设将显示在计时器弹出窗口中,包括配置的名称、持续时间和重音颜色。重新排序以更改显示顺序。" } } } @@ -65471,8 +65483,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "气压" } } } @@ -65731,8 +65743,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "value": "预览缩放:" } } } @@ -89120,8 +89132,8 @@ }, "zh-Hans": { "stringUnit": { - "state": "new", - "value": "" + "state": "translated", + "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"