diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 6d116e6..d124eac 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -7493,8 +7493,9 @@ "comment" : "A description of how to interact with the music slot configuration view.", "isCommentAutoGenerated" : true }, - "Drag the previews to adjust vertical placement. Positive values lift the panel; negative values lower it. Changes apply instantly while the widgets are visible." : { - + "Drag the previews to adjust vertical placement. Positive values lift the panel; negative values lower it. Use the width sliders below to narrow the media and timer widgets without exceeding their default size. Changes apply instantly while the widgets are visible." : { + "comment" : "A footer text describing the functionality of the lock screen positioning controls.", + "isCommentAutoGenerated" : true }, "Drop files here" : { "localizations" : { diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index b869389..6e796e8 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -16,7 +16,11 @@ struct LockScreenMusicPanel: View { let usesLiquidGlass: Bool } - static let collapsedSize = CGSize(width: 420, height: 180) + static let collapsedHeight: CGFloat = 180 + static let defaultCollapsedWidth: CGFloat = 420 + static var collapsedSize: CGSize { + CGSize(width: CGFloat(Defaults[.lockScreenMusicPanelWidth]), height: collapsedHeight) + } static let expandedSize = CGSize(width: 720, height: 340) @ObservedObject var musicManager = MusicManager.shared @@ -45,6 +49,7 @@ struct LockScreenMusicPanel: View { @Default(.musicSkipBehavior) private var musicSkipBehavior @Default(.enableLyrics) private var enableLyrics @Default(.lockScreenMusicAlbumParallaxEnabled) private var lockScreenParallaxEnabled + @Default(.lockScreenMusicPanelWidth) private var collapsedPanelWidth init(animator: LockScreenPanelAnimator) { _animator = ObservedObject(wrappedValue: animator) @@ -66,10 +71,14 @@ struct LockScreenMusicPanel: View { } private var currentSize: CGSize { - let base = isExpanded ? Self.expandedSize : Self.collapsedSize + let base = isExpanded ? Self.expandedSize : collapsedPanelSize return CGSize(width: base.width, height: base.height + totalExtraHeight) } + private var collapsedPanelSize: CGSize { + CGSize(width: CGFloat(collapsedPanelWidth), height: Self.collapsedHeight) + } + private var panelCornerRadius: CGFloat { isExpanded ? expandedPanelCornerRadius : collapsedPanelCornerRadius } @@ -95,7 +104,7 @@ struct LockScreenMusicPanel: View { panelContent } else { Color.clear - .frame(width: Self.collapsedSize.width, height: Self.collapsedSize.height) + .frame(width: collapsedPanelSize.width, height: collapsedPanelSize.height) } } diff --git a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift index 54ee35e..def01d6 100644 --- a/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift +++ b/DynamicIsland/components/LockScreen/LockScreenTimerWidget.swift @@ -2,7 +2,11 @@ import SwiftUI import Defaults struct LockScreenTimerWidget: View { - static let preferredSize = CGSize(width: 420, height: 96) + static let defaultWidth: Double = 420 + static let preferredHeight: CGFloat = 96 + static var preferredSize: CGSize { + CGSize(width: CGFloat(Defaults[.lockScreenTimerWidgetWidth]), height: preferredHeight) + } static let cornerRadius: CGFloat = 26 @ObservedObject private var animator: LockScreenTimerWidgetAnimator @@ -12,6 +16,7 @@ struct LockScreenTimerWidget: View { @Default(.lockScreenTimerLiquidGlassVariant) private var timerGlassVariant @Default(.lockScreenTimerWidgetUsesBlur) private var timerGlassModeIsGlass @Default(.timerPresets) private var timerPresets + @Default(.lockScreenTimerWidgetWidth) private var widgetWidth @MainActor init(animator: LockScreenTimerWidgetAnimator? = nil) { @@ -146,6 +151,10 @@ struct LockScreenTimerWidget: View { .fill(Color.black.opacity(0.65)) } + private var widgetSize: CGSize { + CGSize(width: CGFloat(widgetWidth), height: Self.preferredHeight) + } + private var pauseIcon: String { timerManager.isPaused ? "play.fill" : "pause.fill" } @@ -175,7 +184,7 @@ struct LockScreenTimerWidget: View { } .padding(.horizontal, 12) .padding(.vertical, 18) - .frame(width: Self.preferredSize.width, height: Self.preferredSize.height) + .frame(width: widgetSize.width, height: widgetSize.height) .background(widgetBackground) .clipShape(RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous)) .overlay { diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index 1b8acfc..59d65dc 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -3648,14 +3648,18 @@ private struct LockScreenPositioningControls: View { @Default(.lockScreenWeatherVerticalOffset) private var weatherOffset @Default(.lockScreenMusicVerticalOffset) private var musicOffset @Default(.lockScreenTimerVerticalOffset) private var timerOffset + @Default(.lockScreenMusicPanelWidth) private var musicWidth + @Default(.lockScreenTimerWidgetWidth) private var timerWidth private let offsetRange: ClosedRange = -160...160 + private let musicWidthRange: ClosedRange = 320...Double(LockScreenMusicPanel.defaultCollapsedWidth) + private let timerWidthRange: ClosedRange = 320...LockScreenTimerWidget.defaultWidth var body: some View { Section { let weatherBinding = Binding( get: { weatherOffset }, set: { newValue in - let clampedValue = clamp(newValue) + let clampedValue = clampOffset(newValue) if weatherOffset != clampedValue { weatherOffset = clampedValue } @@ -3666,7 +3670,7 @@ private struct LockScreenPositioningControls: View { let timerBinding = Binding( get: { timerOffset }, set: { newValue in - let clampedValue = clamp(newValue) + let clampedValue = clampOffset(newValue) if timerOffset != clampedValue { timerOffset = clampedValue } @@ -3677,7 +3681,7 @@ private struct LockScreenPositioningControls: View { let musicBinding = Binding( get: { musicOffset }, set: { newValue in - let clampedValue = clamp(newValue) + let clampedValue = clampOffset(newValue) if musicOffset != clampedValue { musicOffset = clampedValue } @@ -3685,7 +3689,35 @@ private struct LockScreenPositioningControls: View { } ) - LockScreenPositioningPreview(weatherOffset: weatherBinding, timerOffset: timerBinding, musicOffset: musicBinding) + let musicWidthBinding = Binding( + get: { musicWidth }, + set: { newValue in + let clampedValue = clamp(newValue, within: musicWidthRange) + if musicWidth != clampedValue { + musicWidth = clampedValue + propagateMusicWidthChange(animated: false) + } + } + ) + + let timerWidthBinding = Binding( + get: { timerWidth }, + set: { newValue in + let clampedValue = clamp(newValue, within: timerWidthRange) + if timerWidth != clampedValue { + timerWidth = clampedValue + propagateTimerWidthChange(animated: false) + } + } + ) + + LockScreenPositioningPreview( + weatherOffset: weatherBinding, + timerOffset: timerBinding, + musicOffset: musicBinding, + musicWidth: musicWidthBinding, + timerWidth: timerWidthBinding + ) .frame(height: 260) .padding(.vertical, 8) @@ -3719,18 +3751,45 @@ private struct LockScreenPositioningControls: View { Spacer() } + + Divider() + .padding(.vertical, 8) + + VStack(alignment: .leading, spacing: 16) { + widthSlider( + title: "Media Panel Width", + value: musicWidthBinding, + range: musicWidthRange, + resetTitle: "Reset Media Width", + resetAction: resetMusicWidth, + helpText: "Shrinks the lock screen media panel while keeping the expanded view full width." + ) + + widthSlider( + title: "Timer Widget Width", + value: timerWidthBinding, + range: timerWidthRange, + resetTitle: "Reset Timer Width", + resetAction: resetTimerWidth, + helpText: "Adjusts the lock screen timer widget width without affecting button sizing." + ) + } } header: { Text("Lock Screen Positioning") } footer: { - Text("Drag the previews to adjust vertical placement. Positive values lift the panel; negative values lower it. Changes apply instantly while the widgets are visible.") + Text("Drag the previews to adjust vertical placement. Positive values lift the panel; negative values lower it. Use the width sliders below to narrow the media and timer widgets without exceeding their default size. Changes apply instantly while the widgets are visible.") .textCase(nil) } } - private func clamp(_ value: Double) -> Double { + private func clampOffset(_ value: Double) -> Double { min(max(value, offsetRange.lowerBound), offsetRange.upperBound) } + private func clamp(_ value: Double, within range: ClosedRange) -> Double { + min(max(value, range.lowerBound), range.upperBound) + } + private func resetWeatherOffset() { weatherOffset = 0 propagateWeatherOffsetChange(animated: true) @@ -3746,6 +3805,16 @@ private struct LockScreenPositioningControls: View { propagateMusicOffsetChange(animated: true) } + private func resetMusicWidth() { + musicWidth = Double(LockScreenMusicPanel.defaultCollapsedWidth) + propagateMusicWidthChange(animated: true) + } + + private func resetTimerWidth() { + timerWidth = LockScreenTimerWidget.defaultWidth + propagateTimerWidthChange(animated: true) + } + private func propagateWeatherOffsetChange(animated: Bool) { Task { @MainActor in LockScreenWeatherPanelManager.shared.refreshPositionForOffsets(animated: animated) @@ -3764,6 +3833,18 @@ private struct LockScreenPositioningControls: View { } } + private func propagateMusicWidthChange(animated: Bool) { + Task { @MainActor in + LockScreenPanelManager.shared.applyOffsetAdjustment(animated: animated) + } + } + + private func propagateTimerWidthChange(animated: Bool) { + Task { @MainActor in + LockScreenTimerWidgetPanelManager.shared.refreshPosition(animated: animated) + } + } + @ViewBuilder private func offsetColumn(title: String, value: Double, resetTitle: String, resetAction: @escaping () -> Void) -> some View { VStack(alignment: .leading, spacing: 6) { @@ -3781,15 +3862,57 @@ private struct LockScreenPositioningControls: View { } } + @ViewBuilder + private func widthSlider( + title: String, + value: Binding, + range: ClosedRange, + resetTitle: String, + resetAction: @escaping () -> Void, + helpText: String + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(title) + Spacer() + Text(formattedWidth(value.wrappedValue)) + .font(.caption) + .foregroundStyle(.secondary) + } + + Slider(value: value, in: range) + + HStack(alignment: .top) { + Button(resetTitle) { + resetAction() + } + .buttonStyle(.bordered) + + Spacer() + + Text(helpText) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.trailing) + } + } + } + private func formattedPoints(_ value: Double) -> String { String(format: "%+.0f", value) } + + private func formattedWidth(_ value: Double) -> String { + String(format: "%.0f pt", value) + } } private struct LockScreenPositioningPreview: View { @Binding var weatherOffset: Double @Binding var timerOffset: Double @Binding var musicOffset: Double + @Binding var musicWidth: Double + @Binding var timerWidth: Double @State private var weatherStartOffset: Double = 0 @State private var timerStartOffset: Double = 0 @@ -3815,8 +3938,17 @@ private struct LockScreenPositioningPreview: View { let timerBaseY = screenRect.minY + (screenRect.height * 0.5) let musicBaseY = screenRect.minY + (screenRect.height * 0.78) let weatherSize = CGSize(width: screenRect.width * 0.42, height: screenRect.height * 0.22) - let timerSize = CGSize(width: screenRect.width * 0.5, height: screenRect.height * 0.2) - let musicSize = CGSize(width: screenRect.width * 0.56, height: screenRect.height * 0.34) + let defaultMusicWidth = Double(LockScreenMusicPanel.defaultCollapsedWidth) + let musicWidthScale = CGFloat(musicWidth / defaultMusicWidth) + let timerWidthScale = CGFloat(timerWidth / LockScreenTimerWidget.defaultWidth) + let timerSize = CGSize( + width: (screenRect.width * 0.5) * timerWidthScale, + height: screenRect.height * 0.2 + ) + let musicSize = CGSize( + width: (screenRect.width * 0.56) * musicWidthScale, + height: screenRect.height * 0.34 + ) ZStack { RoundedRectangle(cornerRadius: screenCornerRadius, style: .continuous) @@ -3844,6 +3976,9 @@ private struct LockScreenPositioningPreview: View { } .animation(.interactiveSpring(response: 0.3, dampingFraction: 0.82), value: weatherOffset) .animation(.interactiveSpring(response: 0.3, dampingFraction: 0.82), value: musicOffset) + .animation(.interactiveSpring(response: 0.3, dampingFraction: 0.82), value: timerOffset) + .animation(.interactiveSpring(response: 0.3, dampingFraction: 0.82), value: musicWidth) + .animation(.interactiveSpring(response: 0.3, dampingFraction: 0.82), value: timerWidth) } private func weatherPanel(size: CGSize) -> some View { diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift index 4c2283a..349587a 100644 --- a/DynamicIsland/managers/LockScreenPanelManager.swift +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -10,6 +10,7 @@ import AppKit import SkyLightWindow import Defaults import QuartzCore +import Combine @MainActor final class LockScreenPanelAnimator: ObservableObject { @@ -32,10 +33,12 @@ class LockScreenPanelManager { private var hideTask: Task? private var screenChangeObserver: NSObjectProtocol? private var workspaceObservers: [NSObjectProtocol] = [] + private var cancellables = Set() private init() { print("[\(timestamp())] LockScreenPanelManager: initialized") registerScreenChangeObservers() + observeDefaultChanges() } private func timestamp() -> String { @@ -118,8 +121,9 @@ class LockScreenPanelManager { panelAnimator.isPresented = false LockScreenTimerWidgetManager.shared.notifyMusicPanelFrameChanged(animated: false) - let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator)) - hosting.frame = NSRect(origin: .zero, size: targetFrame.size) + let hosting = NSHostingView(rootView: LockScreenMusicPanel(animator: panelAnimator)) + hosting.frame = NSRect(origin: .zero, size: targetFrame.size) + hosting.autoresizingMask = [.width, .height] window.contentView = hosting // Ensure the underlying window content is clipped to rounded corners @@ -235,6 +239,15 @@ class LockScreenPanelManager { print("[\(timestamp())] LockScreenPanelManager: realigned window due to \(reason)") } + private func observeDefaultChanges() { + Defaults.publisher(.lockScreenMusicPanelWidth) + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.applyOffsetAdjustment(animated: true) + } + .store(in: &cancellables) + } + private func collapsedFrame(for screenFrame: NSRect) -> NSRect { let collapsedSize = LockScreenMusicPanel.collapsedSize let originX = screenFrame.midX - (collapsedSize.width / 2) diff --git a/DynamicIsland/managers/LockScreenTimerWidgetManager.swift b/DynamicIsland/managers/LockScreenTimerWidgetManager.swift index 62cda9d..a91b0c8 100644 --- a/DynamicIsland/managers/LockScreenTimerWidgetManager.swift +++ b/DynamicIsland/managers/LockScreenTimerWidgetManager.swift @@ -75,6 +75,13 @@ final class LockScreenTimerWidgetManager { self.refreshPositionForOffsets(animated: true) } .store(in: &cancellables) + + Defaults.publisher(.lockScreenTimerWidgetWidth, options: []) + .receive(on: RunLoop.main) + .sink { _ in + LockScreenTimerWidgetPanelManager.shared.refreshPosition(animated: true) + } + .store(in: &cancellables) } private func updateVisibility() { @@ -114,6 +121,7 @@ final class LockScreenTimerWidgetPanelManager { let window = ensureWindow() let frame = targetFrame(on: screen) window.setFrame(frame, display: true) + updateHostingViewSize(for: window, size: frame.size) latestFrame = frame window.alphaValue = 1 window.orderFrontRegardless() @@ -161,6 +169,7 @@ final class LockScreenTimerWidgetPanelManager { } else { window.setFrame(frame, display: true) } + updateHostingViewSize(for: window, size: frame.size) latestFrame = frame LockScreenReminderWidgetPanelManager.shared.refreshPosition(animated: animated) } @@ -212,12 +221,17 @@ final class LockScreenTimerWidgetPanelManager { let view = LockScreenTimerWidget(animator: animator) let hosting = NSHostingView(rootView: view) hosting.frame = NSRect(origin: .zero, size: LockScreenTimerWidget.preferredSize) + hosting.autoresizingMask = [.width, .height] hosting.wantsLayer = true hosting.layer?.masksToBounds = true hosting.layer?.cornerRadius = LockScreenTimerWidget.cornerRadius return hosting } + private func updateHostingViewSize(for window: NSWindow, size: CGSize) { + window.contentView?.frame = NSRect(origin: .zero, size: NSSize(width: size.width, height: size.height)) + } + private func targetFrame(on screen: NSScreen) -> NSRect { let size = LockScreenTimerWidget.preferredSize let originX = screen.frame.midX - (size.width / 2) diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index b1aaf96..f5833d5 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -700,8 +700,10 @@ extension Defaults.Keys { static let lockScreenWeatherProviderSource = Key("lockScreenWeatherProviderSource", default: .openMeteo) static let lockScreenWeatherVerticalOffset = Key("lockScreenWeatherVerticalOffset", default: 0) static let lockScreenMusicVerticalOffset = Key("lockScreenMusicVerticalOffset", default: 0) + static let lockScreenMusicPanelWidth = Key("lockScreenMusicPanelWidth", default: 420) static let lockScreenMusicAlbumParallaxEnabled = Key("lockScreenMusicAlbumParallaxEnabled", default: false) static let lockScreenTimerVerticalOffset = Key("lockScreenTimerVerticalOffset", default: 0) + static let lockScreenTimerWidgetWidth = Key("lockScreenTimerWidgetWidth", default: 420) static let lockScreenGlassStyle = Key("lockScreenGlassStyle", default: .liquid) static let lockScreenGlassCustomizationMode = Key( "lockScreenGlassCustomizationMode",