diff --git a/.gitignore b/.gitignore index 5de2a33..8d394d2 100644 --- a/.gitignore +++ b/.gitignore @@ -85,4 +85,9 @@ comparison_report.json ANIMATION_DEBUG_GUIDE.md MIGRATION_SUMMARY.md ONBOARDING_ENHANCEMENT.md -SHEET_PRESENTATION_FIX.md \ No newline at end of file +SHEET_PRESENTATION_FIX.md +lock_mode_reference.md +liquid_glass.md + +MoveToSky.xcodeproj +/MoveToSky/ \ No newline at end of file diff --git a/DynamicIsland.xcodeproj/project.pbxproj b/DynamicIsland.xcodeproj/project.pbxproj index 66af6e1..2a8b3eb 100644 --- a/DynamicIsland.xcodeproj/project.pbxproj +++ b/DynamicIsland.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */ = {isa = PBXBuildFile; productRef = 7E61181E2E24071E0050006C /* SkyLightWindow */; }; 7E25ECA12E6C6C3800700E72 /* CoreBrightness.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E25ECA02E6C6C3800700E72 /* CoreBrightness.framework */; }; 7E25ECA32E6C6E2600700E72 /* OSD.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E25ECA22E6C6E2600700E72 /* OSD.framework */; }; 7E25ECA52E6C6E7800700E72 /* DisplayServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7E25ECA42E6C6E7800700E72 /* DisplayServices.framework */; }; @@ -72,6 +73,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 7E1ADF1D2EA209B1001DA867 /* SkyLightWindow in Frameworks */, 7E25ECA12E6C6C3800700E72 /* CoreBrightness.framework in Frameworks */, 7EE1D73F2E6B1E1200997219 /* IOKit.framework in Frameworks */, 7EE1D73D2E6B1E0B00997219 /* ApplicationServices.framework in Frameworks */, @@ -174,6 +176,7 @@ B1628B912CC260C0003D8DF3 /* SwiftUIIntrospect */, B194240A2CD10017003E5DC2 /* LottieUI */, 146BC4362D366E9B0047C66A /* MacroVisionKit */, + 7E61181E2E24071E0050006C /* SkyLightWindow */, ); productName = DynamicIsland; productReference = F54406E72CD4987A00C7AFD9 /* DynamicIsland.app */; @@ -214,6 +217,7 @@ B1628B902CC260C0003D8DF3 /* XCRemoteSwiftPackageReference "swiftui-introspect" */, B19424072CD0DB52003E5DC2 /* XCRemoteSwiftPackageReference "LottieUI" */, 146BC4352D366E9B0047C66A /* XCRemoteSwiftPackageReference "MacroVisionKit" */, + 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */, ); preferredProjectObjectVersion = 77; productRefGroup = F54406E82CD4987A00C7AFD9 /* Products */; @@ -517,6 +521,14 @@ minimumVersion = 2.6.4; }; }; + 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Lakr233/SkyLightWindow"; + requirement = { + branch = main; + kind = branch; + }; + }; 9A987A112C73CAA1005CA465 /* XCRemoteSwiftPackageReference "Pow" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/EmergeTools/Pow"; @@ -588,6 +600,11 @@ package = 14D0321B2C68F3350096E6A1 /* XCRemoteSwiftPackageReference "Sparkle" */; productName = Sparkle; }; + 7E61181E2E24071E0050006C /* SkyLightWindow */ = { + isa = XCSwiftPackageProductDependency; + package = 7E61181F2E24071E0050006C /* XCRemoteSwiftPackageReference "SkyLightWindow" */; + productName = SkyLightWindow; + }; 9A987A0F2C73CA8D005CA465 /* Collections */ = { isa = XCSwiftPackageProductDependency; package = F54407082CD4A23F00C7AFD9 /* XCRemoteSwiftPackageReference "swift-collections" */; diff --git a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d8400f2..397f9e1 100644 --- a/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/DynamicIsland.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2741287b65cf0758abd89baadf314f5f8cbfcb46cb8d236a83f98731e5501ee3", + "originHash" : "7d8482ed9f8bfd7995906cd0663db3b8e6656cebc1c013800b81bccf13756944", "pins" : [ { "identity" : "defaults", @@ -64,6 +64,15 @@ "version" : "1.0.5" } }, + { + "identity" : "skylightwindow", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Lakr233/SkyLightWindow", + "state" : { + "branch" : "main", + "revision" : "4a7e8628c85e6b2ec429b57657a556a2171a4864" + } + }, { "identity" : "sparkle", "kind" : "remoteSourceControl", diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index aad7e35..867f899 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -25,6 +25,7 @@ struct ContentView: View { @ObservedObject var statsManager = StatsManager.shared @ObservedObject var recordingManager = ScreenRecordingManager.shared @ObservedObject var privacyManager = PrivacyIndicatorManager.shared + @ObservedObject var lockScreenManager = LockScreenManager.shared @Default(.enableStatsFeature) var enableStatsFeature @Default(.showCpuGraph) var showCpuGraph @@ -422,6 +423,8 @@ struct ContentView: View { TimerLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .recording) && vm.notchState == .closed && (recordingManager.isRecording || !recordingManager.isRecorderIdle) && Defaults[.enableScreenRecordingDetection] && !vm.hideOnClosed { RecordingLiveActivity() + } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .lockScreen) && vm.notchState == .closed && (lockScreenManager.isLocked || !lockScreenManager.isLockIdle) && Defaults[.enableLockScreenLiveActivity] && !vm.hideOnClosed { + LockScreenLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .privacy) && vm.notchState == .closed && privacyManager.hasAnyIndicator && (Defaults[.enableCameraDetection] || Defaults[.enableMicrophoneDetection]) && !vm.hideOnClosed { PrivacyLiveActivity() } else if !coordinator.expandingView.show && vm.notchState == .closed && (!musicManager.isPlaying && musicManager.isPlayerIdle) && Defaults[.showNotHumanFace] && !vm.hideOnClosed { diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 5e8703c..ec917b0 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -11,6 +11,7 @@ import Defaults import KeyboardShortcuts import Sparkle import SwiftUI +import SkyLightWindow @main struct DynamicNotchApp: App { @@ -85,6 +86,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { let dndManager = DoNotDisturbManager.shared // NEW: DND detection let bluetoothAudioManager = BluetoothAudioManager.shared // NEW: Bluetooth audio detection let idleAnimationManager = IdleAnimationManager.shared // NEW: Custom idle animations + let lockScreenPanelManager = LockScreenPanelManager.shared // NEW: Lock screen music panel var closeNotchWorkItem: DispatchWorkItem? private var previousScreens: [NSScreen]? private var onboardingWindowController: NSWindowController? @@ -251,11 +253,12 @@ class AppDelegate: NSObject, NSApplicationDelegate { rootView: ContentView() .environmentObject(viewModel) .environmentObject(webcamManager) + //.moveToSky() ) window.orderFrontRegardless() NotchSpaceManager.shared.notchSpace.windows.insert(window) - + //SkyLightOperator.shared.delegateWindow(window) return window } diff --git a/DynamicIsland/DynamicIslandViewCoordinator.swift b/DynamicIsland/DynamicIslandViewCoordinator.swift index 42fd14f..ebb402b 100644 --- a/DynamicIsland/DynamicIslandViewCoordinator.swift +++ b/DynamicIsland/DynamicIslandViewCoordinator.swift @@ -22,6 +22,7 @@ enum SneakContentType { case recording case bluetoothAudio case privacy + case lockScreen } struct sneakPeek { diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 6cad043..b5d8596 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -4816,6 +4816,9 @@ }, "Controls" : { + }, + "Copy Latest Crash Report" : { + }, "Copy something to get started" : { @@ -6662,6 +6665,10 @@ } } }, + "Enable lock screen media widget" : { + "comment" : "A toggle that enables or disables the lock screen media widget.", + "isCommentAutoGenerated" : true + }, "Enable music live activity" : { "localizations" : { "ar" : { @@ -9188,6 +9195,9 @@ }, "Live Performance Data" : { + }, + "Lock Screen" : { + }, "Loop Mode" : { @@ -15106,6 +15116,9 @@ } } } + }, + "Shows a lock icon in the Dynamic Island when the screen is locked. The lock screen music panel can be toggled separately in Media settings." : { + }, "Shows green camera icon and yellow microphone icon when in use. Uses event-driven CoreAudio and CoreMediaIO APIs." : { diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift new file mode 100644 index 0000000..f998c97 --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift @@ -0,0 +1,78 @@ +// +// LockScreenLiveActivity.swift +// DynamicIsland +// +// Created for lock screen live activity +// + +import SwiftUI +import Defaults +import SkyLightWindow + +struct LockScreenLiveActivity: View { + @EnvironmentObject var vm: DynamicIslandViewModel + // Don't observe lockScreenManager - just show static lock icon without state-driven animations + // This prevents animation conflicts with panel window teardown + @State private var isHovering: Bool = false + @State private var gestureProgress: CGFloat = 0 + @State private var isExpanded: Bool = false + + var body: some View { + HStack(spacing: 0) { + // Left - Lock icon with subtle glow + Color.clear + .background { + if isExpanded { + HStack { + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(Color.blue.opacity(0.15)) + + Image(systemName: "lock.fill") + .font(.system(size: 12)) + .foregroundColor(.blue) + .modifier(LockPulsingModifier()) + } + .frame(width: vm.effectiveClosedNotchHeight - 12, height: vm.effectiveClosedNotchHeight - 12) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + } + .frame(width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) + + // Center - Black fill + Rectangle() + .fill(.black) + .frame(width: vm.closedNotchSize.width + (isHovering ? 8 : 0)) + + // Right - Empty for symmetry with animation + Color.clear + .frame(width: isExpanded ? max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2) : 0, height: vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)) + } + .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) + .onAppear { + // Expand immediately without animation to avoid conflicts + isExpanded = true + } + .onDisappear { + // Collapse immediately when removed from hierarchy + isExpanded = false + } + } +} + +// Pulsing animation modifier for lock indicator +struct LockPulsingModifier: ViewModifier { + @State private var isPulsing = false + + func body(content: Content) -> some View { + content + .scaleEffect(isPulsing ? 1.1 : 1.0) + .opacity(isPulsing ? 0.8 : 1.0) + .onAppear { + withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) { + isPulsing = true + } + } + } +} diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift new file mode 100644 index 0000000..0e9bec5 --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -0,0 +1,257 @@ +// +// LockScreenMusicPanel.swift +// DynamicIsland +// +// Created for lock screen music panel with liquid glass effect +// + +import SwiftUI +import Defaults + +struct LockScreenMusicPanel: View { + @ObservedObject var musicManager = MusicManager.shared + @State private var sliderValue: Double = 0 + @State private var dragging: Bool = false + @State private var lastDragged: Date = .distantPast + @State private var isActive = true + + var body: some View { + if isActive { + panelContent + .onDisappear { + isActive = false + } + } else { + Color.clear + .frame(width: 420, height: 150) + } + } + + private var panelContent: some View { + VStack(spacing: 0) { + // Header area with album art + HStack(alignment: .center, spacing: 10) { + // Album art + Button { + musicManager.openMusicApp() + } label: { + Image(nsImage: musicManager.albumArt) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 50, height: 50) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .buttonStyle(PlainButtonStyle()) + .opacity(musicManager.isPlaying ? 1 : 0.4) + .scaleEffect(musicManager.isPlaying ? 1 : 0.85) + + // Song info + VStack(alignment: .leading, spacing: 1) { + Text(musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.white) + .lineLimit(1) + + Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName) + .font(.system(size: 10, weight: .regular)) + .foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.6) : .gray) + .lineLimit(1) + } + + Spacer() + + // Visualizer + if Defaults[.useMusicVisualizer] { + Rectangle() + .fill(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor).gradient : Color.gray.gradient) + .mask { + AudioSpectrumView(isPlaying: .constant(musicManager.isPlaying)) + .frame(width: 20, height: 16) + } + .frame(width: 20, height: 16) + } + } + .frame(height: 50) + + // Progress bar + progressBar + .padding(.top, 6) + + // Playback controls + playbackControls + .padding(.top, 4) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .frame(width: 420, height: 150) + .background { + if #available(macOS 26.0, *) { + RoundedRectangle(cornerRadius: 20) + .glassEffect(in: .rect(cornerRadius: 20)) + .onAppear { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + print("[\(formatter.string(from: Date()))] LockScreenMusicPanel: ✨ Using macOS 26.0+ Liquid Glass effect") + } + } else { + RoundedRectangle(cornerRadius: 20) + .fill(.ultraThinMaterial) + .onAppear { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + print("[\(formatter.string(from: Date()))] LockScreenMusicPanel: 🪟 Using ultraThinMaterial fallback (macOS < 26.0)") + } + } + } + .shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10) + .onAppear { + sliderValue = musicManager.elapsedTime + isActive = true + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + print("[\(formatter.string(from: Date()))] LockScreenMusicPanel: ✅ View appeared") + } + } + + // MARK: - Progress Bar + + private var progressBar: some View { + TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in + let currentElapsed = currentSliderValue(timeline.date) + + HStack(spacing: 8) { + // Elapsed time + Text(formatTime(dragging ? sliderValue : currentElapsed)) + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundColor(.white.opacity(0.7)) + .frame(width: 42, alignment: .leading) + + // Progress bar + GeometryReader { geometry in + ZStack(alignment: .leading) { + // Background track + RoundedRectangle(cornerRadius: 3) + .fill(Color.white.opacity(0.2)) + .frame(height: 6) + + // Filled portion + RoundedRectangle(cornerRadius: 3) + .fill(sliderColor) + .frame(width: max(0, geometry.size.width * (currentSliderValue(timeline.date) / max(musicManager.songDuration, 1))), height: 6) + } + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + dragging = true + let newValue = min(max(0, Double(value.location.x / geometry.size.width) * musicManager.songDuration), musicManager.songDuration) + sliderValue = newValue + lastDragged = Date() + } + .onEnded { _ in + musicManager.seek(to: sliderValue) + dragging = false + } + ) + } + .frame(height: 6) + + // Time remaining + Text("-\(formatTime(musicManager.songDuration - (dragging ? sliderValue : currentElapsed)))") + .font(.system(size: 11, weight: .medium, design: .monospaced)) + .foregroundColor(.white.opacity(0.7)) + .frame(width: 48, alignment: .trailing) + } + } + .onAppear { + sliderValue = musicManager.elapsedTime + } + } + + private func currentSliderValue(_ date: Date) -> Double { + if dragging { + return sliderValue + } + + if musicManager.isPlaying { + let timeSinceLastUpdate = date.timeIntervalSince(musicManager.timestampDate) + let estimatedElapsed = musicManager.elapsedTime + (timeSinceLastUpdate * musicManager.playbackRate) + return min(estimatedElapsed, musicManager.songDuration) + } + + return musicManager.elapsedTime + } + + private var sliderColor: Color { + switch Defaults[.sliderColor] { + case .white: + return .white + case .albumArt: + return Color(nsColor: musicManager.avgColor) + case .accent: + return .accentColor + } + } + + private func formatTime(_ time: TimeInterval) -> String { + let minutes = Int(time) / 60 + let seconds = Int(time) % 60 + return String(format: "%d:%02d", minutes, seconds) + } + + // MARK: - Playback Controls + + private var playbackControls: some View { + HStack(spacing: 20) { + // Always show shuffle on lock screen + controlButton(icon: "shuffle", isActive: musicManager.isShuffled) { + Task { await musicManager.toggleShuffle() } + } + + controlButton(icon: "backward.fill", size: 18) { + Task { await musicManager.previousTrack() } + } + + playPauseButton + + controlButton(icon: "forward.fill", size: 18) { + Task { await musicManager.nextTrack() } + } + + // Always show repeat on lock screen + controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) { + Task { await musicManager.toggleRepeat() } + } + } + .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 2) + } + + private var playPauseButton: some View { + Button(action: { + Task { await musicManager.togglePlay() } + }) { + Image(systemName: musicManager.isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 24, weight: .semibold)) + .foregroundColor(.white) + .frame(width: 50, height: 50) + } + .buttonStyle(PlainButtonStyle()) + } + + private func controlButton(icon: String, size: CGFloat = 18, isActive: Bool = false, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: size, weight: .medium)) + .foregroundColor(isActive ? .red : .white.opacity(0.8)) + } + .buttonStyle(PlainButtonStyle()) + } + + private var repeatIcon: String { + switch musicManager.repeatMode { + case .off: return "repeat" + case .all: return "repeat" + case .one: return "repeat.1" + } + } +} diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 0fcc1a4..8e6bc5d 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -295,6 +295,18 @@ struct GeneralSettings: View { } footer: { Text("Shows green camera icon and yellow microphone icon when in use. Uses event-driven CoreAudio and CoreMediaIO APIs.") } + + Section { + Defaults.Toggle("Enable Lock Screen Live Activity", key: .enableLockScreenLiveActivity) + + Button("Copy Latest Crash Report") { + copyLatestCrashReport() + } + } header: { + Text("Lock Screen") + } footer: { + Text("Shows a lock icon in the Dynamic Island when the screen is locked. The lock screen music panel can be toggled separately in Media settings.") + } Section { Picker(selection: $notchHeightMode, label: @@ -373,6 +385,43 @@ struct GeneralSettings: View { } } } + + private func copyLatestCrashReport() { + let crashReportsPath = NSString(string: "~/Library/Logs/DiagnosticReports").expandingTildeInPath + let fileManager = FileManager.default + + do { + let files = try fileManager.contentsOfDirectory(atPath: crashReportsPath) + let crashFiles = files.filter { $0.contains("DynamicIsland") && $0.hasSuffix(".crash") } + + guard let latestCrash = crashFiles.sorted(by: >).first else { + let alert = NSAlert() + alert.messageText = "No Crash Reports Found" + alert.informativeText = "No crash reports found for DynamicIsland" + alert.alertStyle = .informational + alert.runModal() + return + } + + let crashPath = (crashReportsPath as NSString).appendingPathComponent(latestCrash) + let crashContent = try String(contentsOfFile: crashPath, encoding: .utf8) + + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(crashContent, forType: .string) + + let alert = NSAlert() + alert.messageText = "Crash Report Copied" + alert.informativeText = "Crash report '\(latestCrash)' has been copied to clipboard" + alert.alertStyle = .informational + alert.runModal() + } catch { + let alert = NSAlert() + alert.messageText = "Error" + alert.informativeText = "Failed to read crash reports: \(error.localizedDescription)" + alert.alertStyle = .warning + alert.runModal() + } + } @ViewBuilder func gestureControls() -> some View { @@ -709,6 +758,12 @@ struct Media: View { customBadge(text: "Beta") } } + // Enable/disable the lock screen media widget + Defaults.Toggle(key: .enableLockScreenMediaWidget) { + HStack { + Text("Enable lock screen media widget") + } + } } header: { Text("Media controls") } diff --git a/DynamicIsland/managers/LockScreenManager.swift b/DynamicIsland/managers/LockScreenManager.swift new file mode 100644 index 0000000..9e63fb6 --- /dev/null +++ b/DynamicIsland/managers/LockScreenManager.swift @@ -0,0 +1,153 @@ +// +// LockScreenManager.swift +// DynamicIsland +// +// Created for lock screen detection feature +// Monitors system lock/unlock events and provides real-time status updates +// + +import Foundation +import Combine +import AppKit +import Defaults +import SwiftUI + +@MainActor +class LockScreenManager: ObservableObject { + static let shared = LockScreenManager() + + // MARK: - Coordinator + private let coordinator = DynamicIslandViewCoordinator.shared + + // MARK: - Published Properties + @Published var isLocked: Bool = false + @Published var isLockIdle: Bool = true + @Published var lastUpdated: Date = .distantPast + + // MARK: - Private Properties + private var debounceIdleTask: Task? + + // 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() + } + + // 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() { + print("[\(timestamp())] LockScreenManager: 🔒 Screen LOCKED event received") + + // Update state SYNCHRONOUSLY without Task/await to avoid any delay + lastUpdated = Date() + updateIdleState(locked: true) + + // Set locked state immediately without animation wrapper + isLocked = true + + // Show panel FIRST (creates and shows window on lock screen) + print("[\(timestamp())] LockScreenManager: 🎵 Showing lock screen panel") + LockScreenPanelManager.shared.showPanel() + + // THEN trigger lock icon in Dynamic Island (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() { + print("[\(timestamp())] LockScreenManager: 🔓 Screen UNLOCKED event received") + + // Hide panel window immediately and synchronously + print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window") + LockScreenPanelManager.shared.hidePanel() + + // Update state immediately + if Defaults[.enableLockScreenLiveActivity] { + self.coordinator.toggleExpandingView(status: false, type: .lockScreen) + } + + self.lastUpdated = Date() + self.updateIdleState(locked: false) + self.isLocked = false + + 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 + try? await Task.sleep(for: .seconds(Defaults[.waitInterval])) + guard let self = self, !Task.isCancelled else { return } + await MainActor.run { + if self.lastUpdated.timeIntervalSinceNow < -Defaults[.waitInterval] { + withAnimation { + self.isLockIdle = !self.isLocked + } + } + } + } + } + } +} + +// MARK: - Extensions + +extension LockScreenManager { + /// 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 + } +} diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift new file mode 100644 index 0000000..7e89df8 --- /dev/null +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -0,0 +1,103 @@ +// +// LockScreenPanelManager.swift +// DynamicIsland +// +// Manages the lock screen music panel window. +// + +import SwiftUI +import AppKit +import SkyLightWindow +import Defaults + +@MainActor +class LockScreenPanelManager { + static let shared = LockScreenPanelManager() + + private var panelWindow: NSWindow? + private var hasDelegated = false + + private init() { + print("[\(timestamp())] LockScreenPanelManager: initialized") + } + + private func timestamp() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + return formatter.string(from: Date()) + } + + func showPanel() { + print("[\(timestamp())] LockScreenPanelManager: showPanel") + + guard Defaults[.enableLockScreenMediaWidget] else { + print("[\(timestamp())] LockScreenPanelManager: widget disabled") + hidePanel() + return + } + + guard let screen = NSScreen.main else { + print("[\(timestamp())] LockScreenPanelManager: no main screen available") + return + } + + let panelWidth: CGFloat = 420 + let panelHeight: CGFloat = 150 + let screenFrame = screen.frame + let originX = screenFrame.origin.x + (screenFrame.width / 2) - (panelWidth / 2) + let originY = screenFrame.origin.y + (screenFrame.height / 2) - panelHeight - 40 + let targetFrame = NSRect(x: originX, y: originY, width: panelWidth, height: panelHeight) + + let window: NSWindow + + if let existingWindow = panelWindow { + window = existingWindow + } else { + let newWindow = NSWindow( + contentRect: targetFrame, + 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 + + panelWindow = newWindow + window = newWindow + hasDelegated = false + } + + window.setFrame(targetFrame, display: true) + window.contentView = NSHostingView(rootView: LockScreenMusicPanel()) + + if !hasDelegated { + SkyLightOperator.shared.delegateWindow(window) + hasDelegated = true + } + + // Keep the window alive and simply order it out on unlock to avoid SkyLight crashes. + window.orderFrontRegardless() + + print("[\(timestamp())] LockScreenPanelManager: panel visible") + } + + func hidePanel() { + print("[\(timestamp())] LockScreenPanelManager: hidePanel") + + guard let window = panelWindow else { + print("LockScreenPanelManager: no panel to hide") + return + } + + window.orderOut(nil) + window.contentView = nil + + print("[\(timestamp())] LockScreenPanelManager: panel hidden") + } +} diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 4d5c6ff..fa40afe 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -334,6 +334,8 @@ extension Defaults.Keys { static let enableFullscreenMediaDetection = Key("enableFullscreenMediaDetection", default: true) static let waitInterval = Key("waitInterval", default: 3) static let showShuffleAndRepeat = Key("showShuffleAndRepeat", default: false) + // Enable lock screen media widget (shows the standalone panel when screen is locked) + static let enableLockScreenMediaWidget = Key("enableLockScreenMediaWidget", default: true) // MARK: Battery static let showPowerStatusNotifications = Key("showPowerStatusNotifications", default: true) @@ -436,6 +438,9 @@ extension Defaults.Keys { static let enableCameraDetection = Key("enableCameraDetection", default: true) static let enableMicrophoneDetection = Key("enableMicrophoneDetection", default: true) + // MARK: Lock Screen Features + static let enableLockScreenLiveActivity = Key("enableLockScreenLiveActivity", default: true) + // MARK: ImageService static let didClearLegacyURLCacheV1 = Key("didClearLegacyURLCacheV1", default: false)