mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
SIGSTOP WallpaperAgent and the aerial extensions while MusicManager reports paused so the lockscreen Canvas holds its current frame, and SIGCONT on resume, hide(), and willTerminate so the agent is never left suspended. Signed-off-by: Jis G Jacob <studiokeys@blissroms.org>
1801 lines
67 KiB
Swift
1801 lines
67 KiB
Swift
/*
|
|
* Atoll (DynamicIsland)
|
|
* Copyright (C) 2024-2026 Atoll Contributors
|
|
*
|
|
* This program is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import AppKit
|
|
import 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<Void, Never>?
|
|
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<Void, Never>?
|
|
private var panelFrameChangeObserver: NSObjectProtocol?
|
|
private var liveWallpaperTask: Task<Void, Never>?
|
|
private var deferredTrackRefreshTask: Task<Void, Never>?
|
|
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<FullScreenLyricsOverlayContent>
|
|
if let existingWindow = lyricsOverlayWindow,
|
|
let existingView = existingWindow.contentView as? NSHostingView<FullScreenLyricsOverlayContent> {
|
|
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()
|
|
}
|
|
}
|
|
}
|