mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Working lock screen widget
This commit is contained in:
parent
a887ee220c
commit
44b18513f2
13 changed files with 705 additions and 3 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -85,4 +85,9 @@ comparison_report.json
|
|||
ANIMATION_DEBUG_GUIDE.md
|
||||
MIGRATION_SUMMARY.md
|
||||
ONBOARDING_ENHANCEMENT.md
|
||||
SHEET_PRESENTATION_FIX.md
|
||||
SHEET_PRESENTATION_FIX.md
|
||||
lock_mode_reference.md
|
||||
liquid_glass.md
|
||||
|
||||
MoveToSky.xcodeproj
|
||||
/MoveToSky/
|
||||
|
|
@ -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" */;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ enum SneakContentType {
|
|||
case recording
|
||||
case bluetoothAudio
|
||||
case privacy
|
||||
case lockScreen
|
||||
}
|
||||
|
||||
struct sneakPeek {
|
||||
|
|
|
|||
|
|
@ -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." : {
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
257
DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift
Normal file
257
DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
153
DynamicIsland/managers/LockScreenManager.swift
Normal file
153
DynamicIsland/managers/LockScreenManager.swift
Normal file
|
|
@ -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<Void, Never>?
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func timestamp() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "HH:mm:ss.SSS"
|
||||
return formatter.string(from: Date())
|
||||
}
|
||||
|
||||
// MARK: - Initialization
|
||||
private init() {
|
||||
setupObservers()
|
||||
print("LockScreenManager: 🔒 Initialized")
|
||||
}
|
||||
|
||||
deinit {
|
||||
DistributedNotificationCenter.default().removeObserver(self)
|
||||
debounceIdleTask?.cancel()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
103
DynamicIsland/managers/LockScreenPanelManager.swift
Normal file
103
DynamicIsland/managers/LockScreenPanelManager.swift
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -334,6 +334,8 @@ extension Defaults.Keys {
|
|||
static let enableFullscreenMediaDetection = Key<Bool>("enableFullscreenMediaDetection", default: true)
|
||||
static let waitInterval = Key<Double>("waitInterval", default: 3)
|
||||
static let showShuffleAndRepeat = Key<Bool>("showShuffleAndRepeat", default: false)
|
||||
// Enable lock screen media widget (shows the standalone panel when screen is locked)
|
||||
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
|
||||
|
||||
// MARK: Battery
|
||||
static let showPowerStatusNotifications = Key<Bool>("showPowerStatusNotifications", default: true)
|
||||
|
|
@ -436,6 +438,9 @@ extension Defaults.Keys {
|
|||
static let enableCameraDetection = Key<Bool>("enableCameraDetection", default: true)
|
||||
static let enableMicrophoneDetection = Key<Bool>("enableMicrophoneDetection", default: true)
|
||||
|
||||
// MARK: Lock Screen Features
|
||||
static let enableLockScreenLiveActivity = Key<Bool>("enableLockScreenLiveActivity", default: true)
|
||||
|
||||
// MARK: ImageService
|
||||
static let didClearLegacyURLCacheV1 = Key<Bool>("didClearLegacyURLCacheV1", default: false)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue