From 44b18513f2251961e5bf0d327ab58049b6665b04 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 17 Oct 2025 17:26:46 +0530 Subject: [PATCH 01/11] Working lock screen widget --- .gitignore | 7 +- DynamicIsland.xcodeproj/project.pbxproj | 17 ++ .../xcshareddata/swiftpm/Package.resolved | 11 +- DynamicIsland/ContentView.swift | 3 + DynamicIsland/DynamicIslandApp.swift | 5 +- .../DynamicIslandViewCoordinator.swift | 1 + DynamicIsland/Localizable.xcstrings | 13 + .../LockScreen/LockScreenLiveActivity.swift | 78 ++++++ .../LockScreen/LockScreenMusicPanel.swift | 257 ++++++++++++++++++ .../components/Settings/SettingsView.swift | 55 ++++ .../managers/LockScreenManager.swift | 153 +++++++++++ .../managers/LockScreenPanelManager.swift | 103 +++++++ DynamicIsland/models/Constants.swift | 5 + 13 files changed, 705 insertions(+), 3 deletions(-) create mode 100644 DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift create mode 100644 DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift create mode 100644 DynamicIsland/managers/LockScreenManager.swift create mode 100644 DynamicIsland/managers/LockScreenPanelManager.swift 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) From ce8cce0252de36a63330dc85fc07affc04653304 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 17 Oct 2025 18:13:36 +0530 Subject: [PATCH 02/11] Working Lock Screen HUD --- .../LockScreen/LockScreenLiveActivity.swift | 51 +++++-- .../LockScreenLiveActivityOverlay.swift | 95 +++++++++++++ .../LockScreenLiveActivityWindowManager.swift | 127 ++++++++++++++++++ .../managers/LockScreenManager.swift | 2 + 4 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift create mode 100644 DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift index f998c97..41a3735 100644 --- a/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift @@ -7,15 +7,26 @@ 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 + @ObservedObject private var lockScreenManager = LockScreenManager.shared @State private var isHovering: Bool = false @State private var gestureProgress: CGFloat = 0 @State private var isExpanded: Bool = false + @Namespace private var lockIconSpace + + private var iconName: String { + lockScreenManager.isLocked ? "lock.fill" : "lock.open.fill" + } + + private var iconColor: Color { + lockScreenManager.isLocked ? .blue : .green + } + + private var glowColor: Color { + lockScreenManager.isLocked ? Color.blue.opacity(0.15) : Color.green.opacity(0.2) + } var body: some View { HStack(spacing: 0) { @@ -26,12 +37,13 @@ struct LockScreenLiveActivity: View { HStack { ZStack { RoundedRectangle(cornerRadius: 6) - .fill(Color.blue.opacity(0.15)) + .fill(glowColor) - Image(systemName: "lock.fill") - .font(.system(size: 12)) - .foregroundColor(.blue) - .modifier(LockPulsingModifier()) + Image(systemName: iconName) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(iconColor) + .modifier(LockPulsingModifier(isUnlocking: !lockScreenManager.isLocked)) + .matchedGeometryEffect(id: "lock-icon", in: lockIconSpace) } .frame(width: vm.effectiveClosedNotchHeight - 12, height: vm.effectiveClosedNotchHeight - 12) } @@ -52,27 +64,46 @@ struct LockScreenLiveActivity: View { .frame(height: vm.effectiveClosedNotchHeight + (isHovering ? 8 : 0)) .onAppear { // Expand immediately without animation to avoid conflicts - isExpanded = true + withAnimation(.smooth(duration: 0.4)) { + isExpanded = true + } } .onDisappear { // Collapse immediately when removed from hierarchy isExpanded = false } + .onChange(of: lockScreenManager.isLockIdle) { _, newValue in + if newValue { + withAnimation(.smooth(duration: 0.4)) { + isExpanded = false + } + } else { + withAnimation(.smooth(duration: 0.4)) { + isExpanded = true + } + } + } + .animation(.spring(response: 0.5, dampingFraction: 0.85), value: lockScreenManager.isLocked) + .animation(.easeOut(duration: 0.25), value: isExpanded) } } // Pulsing animation modifier for lock indicator struct LockPulsingModifier: ViewModifier { + var isUnlocking: Bool @State private var isPulsing = false func body(content: Content) -> some View { content .scaleEffect(isPulsing ? 1.1 : 1.0) - .opacity(isPulsing ? 0.8 : 1.0) + .opacity(isPulsing ? (isUnlocking ? 1.0 : 0.8) : 1.0) .onAppear { withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) { isPulsing = true } } + .onDisappear { + isPulsing = false + } } } diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift new file mode 100644 index 0000000..3acdb3a --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift @@ -0,0 +1,95 @@ +// +// LockScreenLiveActivityOverlay.swift +// DynamicIsland +// +// Mirrors the closed-notch live activity layout on the macOS lock screen. +// + +import SwiftUI + +enum LockLiveActivityState: Equatable { + case locked + case unlocking +} + +struct LockScreenLiveActivityOverlay: View { + let state: LockLiveActivityState + let notchSize: CGSize + + private var indicatorSize: CGFloat { + max(0, notchSize.height - 12) + } + + private var horizontalPadding: CGFloat { + cornerRadiusInsets.closed.bottom + } + + private var baseWidth: CGFloat { + notchSize.width + (indicatorSize * 2) + } + + private var totalWidth: CGFloat { + baseWidth + (horizontalPadding * 2) + } + + private var iconName: String { + state == .locked ? "lock.fill" : "lock.open.fill" + } + + private var iconColor: Color { + state == .locked ? .blue : .green + } + + private var glowColor: Color { + state == .locked ? Color.blue.opacity(0.15) : Color.green.opacity(0.2) + } + + var body: some View { + HStack(spacing: 0) { + Color.clear + .background { + HStack { + ZStack { + RoundedRectangle(cornerRadius: 6) + .fill(glowColor) + + Image(systemName: iconName) + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(iconColor) + .modifier(LockPulsingModifier(isUnlocking: state == .unlocking)) + } + .frame(width: indicatorSize, height: indicatorSize) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + } + .frame(width: indicatorSize, height: notchSize.height) + + Color.clear + .frame(width: notchSize.width, height: notchSize.height) + + Color.clear + .frame(width: indicatorSize, height: notchSize.height) + } + .frame(width: baseWidth, height: notchSize.height) + .padding(.horizontal, horizontalPadding) + .background(Color.black) + .clipShape( + NotchShape( + topCornerRadius: cornerRadiusInsets.closed.top, + bottomCornerRadius: cornerRadiusInsets.closed.bottom + ) + ) + .frame(width: totalWidth, height: notchSize.height) + .animation(.spring(response: 0.45, dampingFraction: 0.85), value: state) + .drawingGroup() + } +} + +#Preview { + VStack(spacing: 24) { + LockScreenLiveActivityOverlay(state: .locked, notchSize: CGSize(width: 220, height: 32)) + LockScreenLiveActivityOverlay(state: .unlocking, notchSize: CGSize(width: 220, height: 32)) + } + .padding() + .background(Color.gray.opacity(0.2)) +} diff --git a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift new file mode 100644 index 0000000..e4aaa35 --- /dev/null +++ b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift @@ -0,0 +1,127 @@ +// +// LockScreenLiveActivityWindowManager.swift +// DynamicIsland +// +// Delegates the lock screen live activity layout to SkyLight so it remains visible when the display is locked. +// + +import AppKit +import Defaults +import SkyLightWindow +import SwiftUI + +@MainActor +class LockScreenLiveActivityWindowManager { + static let shared = LockScreenLiveActivityWindowManager() + + private var window: NSWindow? + private var hasDelegated = false + private var hideTask: Task? + + private init() {} + + private func timestamp() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + return formatter.string(from: Date()) + } + + private func frame(for notchSize: CGSize, on screen: NSScreen) -> NSRect { + let indicatorSize = max(0, notchSize.height - 12) + let horizontalPadding = cornerRadiusInsets.closed.bottom + let totalWidth = notchSize.width + (indicatorSize * 2) + (horizontalPadding * 2) + + let screenFrame = screen.frame + let originX = screenFrame.origin.x + (screenFrame.width / 2) - (totalWidth / 2) + let originY = screenFrame.origin.y + screenFrame.height - notchSize.height + + return NSRect(x: originX, y: originY, width: totalWidth, height: notchSize.height) + } + + private func ensureWindow(notchSize: CGSize, screen: NSScreen) -> NSWindow { + if let window { + return window + } + + let window = NSWindow( + contentRect: frame(for: notchSize, on: screen), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + + window.isOpaque = false + window.backgroundColor = .clear + window.isReleasedWhenClosed = false + window.ignoresMouseEvents = true + window.hasShadow = false + window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) + window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] + window.alphaValue = 0 + + self.window = window + self.hasDelegated = false + return window + } + + private func present(state: LockLiveActivityState) { + guard Defaults[.enableLockScreenLiveActivity] else { + hideImmediately() + return + } + + guard let screen = NSScreen.main else { + print("[\(timestamp())] LockScreenLiveActivityWindowManager: no main screen available") + return + } + + let notchSize = getClosedNotchSize(screen: screen.localizedName) + let window = ensureWindow(notchSize: notchSize, screen: screen) + let targetFrame = frame(for: notchSize, on: screen) + window.setFrame(targetFrame, display: true) + + let overlay = LockScreenLiveActivityOverlay(state: state, notchSize: notchSize) + window.contentView = NSHostingView(rootView: overlay) + + if !hasDelegated { + SkyLightOperator.shared.delegateWindow(window) + hasDelegated = true + } + + window.alphaValue = 1 + window.orderFrontRegardless() + } + + func showLocked() { + hideTask?.cancel() + present(state: .locked) + print("[\(timestamp())] LockScreenLiveActivityWindowManager: showing locked state") + } + + func showUnlockAndScheduleHide() { + hideTask?.cancel() + present(state: .unlocking) + print("[\(timestamp())] LockScreenLiveActivityWindowManager: showing unlock state") + + hideTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(max(Defaults[.waitInterval], 1.0))) + guard let self, !Task.isCancelled else { return } + await MainActor.run { + self.hideImmediately() + } + } + } + + func hideImmediately() { + hideTask?.cancel() + hideTask = nil + + guard let window else { return } + + window.orderOut(nil) + window.alphaValue = 0 + window.contentView = nil + + print("[\(timestamp())] LockScreenLiveActivityWindowManager: HUD hidden") + } +} diff --git a/DynamicIsland/managers/LockScreenManager.swift b/DynamicIsland/managers/LockScreenManager.swift index 9e63fb6..8e7bef1 100644 --- a/DynamicIsland/managers/LockScreenManager.swift +++ b/DynamicIsland/managers/LockScreenManager.swift @@ -83,6 +83,7 @@ class LockScreenManager: ObservableObject { // Show panel FIRST (creates and shows window on lock screen) print("[\(timestamp())] LockScreenManager: 🎵 Showing lock screen panel") LockScreenPanelManager.shared.showPanel() + LockScreenLiveActivityWindowManager.shared.showLocked() // THEN trigger lock icon in Dynamic Island (only if enabled in settings) if Defaults[.enableLockScreenLiveActivity] { @@ -101,6 +102,7 @@ class LockScreenManager: ObservableObject { // Hide panel window immediately and synchronously print("[\(timestamp())] LockScreenManager: 🚪 Hiding panel window") LockScreenPanelManager.shared.hidePanel() + LockScreenLiveActivityWindowManager.shared.showUnlockAndScheduleHide() // Update state immediately if Defaults[.enableLockScreenLiveActivity] { From 4132b13bdc06bfffb5dc4b2256e627f6becf65a2 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 17 Oct 2025 18:24:35 +0530 Subject: [PATCH 03/11] Working with Pulsating --- .../LockScreenLiveActivityOverlay.swift | 8 ++++---- .../LockScreenLiveActivityWindowManager.swift | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift index 3acdb3a..ace5e30 100644 --- a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift @@ -45,7 +45,7 @@ struct LockScreenLiveActivityOverlay: View { } var body: some View { - HStack(spacing: 0) { + HStack(alignment: .top, spacing: 0) { Color.clear .background { HStack { @@ -62,13 +62,13 @@ struct LockScreenLiveActivityOverlay: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) } - .frame(width: indicatorSize, height: notchSize.height) + .frame(width: indicatorSize, height: notchSize.height, alignment: .top) Color.clear - .frame(width: notchSize.width, height: notchSize.height) + .frame(width: notchSize.width, height: notchSize.height, alignment: .top) Color.clear - .frame(width: indicatorSize, height: notchSize.height) + .frame(width: indicatorSize, height: notchSize.height, alignment: .top) } .frame(width: baseWidth, height: notchSize.height) .padding(.horizontal, horizontalPadding) diff --git a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift index e4aaa35..b1fcbbb 100644 --- a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift +++ b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift @@ -58,6 +58,7 @@ class LockScreenLiveActivityWindowManager { window.level = NSWindow.Level(rawValue: Int(CGShieldingWindowLevel())) window.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary] window.alphaValue = 0 + window.animationBehavior = .none self.window = window self.hasDelegated = false @@ -82,14 +83,25 @@ class LockScreenLiveActivityWindowManager { let overlay = LockScreenLiveActivityOverlay(state: state, notchSize: notchSize) window.contentView = NSHostingView(rootView: overlay) + window.displayIfNeeded() if !hasDelegated { SkyLightOperator.shared.delegateWindow(window) hasDelegated = true } - window.alphaValue = 1 - window.orderFrontRegardless() + if !window.isVisible { + window.alphaValue = 0 + window.orderFrontRegardless() + + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.12 + window.animator().alphaValue = 1 + } + } else { + window.orderFrontRegardless() + window.alphaValue = 1 + } } func showLocked() { From 1ac788c5de18322b260959aa64f55ed242035fc0 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 17 Oct 2025 18:32:34 +0530 Subject: [PATCH 04/11] Removed Pulsating --- .../LockScreen/LockScreenLiveActivity.swift | 45 +++---------------- .../LockScreenLiveActivityOverlay.swift | 25 +++-------- .../LockScreenLiveActivityWindowManager.swift | 32 ++++++++----- 3 files changed, 33 insertions(+), 69 deletions(-) diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift index 41a3735..32ba769 100644 --- a/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivity.swift @@ -21,33 +21,20 @@ struct LockScreenLiveActivity: View { } private var iconColor: Color { - lockScreenManager.isLocked ? .blue : .green - } - - private var glowColor: Color { - lockScreenManager.isLocked ? Color.blue.opacity(0.15) : Color.green.opacity(0.2) + .white } var body: some View { HStack(spacing: 0) { // Left - Lock icon with subtle glow Color.clear - .background { + .overlay(alignment: .leading) { if isExpanded { - HStack { - ZStack { - RoundedRectangle(cornerRadius: 6) - .fill(glowColor) - - Image(systemName: iconName) - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(iconColor) - .modifier(LockPulsingModifier(isUnlocking: !lockScreenManager.isLocked)) - .matchedGeometryEffect(id: "lock-icon", in: lockIconSpace) - } + Image(systemName: iconName) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(iconColor) + .matchedGeometryEffect(id: "lock-icon", in: lockIconSpace) .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)) @@ -87,23 +74,3 @@ struct LockScreenLiveActivity: View { .animation(.easeOut(duration: 0.25), value: isExpanded) } } - -// Pulsing animation modifier for lock indicator -struct LockPulsingModifier: ViewModifier { - var isUnlocking: Bool - @State private var isPulsing = false - - func body(content: Content) -> some View { - content - .scaleEffect(isPulsing ? 1.1 : 1.0) - .opacity(isPulsing ? (isUnlocking ? 1.0 : 0.8) : 1.0) - .onAppear { - withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) { - isPulsing = true - } - } - .onDisappear { - isPulsing = false - } - } -} diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift index ace5e30..d28fe33 100644 --- a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift @@ -36,31 +36,16 @@ struct LockScreenLiveActivityOverlay: View { state == .locked ? "lock.fill" : "lock.open.fill" } - private var iconColor: Color { - state == .locked ? .blue : .green - } - - private var glowColor: Color { - state == .locked ? Color.blue.opacity(0.15) : Color.green.opacity(0.2) - } + private var iconColor: Color { .white } var body: some View { HStack(alignment: .top, spacing: 0) { Color.clear - .background { - HStack { - ZStack { - RoundedRectangle(cornerRadius: 6) - .fill(glowColor) - - Image(systemName: iconName) - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(iconColor) - .modifier(LockPulsingModifier(isUnlocking: state == .unlocking)) - } + .overlay(alignment: .leading) { + Image(systemName: iconName) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(iconColor) .frame(width: indicatorSize, height: indicatorSize) - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) } .frame(width: indicatorSize, height: notchSize.height, alignment: .top) diff --git a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift index b1fcbbb..6c14f76 100644 --- a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift +++ b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift @@ -17,6 +17,7 @@ class LockScreenLiveActivityWindowManager { private var window: NSWindow? private var hasDelegated = false private var hideTask: Task? + private var hostingView: NSHostingView? private init() {} @@ -82,7 +83,21 @@ class LockScreenLiveActivityWindowManager { window.setFrame(targetFrame, display: true) let overlay = LockScreenLiveActivityOverlay(state: state, notchSize: notchSize) - window.contentView = NSHostingView(rootView: overlay) + + if let hostingView { + hostingView.rootView = overlay + hostingView.frame = CGRect(origin: .zero, size: targetFrame.size) + } else { + let view = NSHostingView(rootView: overlay) + view.frame = CGRect(origin: .zero, size: targetFrame.size) + hostingView = view + window.contentView = view + } + + if window.contentView !== hostingView { + window.contentView = hostingView + } + window.displayIfNeeded() if !hasDelegated { @@ -90,17 +105,13 @@ class LockScreenLiveActivityWindowManager { hasDelegated = true } - if !window.isVisible { - window.alphaValue = 0 - window.orderFrontRegardless() + window.orderFrontRegardless() + if window.alphaValue < 1 { NSAnimationContext.runAnimationGroup { context in context.duration = 0.12 window.animator().alphaValue = 1 } - } else { - window.orderFrontRegardless() - window.alphaValue = 1 } } @@ -130,9 +141,10 @@ class LockScreenLiveActivityWindowManager { guard let window else { return } - window.orderOut(nil) - window.alphaValue = 0 - window.contentView = nil + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.1 + window.animator().alphaValue = 0 + } print("[\(timestamp())] LockScreenLiveActivityWindowManager: HUD hidden") } From 05d41f6bb75b5eb524bd6af83564e68adb6770c2 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 17 Oct 2025 18:48:22 +0530 Subject: [PATCH 05/11] Working lock liveactivity --- DynamicIsland/DynamicIslandApp.swift | 40 +++++++++++++++++-- .../LockScreenLiveActivityOverlay.swift | 15 +++++-- .../LockScreenLiveActivityWindowManager.swift | 16 +++++++- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index ec917b0..4c490fd 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -91,6 +91,7 @@ class AppDelegate: NSObject, NSApplicationDelegate { private var previousScreens: [NSScreen]? private var onboardingWindowController: NSWindowController? private var cancellables = Set() + private var windowsHiddenForLock = false // Media key monitoring private var globalEventMonitor: Any? @@ -209,14 +210,45 @@ class AppDelegate: NSObject, NSApplicationDelegate { @objc func onScreenLocked(_: Notification) { print("Screen locked") - cleanupWindows() + hideWindowsForLock() } - + @objc func onScreenUnlocked(_: Notification) { print("Screen unlocked") DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.cleanupWindows() - self?.adjustWindowPosition(changeAlpha: true) + guard let self = self else { return } + self.restoreWindowsAfterLock() + self.adjustWindowPosition(changeAlpha: true) + } + } + + private func hideWindowsForLock() { + guard !windowsHiddenForLock else { return } + windowsHiddenForLock = true + + if Defaults[.showOnAllDisplays] { + for window in windows.values { + window.alphaValue = 0 + window.orderOut(nil) + } + } else if let window = window { + window.alphaValue = 0 + window.orderOut(nil) + } + } + + private func restoreWindowsAfterLock() { + guard windowsHiddenForLock else { return } + windowsHiddenForLock = false + + if Defaults[.showOnAllDisplays] { + for window in windows.values { + window.orderFrontRegardless() + window.alphaValue = 1 + } + } else if let window = window { + window.orderFrontRegardless() + window.alphaValue = 1 } } diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift index d28fe33..9a3699e 100644 --- a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift @@ -12,9 +12,14 @@ enum LockLiveActivityState: Equatable { case unlocking } +final class LockScreenLiveActivityOverlayViewModel: ObservableObject { + @Published var expansion: CGFloat = 1 +} + struct LockScreenLiveActivityOverlay: View { let state: LockLiveActivityState let notchSize: CGSize + @ObservedObject var viewModel: LockScreenLiveActivityOverlayViewModel private var indicatorSize: CGFloat { max(0, notchSize.height - 12) @@ -65,15 +70,19 @@ struct LockScreenLiveActivityOverlay: View { ) ) .frame(width: totalWidth, height: notchSize.height) + .scaleEffect(x: max(0.05, viewModel.expansion), y: 1, anchor: .center) + .opacity(min(1, max(0.2, viewModel.expansion * 5))) .animation(.spring(response: 0.45, dampingFraction: 0.85), value: state) + .animation(.spring(response: 0.5, dampingFraction: 0.85), value: viewModel.expansion) .drawingGroup() } } #Preview { - VStack(spacing: 24) { - LockScreenLiveActivityOverlay(state: .locked, notchSize: CGSize(width: 220, height: 32)) - LockScreenLiveActivityOverlay(state: .unlocking, notchSize: CGSize(width: 220, height: 32)) + let model = LockScreenLiveActivityOverlayViewModel() + return VStack(spacing: 24) { + LockScreenLiveActivityOverlay(state: .locked, notchSize: CGSize(width: 220, height: 32), viewModel: model) + LockScreenLiveActivityOverlay(state: .unlocking, notchSize: CGSize(width: 220, height: 32), viewModel: model) } .padding() .background(Color.gray.opacity(0.2)) diff --git a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift index 6c14f76..7ddd400 100644 --- a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift +++ b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift @@ -18,6 +18,7 @@ class LockScreenLiveActivityWindowManager { private var hasDelegated = false private var hideTask: Task? private var hostingView: NSHostingView? + private let overlayModel = LockScreenLiveActivityOverlayViewModel() private init() {} @@ -82,7 +83,7 @@ class LockScreenLiveActivityWindowManager { let targetFrame = frame(for: notchSize, on: screen) window.setFrame(targetFrame, display: true) - let overlay = LockScreenLiveActivityOverlay(state: state, notchSize: notchSize) + let overlay = LockScreenLiveActivityOverlay(state: state, notchSize: notchSize, viewModel: overlayModel) if let hostingView { hostingView.rootView = overlay @@ -107,6 +108,15 @@ class LockScreenLiveActivityWindowManager { window.orderFrontRegardless() + if state == .locked { + overlayModel.expansion = 0.2 + withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) { + overlayModel.expansion = 1 + } + } else { + overlayModel.expansion = 1 + } + if window.alphaValue < 1 { NSAnimationContext.runAnimationGroup { context in context.duration = 0.12 @@ -141,6 +151,10 @@ class LockScreenLiveActivityWindowManager { guard let window else { return } + withAnimation(.easeOut(duration: 0.1)) { + overlayModel.expansion = 0.2 + } + NSAnimationContext.runAnimationGroup { context in context.duration = 0.1 window.animator().alphaValue = 0 From d0448ec2cb3a8338948401cd7fd73ba5416f643b Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 17 Oct 2025 19:59:40 +0530 Subject: [PATCH 06/11] Make some changes: fixes and stuff --- DynamicIsland/ContentView.swift | 18 ++- DynamicIsland/DynamicIslandApp.swift | 2 + .../LockScreenDynamicIslandReplica.swift | 1 + .../LockScreenLiveActivityOverlay.swift | 122 ++++++---------- .../LockScreen/LockScreenMusicPanel.swift | 38 +++-- .../LockScreenLiveActivityWindowManager.swift | 138 ++++++++++++------ .../managers/LockScreenManager.swift | 16 ++ .../managers/LockScreenPanelManager.swift | 6 +- .../models/DynamicIslandViewModel.swift | 9 ++ .../utils/Environment+LockScreenReplica.swift | 1 + 10 files changed, 205 insertions(+), 146 deletions(-) create mode 100644 DynamicIsland/components/LockScreen/LockScreenDynamicIslandReplica.swift create mode 100644 DynamicIsland/utils/Environment+LockScreenReplica.swift diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index 867f899..c459e14 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -34,7 +34,7 @@ struct ContentView: View { @Default(.showNetworkGraph) var showNetworkGraph @Default(.showDiskGraph) var showDiskGraph - // Dynamic sizing based on view type and graph count with smooth transitions + // Dynamic sizing based on view type and graph count with smooth transitions var dynamicNotchSize: CGSize { // Use minimalistic or normal size based on settings let baseSize = Defaults[.enableMinimalisticUI] ? minimalisticOpenNotchSize : openNotchSize @@ -107,6 +107,8 @@ struct ContentView: View { } var body: some View { + let interactionsEnabled = !lockScreenManager.isLocked + ZStack(alignment: .top) { let mainLayout = NotchLayout() .frame(alignment: .top) @@ -149,12 +151,12 @@ struct ContentView: View { .animation(notchStateAnimation, value: vm.notchState) .animation(viewTransitionAnimation, value: coordinator.currentView) } - .conditionalModifier(Defaults[.openNotchOnHover]) { view in + .conditionalModifier(Defaults[.openNotchOnHover] && interactionsEnabled) { view in view.onHover { hovering in handleHover(hovering) } } - .conditionalModifier(!Defaults[.openNotchOnHover]) { view in + .conditionalModifier(!Defaults[.openNotchOnHover] && interactionsEnabled) { view in view .onHover { hovering in handleSimpleHover(hovering) @@ -165,14 +167,14 @@ struct ContentView: View { } doOpen() } - .conditionalModifier(Defaults[.enableGestures]) { view in + .conditionalModifier(Defaults[.enableGestures] && interactionsEnabled) { view in view .panGesture(direction: .down) { translation, phase in handleDownGesture(translation: translation, phase: phase) } } } - .conditionalModifier(Defaults[.closeGestureEnabled] && Defaults[.enableGestures]) { view in + .conditionalModifier(Defaults[.closeGestureEnabled] && Defaults[.enableGestures] && interactionsEnabled) { view in view .panGesture(direction: .up) { translation, phase in handleUpGesture(translation: translation, phase: phase) @@ -417,7 +419,7 @@ struct ContentView: View { } else if coordinator.sneakPeek.show && Defaults[.inlineHUD] && (coordinator.sneakPeek.type != .music) && (coordinator.sneakPeek.type != .battery) { InlineHUD(type: $coordinator.sneakPeek.type, value: $coordinator.sneakPeek.value, icon: $coordinator.sneakPeek.icon, hoverAnimation: $isHovering, gestureProgress: $gestureProgress) .transition(.opacity) - } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed { + } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .music) && vm.notchState == .closed && (musicManager.isPlaying || !musicManager.isPlayerIdle) && coordinator.musicLiveActivityEnabled && !vm.hideOnClosed && !lockScreenManager.isLocked { MusicLiveActivity() } else if (!coordinator.expandingView.show || coordinator.expandingView.type == .timer) && vm.notchState == .closed && timerManager.isTimerActive && coordinator.timerLiveActivityEnabled && !vm.hideOnClosed { TimerLiveActivity() @@ -611,7 +613,9 @@ struct ContentView: View { @ViewBuilder var dragDetector: some View { - if Defaults[.dynamicShelf] && !Defaults[.enableMinimalisticUI] { + if lockScreenManager.isLocked { + EmptyView() + } else if Defaults[.dynamicShelf] && !Defaults[.enableMinimalisticUI] { Color.clear .frame(maxWidth: .infinity, maxHeight: .infinity) .contentShape(Rectangle()) diff --git a/DynamicIsland/DynamicIslandApp.swift b/DynamicIsland/DynamicIslandApp.swift index 4c490fd..418f97e 100644 --- a/DynamicIsland/DynamicIslandApp.swift +++ b/DynamicIsland/DynamicIslandApp.swift @@ -434,6 +434,8 @@ class AppDelegate: NSObject, NSApplicationDelegate { func applicationDidFinishLaunching(_ notification: Notification) { coordinator.setupWorkersNotificationObservers() + LockScreenLiveActivityWindowManager.shared.configure(viewModel: vm) + LockScreenManager.shared.configure(viewModel: vm) // Migrate legacy progress bar settings Defaults.Keys.migrateProgressBarStyle() diff --git a/DynamicIsland/components/LockScreen/LockScreenDynamicIslandReplica.swift b/DynamicIsland/components/LockScreen/LockScreenDynamicIslandReplica.swift new file mode 100644 index 0000000..bf766c3 --- /dev/null +++ b/DynamicIsland/components/LockScreen/LockScreenDynamicIslandReplica.swift @@ -0,0 +1 @@ +// Legacy placeholder file superseded by LockScreenLiveActivityOverlay.swift. diff --git a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift index 9a3699e..b690ce0 100644 --- a/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift +++ b/DynamicIsland/components/LockScreen/LockScreenLiveActivityOverlay.swift @@ -1,89 +1,57 @@ -// -// LockScreenLiveActivityOverlay.swift -// DynamicIsland -// -// Mirrors the closed-notch live activity layout on the macOS lock screen. -// - import SwiftUI -enum LockLiveActivityState: Equatable { - case locked - case unlocking -} - -final class LockScreenLiveActivityOverlayViewModel: ObservableObject { - @Published var expansion: CGFloat = 1 +final class LockScreenLiveActivityOverlayModel: ObservableObject { + @Published var iconName: String = "lock.fill" + @Published var scale: CGFloat = 0.6 + @Published var opacity: Double = 0 } struct LockScreenLiveActivityOverlay: View { - let state: LockLiveActivityState - let notchSize: CGSize - @ObservedObject var viewModel: LockScreenLiveActivityOverlayViewModel + @ObservedObject var model: LockScreenLiveActivityOverlayModel + let notchSize: CGSize - private var indicatorSize: CGFloat { - max(0, notchSize.height - 12) - } + private var indicatorSize: CGFloat { + max(0, notchSize.height - 12) + } - private var horizontalPadding: CGFloat { - cornerRadiusInsets.closed.bottom - } + private var horizontalPadding: CGFloat { + cornerRadiusInsets.closed.bottom + } - private var baseWidth: CGFloat { - notchSize.width + (indicatorSize * 2) - } + private var totalWidth: CGFloat { + notchSize.width + (indicatorSize * 2) + (horizontalPadding * 2) + } - private var totalWidth: CGFloat { - baseWidth + (horizontalPadding * 2) - } + var body: some View { + HStack(spacing: 0) { + Color.clear + .overlay(alignment: .leading) { + Image(systemName: model.iconName) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: indicatorSize, height: indicatorSize) + } + .frame(width: indicatorSize, height: notchSize.height) - private var iconName: String { - state == .locked ? "lock.fill" : "lock.open.fill" - } + Rectangle() + .fill(.black) + .frame(width: notchSize.width, height: notchSize.height) - private var iconColor: Color { .white } - - var body: some View { - HStack(alignment: .top, spacing: 0) { - Color.clear - .overlay(alignment: .leading) { - Image(systemName: iconName) - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(iconColor) - .frame(width: indicatorSize, height: indicatorSize) - } - .frame(width: indicatorSize, height: notchSize.height, alignment: .top) - - Color.clear - .frame(width: notchSize.width, height: notchSize.height, alignment: .top) - - Color.clear - .frame(width: indicatorSize, height: notchSize.height, alignment: .top) - } - .frame(width: baseWidth, height: notchSize.height) - .padding(.horizontal, horizontalPadding) - .background(Color.black) - .clipShape( - NotchShape( - topCornerRadius: cornerRadiusInsets.closed.top, - bottomCornerRadius: cornerRadiusInsets.closed.bottom - ) - ) - .frame(width: totalWidth, height: notchSize.height) - .scaleEffect(x: max(0.05, viewModel.expansion), y: 1, anchor: .center) - .opacity(min(1, max(0.2, viewModel.expansion * 5))) - .animation(.spring(response: 0.45, dampingFraction: 0.85), value: state) - .animation(.spring(response: 0.5, dampingFraction: 0.85), value: viewModel.expansion) - .drawingGroup() - } -} - -#Preview { - let model = LockScreenLiveActivityOverlayViewModel() - return VStack(spacing: 24) { - LockScreenLiveActivityOverlay(state: .locked, notchSize: CGSize(width: 220, height: 32), viewModel: model) - LockScreenLiveActivityOverlay(state: .unlocking, notchSize: CGSize(width: 220, height: 32), viewModel: model) - } - .padding() - .background(Color.gray.opacity(0.2)) + Color.clear + .frame(width: indicatorSize, height: notchSize.height) + } + .frame(width: notchSize.width + (indicatorSize * 2), height: notchSize.height) + .padding(.horizontal, horizontalPadding) + .background(Color.black) + .clipShape( + NotchShape( + topCornerRadius: cornerRadiusInsets.closed.top, + bottomCornerRadius: cornerRadiusInsets.closed.bottom + ) + ) + .frame(width: totalWidth, height: notchSize.height) + .scaleEffect(x: model.scale, y: 1, anchor: .center) + .opacity(model.opacity) + .drawingGroup() + } } diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 0e9bec5..75ace95 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -15,6 +15,10 @@ struct LockScreenMusicPanel: View { @State private var lastDragged: Date = .distantPast @State private var isActive = true + private let panelWidth: CGFloat = 460 + private let panelHeight: CGFloat = 180 + private let cornerRadius: CGFloat = 28 + var body: some View { if isActive { panelContent @@ -23,14 +27,14 @@ struct LockScreenMusicPanel: View { } } else { Color.clear - .frame(width: 420, height: 150) + .frame(width: panelWidth, height: panelHeight) } } private var panelContent: some View { - VStack(spacing: 0) { + VStack(spacing: 12) { // Header area with album art - HStack(alignment: .center, spacing: 10) { + HStack(alignment: .center, spacing: 16) { // Album art Button { musicManager.openMusicApp() @@ -38,8 +42,8 @@ struct LockScreenMusicPanel: View { Image(nsImage: musicManager.albumArt) .resizable() .aspectRatio(contentMode: .fill) - .frame(width: 50, height: 50) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 16)) } .buttonStyle(PlainButtonStyle()) .opacity(musicManager.isPlaying ? 1 : 0.4) @@ -71,30 +75,30 @@ struct LockScreenMusicPanel: View { .frame(width: 20, height: 16) } } - .frame(height: 50) + .frame(height: 60) // Progress bar progressBar - .padding(.top, 6) + .padding(.top, 4) // Playback controls playbackControls .padding(.top, 4) } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .frame(width: 420, height: 150) + .padding(.horizontal, 20) + .padding(.vertical, 16) + .frame(width: panelWidth, height: panelHeight) .background { if #available(macOS 26.0, *) { - RoundedRectangle(cornerRadius: 20) - .glassEffect(in: .rect(cornerRadius: 20)) + RoundedRectangle(cornerRadius: cornerRadius) + .glassEffect(in: .rect(cornerRadius: cornerRadius)) .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) + RoundedRectangle(cornerRadius: cornerRadius) .fill(.ultraThinMaterial) .onAppear { let formatter = DateFormatter() @@ -103,6 +107,10 @@ struct LockScreenMusicPanel: View { } } } + .overlay { + RoundedRectangle(cornerRadius: cornerRadius) + .stroke(Color.white.opacity(0.35), lineWidth: 1.4) + } .shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10) .onAppear { sliderValue = musicManager.elapsedTime @@ -233,7 +241,9 @@ struct LockScreenMusicPanel: View { Image(systemName: musicManager.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 24, weight: .semibold)) .foregroundColor(.white) - .frame(width: 50, height: 50) + .frame(width: 54, height: 54) + .background(Color.white.opacity(0.15)) + .clipShape(Circle()) } .buttonStyle(PlainButtonStyle()) } diff --git a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift index 7ddd400..2b8814e 100644 --- a/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift +++ b/DynamicIsland/managers/LockScreenLiveActivityWindowManager.swift @@ -18,7 +18,8 @@ class LockScreenLiveActivityWindowManager { private var hasDelegated = false private var hideTask: Task? private var hostingView: NSHostingView? - private let overlayModel = LockScreenLiveActivityOverlayViewModel() + private let overlayModel = LockScreenLiveActivityOverlayModel() + private weak var viewModel: DynamicIslandViewModel? private init() {} @@ -28,25 +29,30 @@ class LockScreenLiveActivityWindowManager { return formatter.string(from: Date()) } - private func frame(for notchSize: CGSize, on screen: NSScreen) -> NSRect { - let indicatorSize = max(0, notchSize.height - 12) + private func windowSize(for notchSize: CGSize) -> CGSize { + let indicatorWidth = max(0, notchSize.height - 12) let horizontalPadding = cornerRadiusInsets.closed.bottom - let totalWidth = notchSize.width + (indicatorSize * 2) + (horizontalPadding * 2) - let screenFrame = screen.frame - let originX = screenFrame.origin.x + (screenFrame.width / 2) - (totalWidth / 2) - let originY = screenFrame.origin.y + screenFrame.height - notchSize.height + let totalWidth = notchSize.width + (indicatorWidth * 2) + (horizontalPadding * 2) - return NSRect(x: originX, y: originY, width: totalWidth, height: notchSize.height) + return CGSize(width: totalWidth, height: notchSize.height) } - private func ensureWindow(notchSize: CGSize, screen: NSScreen) -> NSWindow { + private func frame(for windowSize: CGSize, on screen: NSScreen) -> NSRect { + let screenFrame = screen.frame + let originX = screenFrame.origin.x + (screenFrame.width / 2) - (windowSize.width / 2) + let originY = screenFrame.origin.y + screenFrame.height - windowSize.height + + return NSRect(x: originX, y: originY, width: windowSize.width, height: windowSize.height) + } + + private func ensureWindow(windowSize: CGSize, screen: NSScreen) -> NSWindow { if let window { return window } let window = NSWindow( - contentRect: frame(for: notchSize, on: screen), + contentRect: frame(for: windowSize, on: screen), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false @@ -67,29 +73,43 @@ class LockScreenLiveActivityWindowManager { return window } - private func present(state: LockLiveActivityState) { + private func lockContext() -> (notchSize: CGSize, screen: NSScreen)? { + guard let screen = NSScreen.main else { + print("[\(timestamp())] LockScreenLiveActivityWindowManager: no main screen available") + return nil + } + + guard let viewModel else { + print("[\(timestamp())] LockScreenLiveActivityWindowManager: no view model configured") + return nil + } + + var notchSize = viewModel.closedNotchSize + if notchSize.width <= 0 || notchSize.height <= 0 { + notchSize = getClosedNotchSize(screen: screen.localizedName) + } + + return (notchSize, screen) + } + + private func present(notchSize: CGSize, on screen: NSScreen) { guard Defaults[.enableLockScreenLiveActivity] else { hideImmediately() return } - guard let screen = NSScreen.main else { - print("[\(timestamp())] LockScreenLiveActivityWindowManager: no main screen available") - return - } - - let notchSize = getClosedNotchSize(screen: screen.localizedName) - let window = ensureWindow(notchSize: notchSize, screen: screen) - let targetFrame = frame(for: notchSize, on: screen) + let windowSize = windowSize(for: notchSize) + let window = ensureWindow(windowSize: windowSize, screen: screen) + let targetFrame = frame(for: windowSize, on: screen) window.setFrame(targetFrame, display: true) - let overlay = LockScreenLiveActivityOverlay(state: state, notchSize: notchSize, viewModel: overlayModel) + let overlayView = LockScreenLiveActivityOverlay(model: overlayModel, notchSize: notchSize) if let hostingView { - hostingView.rootView = overlay + hostingView.rootView = overlayView hostingView.frame = CGRect(origin: .zero, size: targetFrame.size) } else { - let view = NSHostingView(rootView: overlay) + let view = NSHostingView(rootView: overlayView) view.frame = CGRect(origin: .zero, size: targetFrame.size) hostingView = view window.contentView = view @@ -107,40 +127,55 @@ class LockScreenLiveActivityWindowManager { } window.orderFrontRegardless() - - if state == .locked { - overlayModel.expansion = 0.2 - withAnimation(.spring(response: 0.55, dampingFraction: 0.82)) { - overlayModel.expansion = 1 - } - } else { - overlayModel.expansion = 1 - } - - if window.alphaValue < 1 { - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.12 - window.animator().alphaValue = 1 - } - } + window.alphaValue = 1 } func showLocked() { hideTask?.cancel() - present(state: .locked) + guard let context = lockContext() else { return } + + overlayModel.iconName = "lock.fill" + overlayModel.scale = 0.6 + overlayModel.opacity = 0 + + present(notchSize: context.notchSize, on: context.screen) + + DispatchQueue.main.async { + withAnimation(.spring(response: 0.45, dampingFraction: 0.82)) { + self.overlayModel.scale = 1 + } + withAnimation(.easeOut(duration: 0.18)) { + self.overlayModel.opacity = 1 + } + } + print("[\(timestamp())] LockScreenLiveActivityWindowManager: showing locked state") } func showUnlockAndScheduleHide() { hideTask?.cancel() - present(state: .unlocking) - print("[\(timestamp())] LockScreenLiveActivityWindowManager: showing unlock state") + guard let context = lockContext() else { return } + + overlayModel.iconName = "lock.open.fill" + overlayModel.scale = 0.7 + overlayModel.opacity = 0 + + present(notchSize: context.notchSize, on: context.screen) + + DispatchQueue.main.async { + withAnimation(.spring(response: 0.45, dampingFraction: 0.85)) { + self.overlayModel.scale = 1 + } + withAnimation(.easeOut(duration: 0.16)) { + self.overlayModel.opacity = 1 + } + } hideTask = Task { [weak self] in try? await Task.sleep(for: .seconds(max(Defaults[.waitInterval], 1.0))) guard let self, !Task.isCancelled else { return } await MainActor.run { - self.hideImmediately() + self.hideWithAnimation() } } } @@ -149,17 +184,30 @@ class LockScreenLiveActivityWindowManager { hideTask?.cancel() hideTask = nil + hideWithAnimation() + } + + private func hideWithAnimation() { guard let window else { return } - withAnimation(.easeOut(duration: 0.1)) { - overlayModel.expansion = 0.2 + withAnimation(.easeInOut(duration: 0.18)) { + overlayModel.opacity = 0 + overlayModel.scale = 0.7 } NSAnimationContext.runAnimationGroup { context in - context.duration = 0.1 + context.duration = 0.2 window.animator().alphaValue = 0 } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.22) { + window.orderOut(nil) + } + print("[\(timestamp())] LockScreenLiveActivityWindowManager: HUD hidden") } + + func configure(viewModel: DynamicIslandViewModel) { + self.viewModel = viewModel + } } diff --git a/DynamicIsland/managers/LockScreenManager.swift b/DynamicIsland/managers/LockScreenManager.swift index 8e7bef1..00c3b87 100644 --- a/DynamicIsland/managers/LockScreenManager.swift +++ b/DynamicIsland/managers/LockScreenManager.swift @@ -18,6 +18,7 @@ class LockScreenManager: ObservableObject { // MARK: - Coordinator private let coordinator = DynamicIslandViewCoordinator.shared + private weak var viewModel: DynamicIslandViewModel? // MARK: - Published Properties @Published var isLocked: Bool = false @@ -79,6 +80,17 @@ class LockScreenManager: ObservableObject { // Set locked state immediately without animation wrapper isLocked = true + + 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") @@ -143,6 +155,10 @@ class LockScreenManager: ObservableObject { // MARK: - Extensions extension LockScreenManager { + func configure(viewModel: DynamicIslandViewModel) { + self.viewModel = viewModel + } + /// Get current lock status without async var currentLockStatus: Bool { return isLocked diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift index 7e89df8..06fff41 100644 --- a/DynamicIsland/managers/LockScreenPanelManager.swift +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -41,11 +41,11 @@ class LockScreenPanelManager { return } - let panelWidth: CGFloat = 420 - let panelHeight: CGFloat = 150 + let panelWidth: CGFloat = 460 + let panelHeight: CGFloat = 180 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 originY = screenFrame.origin.y + (screenFrame.height / 2) - panelHeight - 32 let targetFrame = NSRect(x: originX, y: originY, width: panelWidth, height: panelHeight) let window: NSWindow diff --git a/DynamicIsland/models/DynamicIslandViewModel.swift b/DynamicIsland/models/DynamicIslandViewModel.swift index d1e5237..ae057bf 100644 --- a/DynamicIsland/models/DynamicIslandViewModel.swift +++ b/DynamicIsland/models/DynamicIslandViewModel.swift @@ -174,6 +174,15 @@ class DynamicIslandViewModel: NSObject, ObservableObject { } } + func closeForLockScreen() { + let targetSize = getClosedNotchSize(screen: screen) + withAnimation(.none) { + notchSize = targetSize + closedNotchSize = targetSize + notchState = .closed + } + } + func closeHello() { DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { [weak self] in self?.coordinator.firstLaunch = false diff --git a/DynamicIsland/utils/Environment+LockScreenReplica.swift b/DynamicIsland/utils/Environment+LockScreenReplica.swift new file mode 100644 index 0000000..7a6fb9f --- /dev/null +++ b/DynamicIsland/utils/Environment+LockScreenReplica.swift @@ -0,0 +1 @@ +// Deprecated: lockScreenReplica environment key now lives in ContentView.swift. From 070f88df6fa1f0495cce4bace4196b9fe33fc206 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 17 Oct 2025 20:43:29 +0530 Subject: [PATCH 07/11] Fully working stuff --- DynamicIsland/Localizable.xcstrings | 6 ++ .../LockScreen/LockScreenMusicPanel.swift | 82 ++++++++++++------- .../Notch/MinimalisticMusicPlayerView.swift | 69 ++++++++++++---- .../components/Settings/SettingsView.swift | 18 ++++ DynamicIsland/enums/generic.swift | 7 ++ .../managers/LockScreenPanelManager.swift | 9 +- DynamicIsland/models/Constants.swift | 1 + 7 files changed, 143 insertions(+), 49 deletions(-) diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index b5d8596..c3a14aa 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -9189,6 +9189,9 @@ }, "LinkedIn" : { + }, + "Liquid Glass requires macOS 26 or later." : { + }, "Live Monitoring" : { @@ -9198,6 +9201,9 @@ }, "Lock Screen" : { + }, + "Lock screen material" : { + }, "Loop Mode" : { diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 75ace95..16713f5 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -9,15 +9,24 @@ import SwiftUI import Defaults struct LockScreenMusicPanel: View { + static let defaultSize = CGSize(width: 420, height: 180) + @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 + @Default(.lockScreenGlassStyle) var lockScreenGlassStyle - private let panelWidth: CGFloat = 460 - private let panelHeight: CGFloat = 180 private let cornerRadius: CGFloat = 28 + private var panelSize: CGSize { Self.defaultSize } + + private var usesLiquidGlass: Bool { + if #available(macOS 26.0, *) { + return lockScreenGlassStyle == .liquid + } + return false + } var body: some View { if isActive { @@ -27,7 +36,7 @@ struct LockScreenMusicPanel: View { } } else { Color.clear - .frame(width: panelWidth, height: panelHeight) + .frame(width: panelSize.width, height: panelSize.height) } } @@ -87,26 +96,8 @@ struct LockScreenMusicPanel: View { } .padding(.horizontal, 20) .padding(.vertical, 16) - .frame(width: panelWidth, height: panelHeight) - .background { - if #available(macOS 26.0, *) { - RoundedRectangle(cornerRadius: cornerRadius) - .glassEffect(in: .rect(cornerRadius: cornerRadius)) - .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: cornerRadius) - .fill(.ultraThinMaterial) - .onAppear { - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm:ss.SSS" - print("[\(formatter.string(from: Date()))] LockScreenMusicPanel: 🪟 Using ultraThinMaterial fallback (macOS < 26.0)") - } - } - } + .frame(width: panelSize.width, height: panelSize.height) + .background(panelBackground) .overlay { RoundedRectangle(cornerRadius: cornerRadius) .stroke(Color.white.opacity(0.35), lineWidth: 1.4) @@ -115,9 +106,7 @@ struct LockScreenMusicPanel: View { .onAppear { sliderValue = musicManager.elapsedTime isActive = true - let formatter = DateFormatter() - formatter.dateFormat = "HH:mm:ss.SSS" - print("[\(formatter.string(from: Date()))] LockScreenMusicPanel: ✅ View appeared") + logPanelAppearance() } } @@ -241,9 +230,8 @@ struct LockScreenMusicPanel: View { Image(systemName: musicManager.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 24, weight: .semibold)) .foregroundColor(.white) - .frame(width: 54, height: 54) - .background(Color.white.opacity(0.15)) - .clipShape(Circle()) + .frame(width: 48, height: 48) + .contentShape(Rectangle()) } .buttonStyle(PlainButtonStyle()) } @@ -253,6 +241,8 @@ struct LockScreenMusicPanel: View { Image(systemName: icon) .font(.system(size: size, weight: .medium)) .foregroundColor(isActive ? .red : .white.opacity(0.8)) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) } .buttonStyle(PlainButtonStyle()) } @@ -264,4 +254,38 @@ struct LockScreenMusicPanel: View { case .one: return "repeat.1" } } + + @ViewBuilder + private var panelBackground: some View { + if usesLiquidGlass { + liquidPanelBackground + } else { + frostedPanelBackground + } + } + + @ViewBuilder + private var liquidPanelBackground: some View { + if #available(macOS 26.0, *) { + RoundedRectangle(cornerRadius: cornerRadius) + .glassEffect( + .regular + .tint(Color.white.opacity(0.12)) + .interactive(), + in: .rect(cornerRadius: cornerRadius) + ) + } + } + + private var frostedPanelBackground: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(.ultraThinMaterial) + } + + private func logPanelAppearance(event: String = "✅ View appeared") { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + let styleDescriptor = usesLiquidGlass ? "Liquid Glass" : "Frosted" + print("[\(formatter.string(from: Date()))] LockScreenMusicPanel: \(event) – \(styleDescriptor)") + } } diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index fd7eae7..febb879 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -192,24 +192,29 @@ struct MinimalisticMusicPlayerView: View { } 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()) + MinimalisticSquircircleButton( + icon: musicManager.isPlaying ? "pause.fill" : "play.fill", + fontSize: 24, + fontWeight: .semibold, + frameSize: CGSize(width: 54, height: 54), + cornerRadius: 20, + foregroundColor: .white, + action: { + Task { await musicManager.togglePlay() } + } + ) } 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()) + MinimalisticSquircircleButton( + icon: icon, + fontSize: size, + fontWeight: .medium, + frameSize: CGSize(width: 40, height: 40), + cornerRadius: 16, + foregroundColor: isActive ? .red : .white.opacity(0.85), + action: action + ) } private var repeatIcon: String { @@ -274,3 +279,37 @@ struct MinimalisticAlbumArtView: View { .scaleEffect(musicManager.isPlaying ? 1 : 0.85) } } + +// MARK: - Hover-highlighted control button + +private struct MinimalisticSquircircleButton: View { + let icon: String + let fontSize: CGFloat + let fontWeight: Font.Weight + let frameSize: CGSize + let cornerRadius: CGFloat + let foregroundColor: Color + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: fontSize, weight: fontWeight)) + .foregroundColor(foregroundColor) + .frame(width: frameSize.width, height: frameSize.height) + .background( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(isHovering ? Color.white.opacity(0.18) : .clear) + ) + .contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } + .buttonStyle(PlainButtonStyle()) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.18)) { + isHovering = hovering + } + } + } +} diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 8e6bc5d..dfc2e05 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -174,6 +174,7 @@ struct GeneralSettings: View { @Default(.enableScreenRecordingDetection) var enableScreenRecordingDetection @Default(.showRecordingIndicator) var showRecordingIndicator @Default(.enableMinimalisticUI) var enableMinimalisticUI + @Default(.lockScreenGlassStyle) var lockScreenGlassStyle var body: some View { Form { @@ -298,6 +299,23 @@ struct GeneralSettings: View { Section { Defaults.Toggle("Enable Lock Screen Live Activity", key: .enableLockScreenLiveActivity) + if #available(macOS 26.0, *) { + Picker("Lock screen material", selection: $lockScreenGlassStyle) { + ForEach(LockScreenGlassStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + } else { + Picker("Lock screen material", selection: $lockScreenGlassStyle) { + ForEach(LockScreenGlassStyle.allCases) { style in + Text(style.rawValue).tag(style) + } + } + .disabled(true) + Text("Liquid Glass requires macOS 26 or later.") + .font(.caption) + .foregroundStyle(.secondary) + } Button("Copy Latest Crash Report") { copyLatestCrashReport() diff --git a/DynamicIsland/enums/generic.swift b/DynamicIsland/enums/generic.swift index e1ae0c6..58736df 100644 --- a/DynamicIsland/enums/generic.swift +++ b/DynamicIsland/enums/generic.swift @@ -70,3 +70,10 @@ enum SliderColorEnum: String, CaseIterable, Defaults.Serializable { case albumArt = "Match album art" case accent = "Accent color" } + +enum LockScreenGlassStyle: String, CaseIterable, Defaults.Serializable, Identifiable { + case liquid = "Liquid Glass" + case frosted = "Frosted Glass" + + var id: String { rawValue } +} diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift index 06fff41..16f5fab 100644 --- a/DynamicIsland/managers/LockScreenPanelManager.swift +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -41,12 +41,11 @@ class LockScreenPanelManager { return } - let panelWidth: CGFloat = 460 - let panelHeight: CGFloat = 180 + let panelSize = LockScreenMusicPanel.defaultSize let screenFrame = screen.frame - let originX = screenFrame.origin.x + (screenFrame.width / 2) - (panelWidth / 2) - let originY = screenFrame.origin.y + (screenFrame.height / 2) - panelHeight - 32 - let targetFrame = NSRect(x: originX, y: originY, width: panelWidth, height: panelHeight) + let originX = screenFrame.origin.x + (screenFrame.width / 2) - (panelSize.width / 2) + let originY = screenFrame.origin.y + (screenFrame.height / 2) - panelSize.height - 32 + let targetFrame = NSRect(x: originX, y: originY, width: panelSize.width, height: panelSize.height) let window: NSWindow diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index fa40afe..1b7b625 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -336,6 +336,7 @@ extension Defaults.Keys { 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) + static let lockScreenGlassStyle = Key("lockScreenGlassStyle", default: .liquid) // MARK: Battery static let showPowerStatusNotifications = Key("showPowerStatusNotifications", default: true) From 7af1d1bc2757a1d3f66715d77f621293ce92da79 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 17 Oct 2025 22:09:05 +0530 Subject: [PATCH 08/11] Expanding Lock Screen Music player --- .../LockScreen/LockScreenMusicPanel.swift | 269 +++++++++++++----- .../managers/LockScreenPanelManager.swift | 28 +- 2 files changed, 218 insertions(+), 79 deletions(-) diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 16713f5..a6f5fb0 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -9,17 +9,23 @@ import SwiftUI import Defaults struct LockScreenMusicPanel: View { - static let defaultSize = CGSize(width: 420, height: 180) + static let collapsedSize = CGSize(width: 420, height: 180) + static let expandedSize = CGSize(width: 660, height: 310) @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 + @State private var isExpanded = false + @State private var collapseWorkItem: DispatchWorkItem? @Default(.lockScreenGlassStyle) var lockScreenGlassStyle private let cornerRadius: CGFloat = 28 - private var panelSize: CGSize { Self.defaultSize } + private let collapseTimeout: TimeInterval = 5 + + private var currentSize: CGSize { + isExpanded ? Self.expandedSize : Self.collapsedSize + } private var usesLiquidGlass: Bool { if #available(macOS 26.0, *) { @@ -31,84 +37,185 @@ struct LockScreenMusicPanel: View { var body: some View { if isActive { panelContent - .onDisappear { - isActive = false - } } else { Color.clear - .frame(width: panelSize.width, height: panelSize.height) + .frame(width: Self.collapsedSize.width, height: Self.collapsedSize.height) } } private var panelContent: some View { - VStack(spacing: 12) { - // Header area with album art - HStack(alignment: .center, spacing: 16) { - // Album art - Button { - musicManager.openMusicApp() - } label: { - Image(nsImage: musicManager.albumArt) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: 60, height: 60) - .clipShape(RoundedRectangle(cornerRadius: 16)) - } - .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) - } + panelCore + .frame(width: currentSize.width, height: currentSize.height) + .animation(.spring(response: 0.48, dampingFraction: 0.82, blendDuration: 0.18), value: isExpanded) + .onAppear { + sliderValue = musicManager.elapsedTime + isActive = true + logPanelAppearance() + LockScreenPanelManager.shared.updatePanelSize(expanded: false, animated: false) + } + .onDisappear { + isActive = false + cancelCollapseTimer() + } + .onChange(of: isExpanded) { expanded in + LockScreenPanelManager.shared.updatePanelSize(expanded: expanded) + } + } + + @ViewBuilder + private var panelCore: some View { + Group { + if isExpanded { + expandedLayout + } else { + collapsedLayout } - .frame(height: 60) - - // Progress bar - progressBar - .padding(.top, 4) - - // Playback controls - playbackControls - .padding(.top, 4) } - .padding(.horizontal, 20) - .padding(.vertical, 16) - .frame(width: panelSize.width, height: panelSize.height) + .padding(.horizontal, isExpanded ? 24 : 20) + .padding(.vertical, isExpanded ? 22 : 16) + .frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading) .background(panelBackground) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: cornerRadius) .stroke(Color.white.opacity(0.35), lineWidth: 1.4) } .shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10) - .onAppear { - sliderValue = musicManager.elapsedTime - isActive = true - logPanelAppearance() + .contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } + + private var collapsedLayout: some View { + VStack(spacing: 12) { + collapsedHeader + progressBar + .padding(.top, 4) + .frame(maxWidth: .infinity) + playbackControls(alignment: .center) + .padding(.top, 4) } } + + private var expandedLayout: some View { + HStack(alignment: .center, spacing: 28) { + albumArtButton(size: 220, cornerRadius: cornerRadius) + .frame(width: 220, height: 220) + + VStack(alignment: .leading, spacing: 20) { + expandedHeader + progressBar + .padding(.top, 10) + .frame(maxWidth: .infinity) + playbackControls(alignment: .leading) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxHeight: .infinity, alignment: .center) + } + + private var collapsedHeader: some View { + HStack(alignment: .center, spacing: 16) { + albumArtButton(size: 60, cornerRadius: 16) + + 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(width: 20, height: 16) + } + .frame(height: 60) + } + + private var expandedHeader: some View { + HStack(alignment: .top, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text(musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(.white) + .lineLimit(2) + + Text(musicManager.artistName.isEmpty ? "Unknown Artist" : musicManager.artistName) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: musicManager.avgColor).ensureMinimumBrightness(factor: 0.7) : .gray) + .lineLimit(2) + } + + Spacer() + + visualizer(width: 24, height: 20) + } + } + + private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View { + Button(action: toggleExpanded) { + Image(nsImage: musicManager.albumArt) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } + .buttonStyle(PlainButtonStyle()) + .opacity(musicManager.isPlaying ? 1 : 0.4) + .scaleEffect(musicManager.isPlaying ? 1 : 0.85) + .animation(.easeInOut(duration: 0.2), value: musicManager.isPlaying) + } + + @ViewBuilder + private func visualizer(width: CGFloat, height: CGFloat) -> some View { + if Defaults[.useMusicVisualizer] { + Rectangle() + .fill(Defaults[.coloredSpectrogram] ? Color(nsColor: musicManager.avgColor).gradient : Color.gray.gradient) + .mask { + AudioSpectrumView(isPlaying: .constant(musicManager.isPlaying)) + .frame(width: width, height: height) + } + .frame(width: width, height: height) + } + } + + private func toggleExpanded() { + let newState = !isExpanded + withAnimation(.spring(response: 0.5, dampingFraction: 0.82)) { + isExpanded = newState + } + + if newState { + registerInteraction() + logPanelAppearance(event: "🔍 Expanded") + } else { + logPanelAppearance(event: "⬇️ Collapsed") + cancelCollapseTimer() + } + } + + private func registerInteraction() { + cancelCollapseTimer() + guard isExpanded else { return } + + let workItem = DispatchWorkItem { + withAnimation(.spring(response: 0.5, dampingFraction: 0.85)) { + isExpanded = false + } + logPanelAppearance(event: "⏱️ Auto-collapsed") + } + + collapseWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + collapseTimeout, execute: workItem) + } + + private func cancelCollapseTimer() { + collapseWorkItem?.cancel() + collapseWorkItem = nil + } // MARK: - Progress Bar @@ -139,12 +246,13 @@ struct LockScreenMusicPanel: View { .gesture( DragGesture(minimumDistance: 0) .onChanged { value in + registerInteraction() dragging = true let newValue = min(max(0, Double(value.location.x / geometry.size.width) * musicManager.songDuration), musicManager.songDuration) sliderValue = newValue - lastDragged = Date() } .onEnded { _ in + registerInteraction() musicManager.seek(to: sliderValue) dragging = false } @@ -197,8 +305,10 @@ struct LockScreenMusicPanel: View { // MARK: - Playback Controls - private var playbackControls: some View { - HStack(spacing: 20) { + private func playbackControls(alignment: Alignment) -> some View { + let spacing: CGFloat = isExpanded ? 24 : 20 + + return HStack(spacing: spacing) { // Always show shuffle on lock screen controlButton(icon: "shuffle", isActive: musicManager.isShuffled) { Task { await musicManager.toggleShuffle() } @@ -219,29 +329,38 @@ struct LockScreenMusicPanel: View { Task { await musicManager.toggleRepeat() } } } - .frame(maxWidth: .infinity, alignment: .center) - .padding(.top, 2) + .frame(maxWidth: .infinity, alignment: alignment) + .padding(.top, isExpanded ? 6 : 2) } private var playPauseButton: some View { - Button(action: { + let frameSize: CGFloat = isExpanded ? 60 : 48 + + return Button(action: { + registerInteraction() Task { await musicManager.togglePlay() } }) { Image(systemName: musicManager.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 24, weight: .semibold)) .foregroundColor(.white) - .frame(width: 48, height: 48) + .frame(width: frameSize, height: frameSize) .contentShape(Rectangle()) } .buttonStyle(PlainButtonStyle()) } private func controlButton(icon: String, size: CGFloat = 18, isActive: Bool = false, action: @escaping () -> Void) -> some View { - Button(action: action) { + let frameSize: CGFloat = isExpanded ? 44 : 32 + let iconSize: CGFloat = isExpanded ? max(size, 20) : size + + return Button(action: { + registerInteraction() + action() + }) { Image(systemName: icon) - .font(.system(size: size, weight: .medium)) + .font(.system(size: iconSize, weight: .medium)) .foregroundColor(isActive ? .red : .white.opacity(0.8)) - .frame(width: 32, height: 32) + .frame(width: frameSize, height: frameSize) .contentShape(Rectangle()) } .buttonStyle(PlainButtonStyle()) diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift index 16f5fab..ad54397 100644 --- a/DynamicIsland/managers/LockScreenPanelManager.swift +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -16,6 +16,7 @@ class LockScreenPanelManager { private var panelWindow: NSWindow? private var hasDelegated = false + private var collapsedFrame: NSRect? private init() { print("[\(timestamp())] LockScreenPanelManager: initialized") @@ -41,11 +42,13 @@ class LockScreenPanelManager { return } - let panelSize = LockScreenMusicPanel.defaultSize + let collapsedSize = LockScreenMusicPanel.collapsedSize let screenFrame = screen.frame - let originX = screenFrame.origin.x + (screenFrame.width / 2) - (panelSize.width / 2) - let originY = screenFrame.origin.y + (screenFrame.height / 2) - panelSize.height - 32 - let targetFrame = NSRect(x: originX, y: originY, width: panelSize.width, height: panelSize.height) + let centerX = screenFrame.origin.x + (screenFrame.width / 2) + let originX = centerX - (collapsedSize.width / 2) + let originY = screenFrame.origin.y + (screenFrame.height / 2) - collapsedSize.height - 32 + let targetFrame = NSRect(x: originX, y: originY, width: collapsedSize.width, height: collapsedSize.height) + collapsedFrame = targetFrame let window: NSWindow @@ -86,6 +89,23 @@ class LockScreenPanelManager { print("[\(timestamp())] LockScreenPanelManager: panel visible") } + func updatePanelSize(expanded: Bool, animated: Bool = true) { + guard let window = panelWindow, let baseFrame = collapsedFrame else { + return + } + + let targetSize = expanded ? LockScreenMusicPanel.expandedSize : LockScreenMusicPanel.collapsedSize + let originX = baseFrame.midX - (targetSize.width / 2) + let originY = baseFrame.origin.y + let targetFrame = NSRect(x: originX, y: originY, width: targetSize.width, height: targetSize.height) + + if animated { + window.animator().setFrame(targetFrame, display: true) + } else { + window.setFrame(targetFrame, display: true) + } + } + func hidePanel() { print("[\(timestamp())] LockScreenPanelManager: hidePanel") From 1eb3d3d968fda196220e8eba3ec7532251c1b33e Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 17 Oct 2025 22:58:34 +0530 Subject: [PATCH 09/11] Refined UI --- .../LockScreen/LockScreenMusicPanel.swift | 150 ++++++++++++++---- .../components/Settings/SettingsView.swift | 6 + DynamicIsland/models/Constants.swift | 3 + 3 files changed, 128 insertions(+), 31 deletions(-) diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index a6f5fb0..e240cbd 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -10,7 +10,7 @@ import Defaults struct LockScreenMusicPanel: View { static let collapsedSize = CGSize(width: 420, height: 180) - static let expandedSize = CGSize(width: 660, height: 310) + static let expandedSize = CGSize(width: 720, height: 340) @ObservedObject var musicManager = MusicManager.shared @State private var sliderValue: Double = 0 @@ -19,14 +19,24 @@ struct LockScreenMusicPanel: View { @State private var isExpanded = false @State private var collapseWorkItem: DispatchWorkItem? @Default(.lockScreenGlassStyle) var lockScreenGlassStyle + @Default(.lockScreenShowAppIcon) var showAppIcon + @Default(.lockScreenPanelShowsBorder) var showPanelBorder + @Default(.lockScreenPanelUsesBlur) var enableBlur - private let cornerRadius: CGFloat = 28 + private let collapsedPanelCornerRadius: CGFloat = 28 + private let expandedPanelCornerRadius: CGFloat = 44 + private let collapsedAlbumArtCornerRadius: CGFloat = 16 + private let expandedAlbumArtCornerRadius: CGFloat = 52 private let collapseTimeout: TimeInterval = 5 private var currentSize: CGSize { isExpanded ? Self.expandedSize : Self.collapsedSize } + private var panelCornerRadius: CGFloat { + isExpanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius + } + private var usesLiquidGlass: Bool { if #available(macOS 26.0, *) { return lockScreenGlassStyle == .liquid @@ -57,7 +67,7 @@ struct LockScreenMusicPanel: View { isActive = false cancelCollapseTimer() } - .onChange(of: isExpanded) { expanded in + .onChange(of: isExpanded) { _, expanded in LockScreenPanelManager.shared.updatePanelSize(expanded: expanded) } } @@ -75,13 +85,15 @@ struct LockScreenMusicPanel: View { .padding(.vertical, isExpanded ? 22 : 16) .frame(width: currentSize.width, height: currentSize.height, alignment: .topLeading) .background(panelBackground) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .clipShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) .overlay { - RoundedRectangle(cornerRadius: cornerRadius) - .stroke(Color.white.opacity(0.35), lineWidth: 1.4) + if showPanelBorder { + RoundedRectangle(cornerRadius: panelCornerRadius) + .stroke(Color.white.opacity(0.35), lineWidth: 1.4) + } } .shadow(color: Color.black.opacity(0.3), radius: 20, x: 0, y: 10) - .contentShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: panelCornerRadius, style: .continuous)) } private var collapsedLayout: some View { @@ -96,9 +108,9 @@ struct LockScreenMusicPanel: View { } private var expandedLayout: some View { - HStack(alignment: .center, spacing: 28) { - albumArtButton(size: 220, cornerRadius: cornerRadius) - .frame(width: 220, height: 220) + HStack(alignment: .center, spacing: 32) { + albumArtButton(size: 230, cornerRadius: expandedAlbumArtCornerRadius) + .frame(width: 230, height: 230) VStack(alignment: .leading, spacing: 20) { expandedHeader @@ -114,7 +126,7 @@ struct LockScreenMusicPanel: View { private var collapsedHeader: some View { HStack(alignment: .center, spacing: 16) { - albumArtButton(size: 60, cornerRadius: 16) + albumArtButton(size: 60, cornerRadius: collapsedAlbumArtCornerRadius) VStack(alignment: .leading, spacing: 1) { Text(musicManager.songTitle.isEmpty ? "No Music Playing" : musicManager.songTitle) @@ -157,11 +169,29 @@ struct LockScreenMusicPanel: View { private func albumArtButton(size: CGFloat, cornerRadius: CGFloat) -> some View { Button(action: toggleExpanded) { - Image(nsImage: musicManager.albumArt) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: size, height: size) - .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + ZStack(alignment: .bottomTrailing) { + albumArtImage(size: size, cornerRadius: cornerRadius) + if showAppIcon, let icon = lockScreenAppIcon { + RoundedRectangle(cornerRadius: appIconCornerRadius, style: .continuous) + .fill(Color.black.opacity(0.45)) + .frame(width: appIconSize, height: appIconSize) + .overlay { + icon + .resizable() + .aspectRatio(contentMode: .fill) + .clipShape(RoundedRectangle(cornerRadius: appIconCornerRadius, style: .continuous)) + } + .overlay { + RoundedRectangle(cornerRadius: appIconCornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.4), lineWidth: 1) + } + .shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4) + .offset(x: appIconOffset, y: appIconOffset) + .transition(.scale.combined(with: .opacity)) + } + } + .frame(width: size, height: size) + .background(albumArtBackground(cornerRadius: cornerRadius)) } .buttonStyle(PlainButtonStyle()) .opacity(musicManager.isPlaying ? 1 : 0.4) @@ -311,22 +341,22 @@ struct LockScreenMusicPanel: View { return HStack(spacing: spacing) { // Always show shuffle on lock screen controlButton(icon: "shuffle", isActive: musicManager.isShuffled) { - Task { await musicManager.toggleShuffle() } + musicManager.toggleShuffle() } controlButton(icon: "backward.fill", size: 18) { - Task { await musicManager.previousTrack() } + musicManager.previousTrack() } playPauseButton controlButton(icon: "forward.fill", size: 18) { - Task { await musicManager.nextTrack() } + musicManager.nextTrack() } // Always show repeat on lock screen controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) { - Task { await musicManager.toggleRepeat() } + musicManager.toggleRepeat() } } .frame(maxWidth: .infinity, alignment: alignment) @@ -334,14 +364,15 @@ struct LockScreenMusicPanel: View { } private var playPauseButton: some View { - let frameSize: CGFloat = isExpanded ? 60 : 48 + let frameSize: CGFloat = isExpanded ? 72 : 48 + let symbolSize: CGFloat = isExpanded ? 30 : 24 return Button(action: { registerInteraction() - Task { await musicManager.togglePlay() } + musicManager.togglePlay() }) { Image(systemName: musicManager.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 24, weight: .semibold)) + .font(.system(size: symbolSize, weight: .semibold)) .foregroundColor(.white) .frame(width: frameSize, height: frameSize) .contentShape(Rectangle()) @@ -350,8 +381,8 @@ struct LockScreenMusicPanel: View { } private func controlButton(icon: String, size: CGFloat = 18, isActive: Bool = false, action: @escaping () -> Void) -> some View { - let frameSize: CGFloat = isExpanded ? 44 : 32 - let iconSize: CGFloat = isExpanded ? max(size, 20) : size + let frameSize: CGFloat = isExpanded ? 56 : 32 + let iconSize: CGFloat = isExpanded ? max(size, 24) : size return Button(action: { registerInteraction() @@ -376,31 +407,88 @@ struct LockScreenMusicPanel: View { @ViewBuilder private var panelBackground: some View { - if usesLiquidGlass { - liquidPanelBackground + if enableBlur { + if usesLiquidGlass { + liquidPanelBackground + } else { + frostedPanelBackground + } } else { - frostedPanelBackground + RoundedRectangle(cornerRadius: panelCornerRadius) + .fill(Color.black.opacity(0.45)) } } @ViewBuilder private var liquidPanelBackground: some View { if #available(macOS 26.0, *) { - RoundedRectangle(cornerRadius: cornerRadius) + RoundedRectangle(cornerRadius: panelCornerRadius) .glassEffect( .regular .tint(Color.white.opacity(0.12)) .interactive(), - in: .rect(cornerRadius: cornerRadius) + in: .rect(cornerRadius: panelCornerRadius) ) } } private var frostedPanelBackground: some View { - RoundedRectangle(cornerRadius: cornerRadius) + RoundedRectangle(cornerRadius: panelCornerRadius) .fill(.ultraThinMaterial) } + private func albumArtImage(size: CGFloat, cornerRadius: CGFloat) -> some View { + Image(nsImage: musicManager.albumArt) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } + + @ViewBuilder + private func albumArtBackground(cornerRadius: CGFloat) -> some View { + if enableBlur { + if usesLiquidGlass { + if #available(macOS 26.0, *) { + RoundedRectangle(cornerRadius: cornerRadius) + .glassEffect( + .regular + .tint(Color.white.opacity(0.16)) + .interactive(), + in: .rect(cornerRadius: cornerRadius) + ) + } else { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(.ultraThinMaterial) + } + } else { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(.ultraThinMaterial) + } + } else { + RoundedRectangle(cornerRadius: cornerRadius) + .fill(Color.black.opacity(0.35)) + } + } + + private var lockScreenAppIcon: Image? { + guard showAppIcon, !musicManager.usingAppIconForArtwork else { return nil } + let bundleIdentifier = musicManager.bundleIdentifier ?? "com.apple.Music" + return AppIcon(for: bundleIdentifier) + } + + private var appIconSize: CGFloat { + isExpanded ? 54 : 32 + } + + private var appIconCornerRadius: CGFloat { + isExpanded ? 18 : 10 + } + + private var appIconOffset: CGFloat { + isExpanded ? 16 : 10 + } + private func logPanelAppearance(event: String = "✅ View appeared") { let formatter = DateFormatter() formatter.dateFormat = "HH:mm:ss.SSS" diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index dfc2e05..6e7b364 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -175,6 +175,9 @@ struct GeneralSettings: View { @Default(.showRecordingIndicator) var showRecordingIndicator @Default(.enableMinimalisticUI) var enableMinimalisticUI @Default(.lockScreenGlassStyle) var lockScreenGlassStyle + @Default(.lockScreenShowAppIcon) var lockScreenShowAppIcon + @Default(.lockScreenPanelShowsBorder) var lockScreenPanelShowsBorder + @Default(.lockScreenPanelUsesBlur) var lockScreenPanelUsesBlur var body: some View { Form { @@ -316,6 +319,9 @@ struct GeneralSettings: View { .font(.caption) .foregroundStyle(.secondary) } + Defaults.Toggle("Show media app icon", key: .lockScreenShowAppIcon) + Defaults.Toggle("Show panel border", key: .lockScreenPanelShowsBorder) + Defaults.Toggle("Enable blur", key: .lockScreenPanelUsesBlur) Button("Copy Latest Crash Report") { copyLatestCrashReport() diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 1b7b625..40c17cd 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -337,6 +337,9 @@ extension Defaults.Keys { // Enable lock screen media widget (shows the standalone panel when screen is locked) static let enableLockScreenMediaWidget = Key("enableLockScreenMediaWidget", default: true) static let lockScreenGlassStyle = Key("lockScreenGlassStyle", default: .liquid) + static let lockScreenShowAppIcon = Key("lockScreenShowAppIcon", default: false) + static let lockScreenPanelShowsBorder = Key("lockScreenPanelShowsBorder", default: true) + static let lockScreenPanelUsesBlur = Key("lockScreenPanelUsesBlur", default: true) // MARK: Battery static let showPowerStatusNotifications = Key("showPowerStatusNotifications", default: true) From b6424a196f863ef19256e944852fcdf237bc97ae Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 17 Oct 2025 23:18:51 +0530 Subject: [PATCH 10/11] Fully furnished UI --- .../LockScreen/LockScreenMusicPanel.swift | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index e240cbd..30276ea 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -24,9 +24,10 @@ struct LockScreenMusicPanel: View { @Default(.lockScreenPanelUsesBlur) var enableBlur private let collapsedPanelCornerRadius: CGFloat = 28 - private let expandedPanelCornerRadius: CGFloat = 44 + private let expandedPanelCornerRadius: CGFloat = 52 private let collapsedAlbumArtCornerRadius: CGFloat = 16 - private let expandedAlbumArtCornerRadius: CGFloat = 52 + private let expandedAlbumArtCornerRadius: CGFloat = 60 + private let expandedContentSpacing: CGFloat = 40 private let collapseTimeout: TimeInterval = 5 private var currentSize: CGSize { @@ -108,7 +109,7 @@ struct LockScreenMusicPanel: View { } private var expandedLayout: some View { - HStack(alignment: .center, spacing: 32) { + HStack(alignment: .center, spacing: expandedContentSpacing) { albumArtButton(size: 230, cornerRadius: expandedAlbumArtCornerRadius) .frame(width: 230, height: 230) @@ -172,19 +173,11 @@ struct LockScreenMusicPanel: View { ZStack(alignment: .bottomTrailing) { albumArtImage(size: size, cornerRadius: cornerRadius) if showAppIcon, let icon = lockScreenAppIcon { - RoundedRectangle(cornerRadius: appIconCornerRadius, style: .continuous) - .fill(Color.black.opacity(0.45)) + icon + .resizable() + .aspectRatio(contentMode: .fill) .frame(width: appIconSize, height: appIconSize) - .overlay { - icon - .resizable() - .aspectRatio(contentMode: .fill) - .clipShape(RoundedRectangle(cornerRadius: appIconCornerRadius, style: .continuous)) - } - .overlay { - RoundedRectangle(cornerRadius: appIconCornerRadius, style: .continuous) - .stroke(Color.white.opacity(0.4), lineWidth: 1) - } + .clipShape(RoundedRectangle(cornerRadius: appIconCornerRadius, style: .continuous)) .shadow(color: Color.black.opacity(0.35), radius: 6, x: 0, y: 4) .offset(x: appIconOffset, y: appIconOffset) .transition(.scale.combined(with: .opacity)) @@ -478,7 +471,7 @@ struct LockScreenMusicPanel: View { } private var appIconSize: CGFloat { - isExpanded ? 54 : 32 + isExpanded ? 58 : 34 } private var appIconCornerRadius: CGFloat { @@ -486,7 +479,7 @@ struct LockScreenMusicPanel: View { } private var appIconOffset: CGFloat { - isExpanded ? 16 : 10 + isExpanded ? 18 : 12 } private func logPanelAppearance(event: String = "✅ View appeared") { From 12f51615ae05a9586776208367aede3f1b44558f Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 17 Oct 2025 23:43:33 +0530 Subject: [PATCH 11/11] Final Fixes: Flip Animation - wrap for the day --- DynamicIsland/ContentView.swift | 1 + .../LockScreen/LockScreenMusicPanel.swift | 1 + .../components/MinimalisticMusicView.swift | 1 + .../Notch/MinimalisticMusicPlayerView.swift | 2 +- .../components/Notch/NotchHomeView.swift | 1 + .../extensions/AlbumArtFlipModifier.swift | 1 + DynamicIsland/managers/MusicManager.swift | 54 ++++++++++++------- 7 files changed, 42 insertions(+), 19 deletions(-) create mode 100644 DynamicIsland/extensions/AlbumArtFlipModifier.swift diff --git a/DynamicIsland/ContentView.swift b/DynamicIsland/ContentView.swift index c459e14..8c71423 100644 --- a/DynamicIsland/ContentView.swift +++ b/DynamicIsland/ContentView.swift @@ -544,6 +544,7 @@ struct ContentView: View { .clipped() .clipShape(RoundedRectangle(cornerRadius: MusicPlayerImageSizes.cornerRadiusInset.closed)) .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) + .albumArtFlip(angle: musicManager.flipAngle) .frame(width: max(0, vm.effectiveClosedNotchHeight - 12), height: max(0, vm.effectiveClosedNotchHeight - 12)) } .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12) + gestureProgress / 2), height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))) diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 30276ea..b539d8b 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -183,6 +183,7 @@ struct LockScreenMusicPanel: View { .transition(.scale.combined(with: .opacity)) } } + .albumArtFlip(angle: musicManager.flipAngle) .frame(width: size, height: size) .background(albumArtBackground(cornerRadius: cornerRadius)) } diff --git a/DynamicIsland/components/MinimalisticMusicView.swift b/DynamicIsland/components/MinimalisticMusicView.swift index e297b96..23ebb07 100644 --- a/DynamicIsland/components/MinimalisticMusicView.swift +++ b/DynamicIsland/components/MinimalisticMusicView.swift @@ -58,6 +58,7 @@ struct MinimalisticMusicView: View { ) .clipped() .clipShape(RoundedRectangle(cornerRadius: 18)) // Dramatically increased corner radius for minimalistic mode + .albumArtFlip(angle: musicManager.flipAngle) .frame(width: max(0, vm.effectiveClosedNotchHeight - 12), height: max(0, vm.effectiveClosedNotchHeight - 12)) } .frame(width: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12)), height: max(0, vm.effectiveClosedNotchHeight - (isHovering ? 0 : 12))) diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index febb879..a18243f 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -268,11 +268,11 @@ struct MinimalisticAlbumArtView: View { Image(nsImage: musicManager.albumArt) .resizable() .aspectRatio(contentMode: .fill) - .animation(.spring(response: 0.4, dampingFraction: 0.8), value: musicManager.isFlipping) ) .clipped() .clipShape(RoundedRectangle(cornerRadius: 12)) .matchedGeometryEffect(id: "albumArt", in: albumArtNamespace) + .albumArtFlip(angle: musicManager.flipAngle) } .buttonStyle(PlainButtonStyle()) .opacity(musicManager.isPlaying ? 1 : 0.4) diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index dbbcf18..f999438 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -65,6 +65,7 @@ struct AlbumArtView: View { albumArtImage appIconOverlay } + .albumArtFlip(angle: musicManager.flipAngle) } .buttonStyle(PlainButtonStyle()) .scaleEffect(musicManager.isPlaying ? 1 : 0.85) diff --git a/DynamicIsland/extensions/AlbumArtFlipModifier.swift b/DynamicIsland/extensions/AlbumArtFlipModifier.swift new file mode 100644 index 0000000..8ccc299 --- /dev/null +++ b/DynamicIsland/extensions/AlbumArtFlipModifier.swift @@ -0,0 +1 @@ +// Deprecated placeholder: albumArtFlip modifier now lives alongside MusicManager. diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index d51b228..3691163 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -55,8 +55,8 @@ class MusicManager: ObservableObject { private var lastArtworkAlbum: String = "Self Love" private var lastArtworkBundleIdentifier: String? = nil - @Published var isFlipping: Bool = false - private var flipWorkItem: DispatchWorkItem? + @Published var flipAngle: Double = 0 + private let flipAnimationDuration: TimeInterval = 0.45 @Published var isTransitioning: Bool = false private var transitionWorkItem: DispatchWorkItem? @@ -93,7 +93,6 @@ class MusicManager: ObservableObject { debounceIdleTask?.cancel() cancellables.removeAll() controllerCancellables.removeAll() - flipWorkItem?.cancel() transitionWorkItem?.cancel() // Release active controller @@ -159,9 +158,6 @@ class MusicManager: ObservableObject { } private func setActiveController(_ controller: any MediaControllerProtocol) { - // Cancel any existing flip animation - flipWorkItem?.cancel() - // Set new active controller activeController = controller @@ -270,19 +266,9 @@ class MusicManager: ObservableObject { } private func triggerFlipAnimation() { - // Cancel any existing animation - flipWorkItem?.cancel() - - // Create a new animation - let workItem = DispatchWorkItem { [weak self] in - self?.isFlipping = true - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - self?.isFlipping = false - } + withAnimation(.easeInOut(duration: flipAnimationDuration)) { + flipAngle += 180 } - - flipWorkItem = workItem - DispatchQueue.main.async(execute: workItem) } private func updateArtwork(_ artworkData: Data) { @@ -447,3 +433,35 @@ class MusicManager: ObservableObject { } } } + +// MARK: - Album Art Flip Helper + +private struct AlbumArtFlipModifier: ViewModifier { + let angle: Double + + private var normalizedAngle: Double { + var value = angle.truncatingRemainder(dividingBy: 360) + if value < 0 { value += 360 } + return value + } + + private var mirrorScale: CGFloat { + (normalizedAngle > 90 && normalizedAngle < 270) ? -1 : 1 + } + + func body(content: Content) -> some View { + content + .rotation3DEffect( + .degrees(angle), + axis: (x: 0, y: 1, z: 0), + perspective: 0.65 + ) + .scaleEffect(x: mirrorScale, y: 1) + } +} + +extension View { + func albumArtFlip(angle: Double) -> some View { + modifier(AlbumArtFlipModifier(angle: angle)) + } +}