mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Right-click on the album art in the lock screen music panel to set it as the desktop wallpaper. Right-click again or click anywhere on the background to restore the original wallpaper. Wallpaper also restores automatically on track change or screen unlock. Uses direct plist manipulation of the wallpaper config to avoid leaving traces in the wallpaper picker. Album art PNG is pre-cached in the background on track change for instant application. Adds video artwork URL fetching from iTunes catalog for Apple Music tracks (musicVideo entity search). Signed-off-by: Jis G Jacob <studiokeys@blissroms.org>
316 lines
11 KiB
Swift
316 lines
11 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 Foundation
|
|
import Combine
|
|
import AppKit
|
|
import Defaults
|
|
import SwiftUI
|
|
import AVFoundation
|
|
|
|
enum LockScreenAnimationTimings {
|
|
static let lockExpand: TimeInterval = 0.45
|
|
static let unlockCollapse: TimeInterval = 0.82
|
|
static let postUnlockMusicHUDPause: TimeInterval = 1.0
|
|
static let postUnlockMusicHUDReveal: TimeInterval = 0.34
|
|
}
|
|
|
|
@MainActor
|
|
class LockScreenManager: ObservableObject {
|
|
static let shared = LockScreenManager()
|
|
|
|
// MARK: - Coordinator
|
|
private let coordinator = DynamicIslandViewCoordinator.shared
|
|
private weak var viewModel: DynamicIslandViewModel?
|
|
|
|
// MARK: - Published Properties
|
|
@Published var isLocked: Bool = false
|
|
@Published var isLockIdle: Bool = true
|
|
@Published var shouldDelayPostUnlockMusicHUD: Bool = false
|
|
@Published var lastUpdated: Date = .distantPast
|
|
|
|
// MARK: - Private Properties
|
|
private var debounceIdleTask: Task<Void, Never>?
|
|
private var collapseTask: Task<Void, Never>?
|
|
private var postUnlockMusicHUDTask: Task<Void, Never>?
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func timestamp() -> String {
|
|
let formatter = DateFormatter()
|
|
formatter.dateFormat = "HH:mm:ss.SSS"
|
|
return formatter.string(from: Date())
|
|
}
|
|
|
|
// MARK: - Initialization
|
|
private init() {
|
|
setupObservers()
|
|
print("LockScreenManager: 🔒 Initialized")
|
|
}
|
|
|
|
deinit {
|
|
DistributedNotificationCenter.default().removeObserver(self)
|
|
debounceIdleTask?.cancel()
|
|
collapseTask?.cancel()
|
|
postUnlockMusicHUDTask?.cancel()
|
|
}
|
|
|
|
// MARK: - Setup
|
|
|
|
private func setupObservers() {
|
|
// Observe screen locked event
|
|
DistributedNotificationCenter.default().addObserver(
|
|
self,
|
|
selector: #selector(screenLocked),
|
|
name: .init("com.apple.screenIsLocked"),
|
|
object: nil
|
|
)
|
|
|
|
// Observe screen unlocked event
|
|
DistributedNotificationCenter.default().addObserver(
|
|
self,
|
|
selector: #selector(screenUnlocked),
|
|
name: .init("com.apple.screenIsUnlocked"),
|
|
object: nil
|
|
)
|
|
|
|
print("LockScreenManager: ✅ Observers registered for lock/unlock events")
|
|
}
|
|
|
|
// MARK: - Event Handlers
|
|
|
|
@objc private func screenLocked() {
|
|
guard !isLocked else {
|
|
print("[\(timestamp())] LockScreenManager: 🔁 Duplicate LOCK event ignored")
|
|
return
|
|
}
|
|
print("[\(timestamp())] LockScreenManager: 🔒 Screen LOCKED event received")
|
|
Logger.log("LockScreenManager: Screen locked", category: .lifecycle)
|
|
LockSoundPlayer.shared.playLockChime()
|
|
LockScreenDisplayContextProvider.shared.refresh(reason: "screen-locked")
|
|
|
|
// Update state SYNCHRONOUSLY without Task/await to avoid any delay
|
|
lastUpdated = Date()
|
|
updateIdleState(locked: true)
|
|
postUnlockMusicHUDTask?.cancel()
|
|
shouldDelayPostUnlockMusicHUD = false
|
|
|
|
// Set locked state immediately without animation wrapper
|
|
isLocked = true
|
|
collapseTask?.cancel()
|
|
|
|
viewModel?.closeForLockScreen()
|
|
|
|
if coordinator.expandingView.show {
|
|
let currentType = coordinator.expandingView.type
|
|
coordinator.toggleExpandingView(status: false, type: currentType)
|
|
}
|
|
|
|
if coordinator.sneakPeek.show {
|
|
coordinator.toggleSneakPeek(status: false, type: coordinator.sneakPeek.type)
|
|
}
|
|
|
|
// Show panel FIRST (creates and shows window on lock screen)
|
|
print("[\(timestamp())] LockScreenManager: 🎵 Showing lock screen panel")
|
|
LockScreenPanelManager.shared.showPanel()
|
|
LockScreenLiveActivityWindowManager.shared.showLocked()
|
|
LockScreenWeatherManager.shared.showWeatherWidget()
|
|
LockScreenTimerWidgetManager.shared.handleLockStateChange(isLocked: true)
|
|
TimerControlWindowManager.shared.hide(animated: false)
|
|
|
|
// THEN trigger lock icon in Atoll (only if enabled in settings)
|
|
if Defaults[.enableLockScreenLiveActivity] {
|
|
print("[\(timestamp())] LockScreenManager: 🔴 Starting lock icon live activity")
|
|
coordinator.toggleExpandingView(status: true, type: .lockScreen)
|
|
} else {
|
|
print("[\(timestamp())] LockScreenManager: ⏭️ Lock icon disabled in settings")
|
|
}
|
|
|
|
print("[\(timestamp())] LockScreenManager: ✅ Lock screen activated")
|
|
}
|
|
|
|
@objc private func screenUnlocked() {
|
|
guard isLocked else {
|
|
print("[\(timestamp())] LockScreenManager: 🔁 Unlock event ignored (already unlocked)")
|
|
return
|
|
}
|
|
print("[\(timestamp())] LockScreenManager: 🔓 Screen UNLOCKED event received")
|
|
Logger.log("LockScreenManager: Screen unlocked", category: .lifecycle)
|
|
LockSoundPlayer.shared.playUnlockChime()
|
|
LockScreenDisplayContextProvider.shared.refresh(reason: "screen-unlocked")
|
|
lastUpdated = Date()
|
|
updateIdleState(locked: false)
|
|
isLocked = false
|
|
postUnlockMusicHUDTask?.cancel()
|
|
shouldDelayPostUnlockMusicHUD = Defaults[.enableLockScreenLiveActivity]
|
|
|
|
if shouldDelayPostUnlockMusicHUD {
|
|
postUnlockMusicHUDTask = Task { [weak self] in
|
|
try? await Task.sleep(
|
|
for: .seconds(
|
|
LockScreenAnimationTimings.unlockCollapse
|
|
+ LockScreenAnimationTimings.postUnlockMusicHUDPause
|
|
)
|
|
)
|
|
guard let self = self, !Task.isCancelled else { return }
|
|
await MainActor.run {
|
|
if !self.isLocked {
|
|
withAnimation(
|
|
.spring(
|
|
response: LockScreenAnimationTimings.postUnlockMusicHUDReveal,
|
|
dampingFraction: 0.88,
|
|
blendDuration: 0.08
|
|
)
|
|
) {
|
|
self.shouldDelayPostUnlockMusicHUD = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
|
|
// Update state immediately
|
|
if Defaults[.enableLockScreenLiveActivity] {
|
|
collapseTask?.cancel()
|
|
collapseTask = Task { [weak self] in
|
|
try? await Task.sleep(for: .seconds(LockScreenAnimationTimings.unlockCollapse))
|
|
guard let self = self, !Task.isCancelled else { return }
|
|
await MainActor.run {
|
|
self.coordinator.toggleExpandingView(status: false, type: .lockScreen)
|
|
}
|
|
}
|
|
}
|
|
|
|
print("[\(self.timestamp())] LockScreenManager: ✅ Lock screen deactivated")
|
|
}
|
|
|
|
// MARK: - Idle State Management
|
|
|
|
/// Copy EXACT logic from ScreenRecordingManager
|
|
private func updateIdleState(locked: Bool) {
|
|
if locked {
|
|
isLockIdle = false
|
|
debounceIdleTask?.cancel()
|
|
} else {
|
|
debounceIdleTask?.cancel()
|
|
debounceIdleTask = Task { [weak self] in
|
|
// Keep the lock live activity mounted until the collapse animation finishes,
|
|
// otherwise the content disappears before the island fully closes.
|
|
let idleDelay = LockScreenAnimationTimings.unlockCollapse
|
|
try? await Task.sleep(for: .seconds(idleDelay))
|
|
guard let self = self, !Task.isCancelled else { return }
|
|
await MainActor.run {
|
|
if self.lastUpdated.timeIntervalSinceNow < -idleDelay {
|
|
withAnimation(.smooth(duration: 0.3)) {
|
|
self.isLockIdle = !self.isLocked
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Extensions
|
|
|
|
extension LockScreenManager {
|
|
func configure(viewModel: DynamicIslandViewModel) {
|
|
self.viewModel = viewModel
|
|
}
|
|
|
|
/// Get current lock status without async
|
|
var currentLockStatus: Bool {
|
|
return isLocked
|
|
}
|
|
|
|
/// Check if monitoring is available (for settings UI)
|
|
var isMonitoringAvailable: Bool {
|
|
return true // Always available on macOS
|
|
}
|
|
}
|
|
|
|
// MARK: - Lock Sound Playback
|
|
|
|
@MainActor
|
|
final class LockSoundPlayer {
|
|
static let shared = LockSoundPlayer()
|
|
private let throttleInterval: TimeInterval = 0.25
|
|
private var players: [SoundType: AVAudioPlayer] = [:]
|
|
private var lastPlaybackDates: [SoundType: Date] = [:]
|
|
|
|
private init() {}
|
|
|
|
func playLockChime() {
|
|
play(.lock)
|
|
}
|
|
|
|
func playUnlockChime() {
|
|
play(.unlock)
|
|
}
|
|
|
|
private func play(_ type: SoundType) {
|
|
guard Defaults[.enableLockSounds] else { return }
|
|
guard shouldPlay(type) else { return }
|
|
guard let player = resolvePlayer(for: type) else { return }
|
|
|
|
player.currentTime = 0
|
|
player.play()
|
|
lastPlaybackDates[type] = Date()
|
|
}
|
|
|
|
private func shouldPlay(_ type: SoundType) -> Bool {
|
|
guard let last = lastPlaybackDates[type] else { return true }
|
|
return Date().timeIntervalSince(last) >= throttleInterval
|
|
}
|
|
|
|
private func resolvePlayer(for type: SoundType) -> AVAudioPlayer? {
|
|
if let cached = players[type] {
|
|
return cached
|
|
}
|
|
|
|
guard let url = Bundle.main.url(forResource: type.resourceName, withExtension: "mp3") else {
|
|
Logger.log("Missing \(type.resourceName).mp3 in bundle", category: .warning)
|
|
return nil
|
|
}
|
|
|
|
do {
|
|
let player = try AVAudioPlayer(contentsOf: url)
|
|
player.prepareToPlay()
|
|
players[type] = player
|
|
return player
|
|
} catch {
|
|
Logger.log("Failed to initialize lock sound player for \(type.resourceName): \(error.localizedDescription)", category: .error)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private enum SoundType: String {
|
|
case lock
|
|
case unlock
|
|
|
|
var resourceName: String { rawValue }
|
|
}
|
|
}
|