Merge pull request #112 from Ebullioscopic/feature/lock-screen

Skip 10s buttons and Lock Screen Widget repositioning
This commit is contained in:
Hariharan Mudaliar 2025-11-20 23:41:25 +05:30 • committed by GitHub
commit ef18157857
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 994 additions and 260 deletions

View file

@ -207,6 +207,7 @@
}, },
"-%@" : { "-%@" : {
"comment" : "A label displaying the duration of the current song. The argument is the duration of the song in seconds.", "comment" : "A label displaying the duration of the current song. The argument is the duration of the song in seconds.",
"extractionState" : "stale",
"isCommentAutoGenerated" : true, "isCommentAutoGenerated" : true,
"localizations" : { "localizations" : {
"fr" : { "fr" : {
@ -751,6 +752,14 @@
} }
} }
}, },
"%@ Offset" : {
"comment" : "A control within the lock screen settings that allows the user to adjust the offset for a specific dimension (e.g. width or height). The argument is the name of the dimension, and the second argument is",
"isCommentAutoGenerated" : true
},
"%@ pt" : {
"comment" : "A text view displaying the current offset value for a specific lock screen control. The argument is the name of the control.",
"isCommentAutoGenerated" : true
},
"%@°" : { "%@°" : {
"comment" : "A label inside the temperature gauge that shows the current temperature. The value is shown in degrees Celsius (or Fahrenheit, if specified in the snapshot).", "comment" : "A label inside the temperature gauge that shows the current temperature. The value is shown in degrees Celsius (or Fahrenheit, if specified in the snapshot).",
"isCommentAutoGenerated" : true "isCommentAutoGenerated" : true
@ -7052,6 +7061,9 @@
} }
} }
} }
},
"Drag the previews to adjust vertical placement. Positive values lift the panel; negative values lower it. Changes apply instantly while the widgets are visible." : {
}, },
"Drop files here" : { "Drop files here" : {
"localizations" : { "localizations" : {
@ -10225,6 +10237,9 @@
} }
} }
} }
},
"Inline snapshot preview" : {
}, },
"Install updates automatically" : { "Install updates automatically" : {
"extractionState" : "stale", "extractionState" : "stale",
@ -10587,6 +10602,14 @@
}, },
"Lock screen material" : { "Lock screen material" : {
},
"Lock screen panel preview" : {
"comment" : "A description of the content displayed in the lock screen panel.",
"isCommentAutoGenerated" : true
},
"Lock Screen Positioning" : {
"comment" : "The header text for the \"Lock Screen Positioning\" section in the settings view.",
"isCommentAutoGenerated" : true
}, },
"Lock Screen Reminder Widget" : { "Lock Screen Reminder Widget" : {
"comment" : "A section header for the lock screen reminder widget settings.", "comment" : "A section header for the lock screen reminder widget settings.",
@ -17135,6 +17158,14 @@
} }
} }
}, },
"Skip behaviour" : {
"comment" : "A section header that describes the skip behaviour settings.",
"isCommentAutoGenerated" : true
},
"Skip buttons" : {
"comment" : "A section header that lists the skip behaviour options.",
"isCommentAutoGenerated" : true
},
"Slider color" : { "Slider color" : {
"localizations" : { "localizations" : {
"ar" : { "ar" : {
@ -19766,6 +19797,10 @@
} }
} }
}, },
"Weather" : {
"comment" : "A label and its accompanying text displayed in a lock screen panel.",
"isCommentAutoGenerated" : true
},
"Weather data provider" : { "Weather data provider" : {
}, },

View file

@ -17,6 +17,8 @@ struct HoverButton: View {
@State private var isHovering = false @State private var isHovering = false
@State private var pressOffset: CGFloat = 0 @State private var pressOffset: CGFloat = 0
@State private var wiggleAngle: Double = 0
@State private var wiggleToken: Int = 0
var body: some View { var body: some View {
let size = CGFloat(scale == .large ? 40 : 30) let size = CGFloat(scale == .large ? 40 : 30)
@ -34,15 +36,31 @@ struct HoverButton: View {
.fill(isHovering ? Color.gray.opacity(0.2) : .clear) .fill(isHovering ? Color.gray.opacity(0.2) : .clear)
.frame(width: size, height: size) .frame(width: size, height: size)
.overlay { .overlay {
Image(systemName: icon) let baseImage = Image(systemName: icon)
.foregroundColor(iconColor) .foregroundColor(iconColor)
.contentTransition(contentTransition) .contentTransition(contentTransition)
.font(scale == .large ? .largeTitle : .body) .font(scale == .large ? .largeTitle : .body)
if case .wiggle = pressEffect {
if #available(macOS 14.0, *) {
baseImage
.symbolEffect(
.wiggle.byLayer,
options: .nonRepeating,
value: wiggleToken
)
} else {
baseImage
}
} else {
baseImage
}
} }
} }
} }
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
.offset(x: pressOffset) .offset(x: pressOffset)
.rotationEffect(.degrees(wiggleAngle))
.onHover { hovering in .onHover { hovering in
withAnimation(.smooth(duration: 0.3)) { withAnimation(.smooth(duration: 0.3)) {
isHovering = hovering isHovering = hovering
@ -63,10 +81,30 @@ struct HoverButton: View {
pressOffset = 0 pressOffset = 0
} }
} }
case .wiggle(let direction):
guard #available(macOS 14.0, *) else { return }
wiggleToken += 1
let angle: Double = direction == .clockwise ? 10 : -10
withAnimation(.spring(response: 0.18, dampingFraction: 0.5)) {
wiggleAngle = angle
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
withAnimation(.spring(response: 0.32, dampingFraction: 0.72)) {
wiggleAngle = 0
}
}
} }
} }
enum PressEffect { enum PressEffect {
case nudge(CGFloat) case nudge(CGFloat)
case wiggle(WiggleDirection)
}
enum WiggleDirection {
case clockwise
case counterClockwise
} }
} }

View file

@ -18,6 +18,7 @@ struct LockScreenMusicPanel: View {
@ObservedObject private var animator: LockScreenPanelAnimator @ObservedObject private var animator: LockScreenPanelAnimator
@State private var sliderValue: Double = 0 @State private var sliderValue: Double = 0
@State private var dragging: Bool = false @State private var dragging: Bool = false
@State private var lastDragged: Date = .distantPast
@State private var isActive = true @State private var isActive = true
@State private var isExpanded = false @State private var isExpanded = false
@State private var isVolumeSliderVisible = false @State private var isVolumeSliderVisible = false
@ -30,6 +31,7 @@ struct LockScreenMusicPanel: View {
@Default(.showShuffleAndRepeat) var showShuffleAndRepeat @Default(.showShuffleAndRepeat) var showShuffleAndRepeat
@Default(.musicAuxLeftControl) private var leftAuxControl @Default(.musicAuxLeftControl) private var leftAuxControl
@Default(.musicAuxRightControl) private var rightAuxControl @Default(.musicAuxRightControl) private var rightAuxControl
@Default(.musicSkipBehavior) private var musicSkipBehavior
@Default(.enableLyrics) private var enableLyrics @Default(.enableLyrics) private var enableLyrics
init(animator: LockScreenPanelAnimator) { init(animator: LockScreenPanelAnimator) {
@ -286,65 +288,31 @@ struct LockScreenMusicPanel: View {
minimumInterval: (!musicManager.isLiveStream && musicManager.playbackRate > 0) ? 0.1 : nil minimumInterval: (!musicManager.isLiveStream && musicManager.playbackRate > 0) ? 0.1 : nil
) )
) { timeline in ) { timeline in
let progressValue = dragging ? sliderValue : currentSliderValue(timeline.date) MusicSliderView(
sliderValue: $sliderValue,
if musicManager.isLiveStream { duration: Binding(
LiveStreamProgressIndicator(tint: sliderColor) get: { musicManager.songDuration },
.frame(maxWidth: .infinity) set: { musicManager.songDuration = $0 }
} else {
HStack(spacing: 8) {
// Elapsed time
Text(formatTime(progressValue))
.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 * (progressValue / max(musicManager.songDuration, 1))
), ),
height: 6 lastDragged: $lastDragged,
) color: musicManager.avgColor,
} dragging: $dragging,
.gesture( currentDate: timeline.date,
DragGesture(minimumDistance: 0) timestampDate: musicManager.timestampDate,
.onChanged { value in elapsedTime: musicManager.elapsedTime,
playbackRate: musicManager.playbackRate,
isPlaying: musicManager.isPlaying,
isLiveStream: musicManager.isLiveStream,
onValueChange: { newValue in
registerInteraction() registerInteraction()
dragging = true musicManager.seek(to: newValue)
let duration = max(musicManager.songDuration, 0.0001) },
let clampedX = min(max(value.location.x, 0), geometry.size.width) labelLayout: .inline,
let newValue = Double(clampedX / geometry.size.width) * duration trailingLabel: .remaining,
sliderValue = min(max(0, newValue), musicManager.songDuration) restingTrackHeight: 7,
} draggingTrackHeight: 11
.onEnded { _ in
registerInteraction()
musicManager.seek(to: sliderValue)
dragging = false
}
) )
} }
.frame(height: 6)
// Time remaining
Text("-\(formatTime(max(musicManager.songDuration - progressValue, 0)))")
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
.frame(width: 48, alignment: .trailing)
}
}
}
.onAppear { .onAppear {
sliderValue = musicManager.elapsedTime sliderValue = musicManager.elapsedTime
} }
@ -356,24 +324,6 @@ struct LockScreenMusicPanel: View {
} }
} }
private func currentSliderValue(_ date: Date) -> Double {
if dragging {
return sliderValue
}
if musicManager.isLiveStream {
return 0
}
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 { private var sliderColor: Color {
switch Defaults[.sliderColor] { switch Defaults[.sliderColor] {
case .white: case .white:
@ -385,12 +335,6 @@ struct LockScreenMusicPanel: View {
} }
} }
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 // MARK: - Playback Controls
private func playbackControls(alignment: Alignment) -> some View { private func playbackControls(alignment: Alignment) -> some View {
@ -419,21 +363,63 @@ struct LockScreenMusicPanel: View {
private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View { private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View {
let controls = resolvedAuxControls let controls = resolvedAuxControls
let skipNudge: CGFloat = isExpanded ? 14 : 9
let seekInterval: TimeInterval = 10
let backwardConfig: (icon: String, interaction: PanelControlButton.Interaction, symbol: PanelControlButton.SymbolEffectStyle, action: () -> Void)
let forwardConfig: (icon: String, interaction: PanelControlButton.Interaction, symbol: PanelControlButton.SymbolEffectStyle, action: () -> Void)
switch musicSkipBehavior {
case .track:
backwardConfig = (
icon: "backward.fill",
interaction: .nudge(-skipNudge),
symbol: .replace,
action: { musicManager.previousTrack() }
)
forwardConfig = (
icon: "forward.fill",
interaction: .nudge(skipNudge),
symbol: .replace,
action: { musicManager.nextTrack() }
)
case .tenSecond:
backwardConfig = (
icon: "gobackward.10",
interaction: .wiggle(.counterClockwise),
symbol: .wiggle,
action: { musicManager.seek(by: -seekInterval) }
)
forwardConfig = (
icon: "goforward.10",
interaction: .wiggle(.clockwise),
symbol: .wiggle,
action: { musicManager.seek(by: seekInterval) }
)
}
return HStack(spacing: spacing) { return HStack(spacing: spacing) {
if let leftControl = controls.left { if let leftControl = controls.left {
auxButton(for: leftControl) auxButton(for: leftControl)
} }
controlButton(icon: "backward.fill", size: 18) { controlButton(
musicManager.previousTrack() icon: backwardConfig.icon,
} size: 18,
interaction: backwardConfig.interaction,
symbolEffect: backwardConfig.symbol,
action: backwardConfig.action
)
playPauseButton playPauseButton
controlButton(icon: "forward.fill", size: 18) { controlButton(
musicManager.nextTrack() icon: forwardConfig.icon,
} size: 18,
interaction: forwardConfig.interaction,
symbolEffect: forwardConfig.symbol,
action: forwardConfig.action
)
if let rightControl = controls.right { if let rightControl = controls.right {
auxButton(for: rightControl) auxButton(for: rightControl)
@ -443,20 +429,19 @@ struct LockScreenMusicPanel: View {
} }
private var playPauseButton: some View { private var playPauseButton: some View {
let frameSize: CGFloat = isExpanded ? 72 : 48 let frameSize: CGFloat = isExpanded ? 80 : 54
let symbolSize: CGFloat = isExpanded ? 30 : 24 let iconName = musicManager.isPlaying ? "pause.fill" : "play.fill"
return Button(action: { return HoverButton(
icon: iconName,
iconColor: .white,
scale: .large,
pressEffect: nil
) {
registerInteraction() registerInteraction()
musicManager.togglePlay() musicManager.togglePlay()
}) {
Image(systemName: musicManager.isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: symbolSize, weight: .semibold))
.foregroundColor(.white)
.frame(width: frameSize, height: frameSize)
.contentShape(Rectangle())
} }
.buttonStyle(PlainButtonStyle()) .frame(width: frameSize, height: frameSize)
} }
private func controlButton( private func controlButton(
@ -464,37 +449,44 @@ struct LockScreenMusicPanel: View {
size: CGFloat = 18, size: CGFloat = 18,
isActive: Bool = false, isActive: Bool = false,
activeColor: Color = .red, activeColor: Color = .red,
interaction: PanelControlButton.Interaction = .none,
symbolEffect: PanelControlButton.SymbolEffectStyle = .replace,
action: @escaping () -> Void action: @escaping () -> Void
) -> some View { ) -> some View {
let frameSize: CGFloat = isExpanded ? 56 : 32 let frameSize: CGFloat = isExpanded ? 56 : 32
let iconSize: CGFloat = isExpanded ? max(size, 24) : size let iconSize: CGFloat = isExpanded ? max(size, 24) : size
let iconColor = isActive ? activeColor : .white.opacity(0.8)
let backgroundOpacity: Double = isActive ? 0.22 : 0.0
return Button(action: { return PanelControlButton(
icon: icon,
frameSize: frameSize,
iconSize: iconSize,
iconColor: iconColor,
backgroundOpacity: backgroundOpacity,
interaction: interaction,
symbolEffect: symbolEffect
) {
registerInteraction() registerInteraction()
action() action()
}) {
Image(systemName: icon)
.font(.system(size: iconSize, weight: .medium))
.foregroundColor(isActive ? activeColor : .white.opacity(0.8))
.frame(width: frameSize, height: frameSize)
.contentShape(Rectangle())
} }
.buttonStyle(PlainButtonStyle())
} }
private var mediaOutputControlButton: some View { private var mediaOutputControlButton: some View {
let frameSize: CGFloat = isExpanded ? 56 : 32 let frameSize: CGFloat = isExpanded ? 56 : 32
let iconSize: CGFloat = isExpanded ? 26 : 18 let iconSize: CGFloat = isExpanded ? 26 : 18
return Button(action: toggleVolumeSlider) { return PanelControlButton(
Image(systemName: mediaOutputIcon) icon: mediaOutputIcon,
.font(.system(size: iconSize, weight: .medium)) frameSize: frameSize,
.foregroundColor(shouldShowVolumeSlider ? .accentColor : .white.opacity(0.8)) iconSize: iconSize,
.frame(width: frameSize, height: frameSize) iconColor: shouldShowVolumeSlider ? .accentColor : .white.opacity(0.8),
.contentShape(Rectangle()) backgroundOpacity: shouldShowVolumeSlider ? 0.22 : 0.0,
} interaction: .none,
symbolEffect: .replace,
action: toggleVolumeSlider
)
.accessibilityLabel("Media output") .accessibilityLabel("Media output")
.buttonStyle(PlainButtonStyle())
} }
private func lyricsSection(alignment: Alignment) -> some View { private func lyricsSection(alignment: Alignment) -> some View {
@ -791,3 +783,123 @@ struct LockScreenMusicPanel: View {
print("[\(formatter.string(from: Date()))] LockScreenMusicPanel: \(event) – \(styleDescriptor)") print("[\(formatter.string(from: Date()))] LockScreenMusicPanel: \(event) – \(styleDescriptor)")
} }
} }
private struct PanelControlButton: View {
let icon: String
let frameSize: CGFloat
let iconSize: CGFloat
let iconColor: Color
let backgroundOpacity: Double
let interaction: Interaction
let symbolEffect: SymbolEffectStyle
let action: () -> Void
@State private var isHovering = false
@State private var pressOffset: CGFloat = 0
@State private var rotationAngle: Double = 0
@State private var wiggleToken: Int = 0
var body: some View {
Button(action: {
triggerPressEffect()
action()
}) {
RoundedRectangle(cornerRadius: frameSize / 2, style: .continuous)
.fill(backgroundColor)
.overlay(
iconView
)
}
.frame(width: frameSize, height: frameSize)
.buttonStyle(PlainButtonStyle())
.offset(x: pressOffset)
.rotationEffect(.degrees(rotationAngle))
.onHover { hovering in
withAnimation(.smooth(duration: 0.24)) {
isHovering = hovering
}
}
}
private var backgroundColor: Color {
let hoveredOpacity = max(backgroundOpacity + 0.08, 0.18)
let appliedOpacity = isHovering ? hoveredOpacity : backgroundOpacity
return Color.white.opacity(min(appliedOpacity, 0.32))
}
private func triggerPressEffect() {
switch interaction {
case .none:
return
case .nudge(let amount):
withAnimation(.spring(response: 0.2, dampingFraction: 0.55)) {
pressOffset = amount
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
withAnimation(.spring(response: 0.28, dampingFraction: 0.72)) {
pressOffset = 0
}
}
case .wiggle(let direction):
guard #available(macOS 14.0, *) else { return }
wiggleToken += 1
let angle: Double = direction == .clockwise ? 10 : -10
withAnimation(.spring(response: 0.18, dampingFraction: 0.5)) {
rotationAngle = angle
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation(.spring(response: 0.32, dampingFraction: 0.75)) {
rotationAngle = 0
}
}
}
}
@ViewBuilder
private var iconView: some View {
let base = Image(systemName: icon)
.font(.system(size: iconSize, weight: .medium))
.foregroundStyle(iconColor)
switch symbolEffect {
case .replace:
base.contentTransition(.symbolEffect(.replace))
case .replaceAndBounce:
if #available(macOS 14.0, *) {
base
.contentTransition(.symbolEffect(.replace))
.symbolEffect(.bounce, value: icon)
} else {
base.contentTransition(.symbolEffect(.replace))
}
case .wiggle:
if #available(macOS 14.0, *) {
base
.contentTransition(.symbolEffect(.replace))
.symbolEffect(.wiggle.byLayer, options: .nonRepeating, value: wiggleToken)
} else {
base.contentTransition(.symbolEffect(.replace))
}
}
}
enum Interaction {
case none
case nudge(CGFloat)
case wiggle(WiggleDirection)
}
enum SymbolEffectStyle {
case replace
case replaceAndBounce
case wiggle
}
enum WiggleDirection {
case clockwise
case counterClockwise
}
}

View file

@ -19,6 +19,7 @@ struct MinimalisticMusicPlayerView: View {
let albumArtNamespace: Namespace.ID let albumArtNamespace: Namespace.ID
@Default(.musicAuxLeftControl) private var leftAuxControl @Default(.musicAuxLeftControl) private var leftAuxControl
@Default(.musicAuxRightControl) private var rightAuxControl @Default(.musicAuxRightControl) private var rightAuxControl
@Default(.musicSkipBehavior) private var musicSkipBehavior
@ObservedObject private var reminderManager = ReminderLiveActivityManager.shared @ObservedObject private var reminderManager = ReminderLiveActivityManager.shared
@Default(.enableReminderLiveActivity) private var enableReminderLiveActivity @Default(.enableReminderLiveActivity) private var enableReminderLiveActivity
@Default(.enableLyrics) private var enableLyrics @Default(.enableLyrics) private var enableLyrics
@ -507,115 +508,107 @@ private struct MinimalisticReminderDetailsView: View {
@State private var lastDragged: Date = .distantPast @State private var lastDragged: Date = .distantPast
private var progressBar: some View { private var progressBar: some View {
TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in TimelineView(
if musicManager.isLiveStream { .animation(
HStack(spacing: 8) { minimumInterval: (!musicManager.isLiveStream && musicManager.playbackRate > 0) ? 0.1 : nil
Spacer() )
.frame(width: 42) ) { timeline in
LiveStreamProgressIndicator(tint: sliderColor) MusicSliderView(
.frame(maxWidth: .infinity, minHeight: 6, maxHeight: 6) sliderValue: $sliderValue,
Spacer() duration: Binding(
.frame(width: 48) get: { musicManager.songDuration },
} set: { musicManager.songDuration = $0 }
.allowsHitTesting(false) ),
} else { lastDragged: $lastDragged,
let currentElapsed = currentSliderValue(timeline.date) color: musicManager.avgColor,
dragging: $dragging,
HStack(spacing: 8) { currentDate: timeline.date,
Text(formatTime(dragging ? sliderValue : currentElapsed)) timestampDate: musicManager.timestampDate,
.font(.system(size: 11, weight: .medium, design: .monospaced)) elapsedTime: musicManager.elapsedTime,
.foregroundColor(.white.opacity(0.7)) playbackRate: musicManager.playbackRate,
.frame(width: 42, alignment: .leading) isPlaying: musicManager.isPlaying,
isLiveStream: musicManager.isLiveStream,
GeometryReader { geometry in onValueChange: { newValue in
ZStack(alignment: .leading) { musicManager.seek(to: newValue)
RoundedRectangle(cornerRadius: 3) },
.fill(Color.white.opacity(0.2)) labelLayout: .inline,
.frame(height: 6) trailingLabel: .remaining,
restingTrackHeight: 7,
RoundedRectangle(cornerRadius: 3) draggingTrackHeight: 11
.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)
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 { .onAppear {
sliderValue = musicManager.elapsedTime sliderValue = musicManager.elapsedTime
} }
} .onChange(of: musicManager.isLiveStream) { _, isLive in
if isLive {
private func currentSliderValue(_ date: Date) -> Double { dragging = false
if dragging { sliderValue = 0
return sliderValue
}
// Update slider value based on playback
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 (Larger) // MARK: - Playback Controls (Larger)
private var playbackControls: some View { private var playbackControls: some View {
let controls = resolvedAuxControls let controls = resolvedAuxControls
let seekInterval: TimeInterval = 10
let skipPressMagnitude: CGFloat = 8
let backwardConfig: (icon: String, press: MinimalisticSquircircleButton.PressEffect, symbol: MinimalisticSquircircleButton.SymbolEffectStyle, action: () -> Void)
let forwardConfig: (icon: String, press: MinimalisticSquircircleButton.PressEffect, symbol: MinimalisticSquircircleButton.SymbolEffectStyle, action: () -> Void)
switch musicSkipBehavior {
case .track:
backwardConfig = (
icon: "backward.fill",
press: .nudge(-skipPressMagnitude),
symbol: .replace,
action: { musicManager.previousTrack() }
)
forwardConfig = (
icon: "forward.fill",
press: .nudge(skipPressMagnitude),
symbol: .replace,
action: { musicManager.nextTrack() }
)
case .tenSecond:
backwardConfig = (
icon: "gobackward.10",
press: .wiggle(.counterClockwise),
symbol: .wiggle,
action: { musicManager.seek(by: -seekInterval) }
)
forwardConfig = (
icon: "goforward.10",
press: .wiggle(.clockwise),
symbol: .wiggle,
action: { musicManager.seek(by: seekInterval) }
)
}
return HStack(spacing: 16) { return HStack(spacing: 16) {
if Defaults[.showShuffleAndRepeat] { if Defaults[.showShuffleAndRepeat] {
auxButton(for: controls.left) auxButton(for: controls.left)
} }
controlButton(icon: "backward.fill", size: 18, pressEffect: .nudge(-8)) { controlButton(
Task { await musicManager.previousTrack() } icon: backwardConfig.icon,
} size: 18,
pressEffect: backwardConfig.press,
symbolEffect: backwardConfig.symbol,
action: backwardConfig.action
)
playPauseButton playPauseButton
controlButton(icon: "forward.fill", size: 18, pressEffect: .nudge(8)) { controlButton(
Task { await musicManager.nextTrack() } icon: forwardConfig.icon,
} size: 18,
pressEffect: forwardConfig.press,
symbolEffect: forwardConfig.symbol,
action: forwardConfig.action
)
if Defaults[.showShuffleAndRepeat] { if Defaults[.showShuffleAndRepeat] {
auxButton(for: controls.right) auxButton(for: controls.right)
@ -628,15 +621,15 @@ private struct MinimalisticReminderDetailsView: View {
private var playPauseButton: some View { private var playPauseButton: some View {
MinimalisticSquircircleButton( MinimalisticSquircircleButton(
icon: musicManager.isPlaying ? "pause.fill" : "play.fill", icon: musicManager.isPlaying ? "pause.fill" : "play.fill",
fontSize: 24, fontSize: 28,
fontWeight: .semibold, fontWeight: .semibold,
frameSize: CGSize(width: 54, height: 54), frameSize: CGSize(width: 60, height: 60),
cornerRadius: 20, cornerRadius: 24,
foregroundColor: .white, foregroundColor: .white,
pressEffect: .none, pressEffect: .none,
symbolEffectStyle: .replace, symbolEffectStyle: .replace,
action: { action: {
Task { await musicManager.togglePlay() } musicManager.togglePlay()
} }
) )
} }
@ -826,6 +819,8 @@ private struct MinimalisticSquircircleButton: View {
@State private var isHovering = false @State private var isHovering = false
@State private var pressOffset: CGFloat = 0 @State private var pressOffset: CGFloat = 0
@State private var rotationAngle: Double = 0
@State private var wiggleToken: Int = 0
init( init(
icon: String, icon: String,
@ -864,6 +859,7 @@ private struct MinimalisticSquircircleButton: View {
} }
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
.offset(x: pressOffset) .offset(x: pressOffset)
.rotationEffect(.degrees(rotationAngle))
.onHover { hovering in .onHover { hovering in
withAnimation(.easeOut(duration: 0.18)) { withAnimation(.easeOut(duration: 0.18)) {
isHovering = hovering isHovering = hovering
@ -872,8 +868,10 @@ private struct MinimalisticSquircircleButton: View {
} }
private func triggerPressEffect() { private func triggerPressEffect() {
guard case let .nudge(amount) = pressEffect else { return } switch pressEffect {
case .none:
return
case .nudge(let amount):
withAnimation(.spring(response: 0.16, dampingFraction: 0.72)) { withAnimation(.spring(response: 0.16, dampingFraction: 0.72)) {
pressOffset = amount pressOffset = amount
} }
@ -883,6 +881,21 @@ private struct MinimalisticSquircircleButton: View {
pressOffset = 0 pressOffset = 0
} }
} }
case .wiggle(let direction):
guard #available(macOS 14.0, *) else { return }
wiggleToken += 1
let angle: Double = direction == .clockwise ? 11 : -11
withAnimation(.spring(response: 0.18, dampingFraction: 0.52)) {
rotationAngle = angle
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
withAnimation(.spring(response: 0.32, dampingFraction: 0.76)) {
rotationAngle = 0
}
}
}
} }
@ViewBuilder @ViewBuilder
@ -896,9 +909,7 @@ private struct MinimalisticSquircircleButton: View {
image image
case .replace: case .replace:
if #available(macOS 14.0, *) { if #available(macOS 14.0, *) {
image image.contentTransition(.symbolEffect(.replace))
.contentTransition(.symbolEffect(.replace))
.id(icon)
} else { } else {
image image
} }
@ -908,17 +919,43 @@ private struct MinimalisticSquircircleButton: View {
} else { } else {
image image
} }
case .replaceAndBounce:
if #available(macOS 14.0, *) {
image
.contentTransition(.symbolEffect(.replace))
.symbolEffect(.bounce, value: icon)
} else {
image
}
case .wiggle:
if #available(macOS 14.0, *) {
image.symbolEffect(
.wiggle.byLayer,
options: .nonRepeating,
value: wiggleToken
)
} else {
image
}
} }
} }
enum PressEffect { enum PressEffect {
case none case none
case nudge(CGFloat) case nudge(CGFloat)
case wiggle(WiggleDirection)
} }
enum SymbolEffectStyle { enum SymbolEffectStyle {
case none case none
case replace case replace
case bounce case bounce
case replaceAndBounce
case wiggle
}
enum WiggleDirection {
case clockwise
case counterClockwise
} }
} }

View file

@ -122,6 +122,7 @@ struct MusicControlsView: View {
let showShuffleAndRepeat: Bool let showShuffleAndRepeat: Bool
@Default(.musicAuxLeftControl) private var leftAuxControl @Default(.musicAuxLeftControl) private var leftAuxControl
@Default(.musicAuxRightControl) private var rightAuxControl @Default(.musicAuxRightControl) private var rightAuxControl
@Default(.musicSkipBehavior) private var musicSkipBehavior
@Default(.enableLyrics) private var enableLyrics @Default(.enableLyrics) private var enableLyrics
var body: some View { var body: some View {
@ -214,20 +215,62 @@ struct MusicControlsView: View {
private var playbackControls: some View { private var playbackControls: some View {
let controls = resolvedAuxControls let controls = resolvedAuxControls
let seekInterval: TimeInterval = 10
let skipMagnitude: CGFloat = 6
let backwardConfig: (icon: String, press: HoverButton.PressEffect?, action: () -> Void)
let forwardConfig: (icon: String, press: HoverButton.PressEffect?, action: () -> Void)
switch musicSkipBehavior {
case .track:
backwardConfig = (
icon: "backward.fill",
press: .nudge(-skipMagnitude),
action: { musicManager.previousTrack() }
)
forwardConfig = (
icon: "forward.fill",
press: .nudge(skipMagnitude),
action: { musicManager.nextTrack() }
)
case .tenSecond:
backwardConfig = (
icon: "gobackward.10",
press: .wiggle(.counterClockwise),
action: { musicManager.seek(by: -seekInterval) }
)
forwardConfig = (
icon: "goforward.10",
press: .wiggle(.clockwise),
action: { musicManager.seek(by: seekInterval) }
)
}
return HStack(spacing: 8) { return HStack(spacing: 8) {
if showShuffleAndRepeat { if showShuffleAndRepeat {
auxButton(for: controls.left) auxButton(for: controls.left)
} }
HoverButton(icon: "backward.fill", scale: .medium, pressEffect: .nudge(-6)) {
MusicManager.shared.previousTrack() HoverButton(
icon: backwardConfig.icon,
scale: .medium,
pressEffect: backwardConfig.press
) {
backwardConfig.action()
} }
HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) { HoverButton(icon: musicManager.isPlaying ? "pause.fill" : "play.fill", scale: .large) {
MusicManager.shared.togglePlay() MusicManager.shared.togglePlay()
} }
HoverButton(icon: "forward.fill", scale: .medium, pressEffect: .nudge(6)) {
MusicManager.shared.nextTrack() HoverButton(
icon: forwardConfig.icon,
scale: .medium,
pressEffect: forwardConfig.press
) {
forwardConfig.action()
} }
if showShuffleAndRepeat { if showShuffleAndRepeat {
auxButton(for: controls.right) auxButton(for: controls.right)
} }
@ -361,21 +404,32 @@ struct MusicSliderView: View {
let isPlaying: Bool let isPlaying: Bool
let isLiveStream: Bool let isLiveStream: Bool
var onValueChange: (Double) -> Void var onValueChange: (Double) -> Void
var labelLayout: TimeLabelLayout = .stacked
var trailingLabel: TrailingLabel = .duration
var restingTrackHeight: CGFloat = 5
var draggingTrackHeight: CGFloat = 9
enum TimeLabelLayout {
case stacked
case inline
}
enum TrailingLabel {
case duration
case remaining
}
var body: some View { var body: some View {
VStack { Group {
sliderContent if isLiveStream {
.frame(height: 10, alignment: .center) liveStreamView
if !isLiveStream { } else {
HStack { switch labelLayout {
Text(timeString(from: sliderValue)) case .stacked:
Spacer() stackedContent
Text(timeString(from: duration)) case .inline:
inlineContent
} }
.fontWeight(.medium)
.foregroundColor(timeLabelColor)
.font(.caption)
} }
} }
.onChange(of: currentDate) { newDate in .onChange(of: currentDate) { newDate in
@ -390,22 +444,72 @@ struct MusicSliderView: View {
} }
} }
private var stackedContent: some View {
VStack(spacing: 6) {
sliderCore
.frame(height: sliderFrameHeight)
HStack {
Text(timeString(from: sliderValue))
Spacer()
Text(trailingTimeText)
}
.fontWeight(.medium)
.foregroundColor(timeLabelColor)
.font(.caption)
}
}
private var inlineContent: some View {
HStack(spacing: 10) {
Text(timeString(from: sliderValue))
.font(inlineLabelFont)
.foregroundColor(timeLabelColor)
.frame(width: 42, alignment: .leading)
sliderCore
.frame(height: sliderFrameHeight)
.frame(maxWidth: .infinity)
Text(trailingTimeText)
.font(inlineLabelFont)
.foregroundColor(timeLabelColor)
.frame(width: 48, alignment: .trailing)
}
}
@ViewBuilder @ViewBuilder
private var sliderContent: some View { private var liveStreamView: some View {
if isLiveStream { switch labelLayout {
case .stacked:
LiveStreamProgressIndicator(tint: sliderTint) LiveStreamProgressIndicator(tint: sliderTint)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} else { .frame(height: sliderFrameHeight)
case .inline:
HStack(spacing: 10) {
Spacer()
.frame(width: 42)
LiveStreamProgressIndicator(tint: sliderTint)
.frame(maxWidth: .infinity)
.frame(height: sliderFrameHeight)
Spacer()
.frame(width: 48)
}
}
}
private var sliderCore: some View {
CustomSlider( CustomSlider(
value: $sliderValue, value: $sliderValue,
range: 0 ... duration, range: 0 ... duration,
color: sliderTint, color: sliderTint,
dragging: $dragging, dragging: $dragging,
lastDragged: $lastDragged, lastDragged: $lastDragged,
onValueChange: onValueChange onValueChange: onValueChange,
restingTrackHeight: restingTrackHeight,
draggingTrackHeight: draggingTrackHeight
) )
} }
}
private var sliderTint: Color { private var sliderTint: Color {
switch Defaults[.sliderColor] { switch Defaults[.sliderColor] {
@ -424,6 +528,24 @@ struct MusicSliderView: View {
: .gray : .gray
} }
private var trailingTimeText: String {
switch trailingLabel {
case .duration:
return timeString(from: duration)
case .remaining:
let remaining = max(duration - sliderValue, 0)
return "-" + timeString(from: remaining)
}
}
private var inlineLabelFont: Font {
.system(size: 11, weight: .medium, design: .monospaced)
}
private var sliderFrameHeight: CGFloat {
max(restingTrackHeight, draggingTrackHeight)
}
func timeString(from seconds: Double) -> String { func timeString(from seconds: Double) -> String {
let totalMinutes = Int(seconds) / 60 let totalMinutes = Int(seconds) / 60
let remainingSeconds = Int(seconds) % 60 let remainingSeconds = Int(seconds) % 60
@ -448,11 +570,13 @@ struct CustomSlider: View {
@Binding var lastDragged: Date @Binding var lastDragged: Date
var onValueChange: ((Double) -> Void)? var onValueChange: ((Double) -> Void)?
var thumbSize: CGFloat = 12 var thumbSize: CGFloat = 12
var restingTrackHeight: CGFloat = 5
var draggingTrackHeight: CGFloat = 9
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
let width = geometry.size.width let width = geometry.size.width
let height = CGFloat(dragging ? 9 : 5) let trackHeight = CGFloat(dragging ? draggingTrackHeight : restingTrackHeight)
let rangeSpan = range.upperBound - range.lowerBound let rangeSpan = range.upperBound - range.lowerBound
let progress = rangeSpan == .zero ? 0 : (value - range.lowerBound) / rangeSpan let progress = rangeSpan == .zero ? 0 : (value - range.lowerBound) / rangeSpan
@ -462,15 +586,15 @@ struct CustomSlider: View {
// Background track // Background track
Rectangle() Rectangle()
.fill(.gray.opacity(0.3)) .fill(.gray.opacity(0.3))
.frame(height: height) .frame(height: trackHeight)
// Filled track // Filled track
Rectangle() Rectangle()
.fill(color) .fill(color)
.frame(width: filledTrackWidth, height: height) .frame(width: filledTrackWidth, height: trackHeight)
} }
.cornerRadius(height / 2) .cornerRadius(trackHeight / 2)
.frame(height: 10) .frame(height: max(restingTrackHeight, draggingTrackHeight))
.contentShape(Rectangle()) .contentShape(Rectangle())
.highPriorityGesture( .highPriorityGesture(
DragGesture(minimumDistance: 0) DragGesture(minimumDistance: 0)

View file

@ -857,6 +857,7 @@ struct Media: View {
@Default(.showShuffleAndRepeat) private var showShuffleAndRepeat @Default(.showShuffleAndRepeat) private var showShuffleAndRepeat
@Default(.musicAuxLeftControl) private var musicAuxLeftControl @Default(.musicAuxLeftControl) private var musicAuxLeftControl
@Default(.musicAuxRightControl) private var musicAuxRightControl @Default(.musicAuxRightControl) private var musicAuxRightControl
@Default(.musicSkipBehavior) private var musicSkipBehavior
@State private var previousLeftAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxLeftControl] @State private var previousLeftAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxLeftControl]
@State private var previousRightAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxRightControl] @State private var previousRightAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxRightControl]
@ -918,6 +919,20 @@ struct Media: View {
} header: { } header: {
Text("Media controls") Text("Media controls")
} }
Section {
Picker("Skip buttons", selection: $musicSkipBehavior) {
ForEach(MusicSkipBehavior.allCases) { behavior in
Text(behavior.displayName).tag(behavior)
}
}
.pickerStyle(.segmented)
Text(musicSkipBehavior.description)
.font(.caption)
.foregroundStyle(.secondary)
} header: {
Text("Skip behaviour")
}
Section { Section {
Toggle( Toggle(
"Enable music live activity", "Enable music live activity",
@ -1436,6 +1451,8 @@ struct Appearance: View {
Text("General") Text("General")
} }
LockScreenPositioningControls()
Section { Section {
Defaults.Toggle("Enable colored spectrograms", key: .coloredSpectrogram) Defaults.Toggle("Enable colored spectrograms", key: .coloredSpectrogram)
Defaults Defaults
@ -1693,6 +1710,272 @@ struct Appearance: View {
} }
} }
private struct LockScreenPositioningControls: View {
@Default(.lockScreenWeatherVerticalOffset) private var weatherOffset
@Default(.lockScreenMusicVerticalOffset) private var musicOffset
private let offsetRange: ClosedRange<Double> = -160...160
var body: some View {
Section {
let weatherBinding = Binding<Double>(
get: { weatherOffset },
set: { newValue in
let clampedValue = clamp(newValue)
if weatherOffset != clampedValue {
weatherOffset = clampedValue
}
propagateWeatherOffsetChange(animated: false)
}
)
let musicBinding = Binding<Double>(
get: { musicOffset },
set: { newValue in
let clampedValue = clamp(newValue)
if musicOffset != clampedValue {
musicOffset = clampedValue
}
propagateMusicOffsetChange(animated: false)
}
)
LockScreenPositioningPreview(weatherOffset: weatherBinding, musicOffset: musicBinding)
.frame(height: 260)
.padding(.vertical, 8)
HStack(alignment: .top, spacing: 24) {
offsetColumn(
title: "Weather",
value: weatherOffset,
resetTitle: "Reset Weather",
resetAction: resetWeatherOffset
)
Divider()
.frame(height: 64)
offsetColumn(
title: "Music",
value: musicOffset,
resetTitle: "Reset Music",
resetAction: resetMusicOffset
)
Spacer()
}
} 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.")
.textCase(nil)
}
}
private func clamp(_ value: Double) -> Double {
min(max(value, offsetRange.lowerBound), offsetRange.upperBound)
}
private func resetWeatherOffset() {
weatherOffset = 0
propagateWeatherOffsetChange(animated: true)
}
private func resetMusicOffset() {
musicOffset = 0
propagateMusicOffsetChange(animated: true)
}
private func propagateWeatherOffsetChange(animated: Bool) {
Task { @MainActor in
LockScreenWeatherPanelManager.shared.refreshPositionForOffsets(animated: animated)
}
}
private func propagateMusicOffsetChange(animated: Bool) {
Task { @MainActor in
LockScreenPanelManager.shared.applyOffsetAdjustment(animated: animated)
}
}
@ViewBuilder
private func offsetColumn(title: String, value: Double, resetTitle: String, resetAction: @escaping () -> Void) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text("\(title) Offset")
.font(.subheadline.weight(.semibold))
Text("\(formattedPoints(value)) pt")
.font(.caption)
.foregroundStyle(.secondary)
Button(resetTitle) {
resetAction()
}
.buttonStyle(.bordered)
}
}
private func formattedPoints(_ value: Double) -> String {
String(format: "%+.0f", value)
}
}
private struct LockScreenPositioningPreview: View {
@Binding var weatherOffset: Double
@Binding var musicOffset: Double
@State private var weatherStartOffset: Double = 0
@State private var musicStartOffset: Double = 0
@State private var isWeatherDragging = false
@State private var isMusicDragging = false
private let offsetRange: ClosedRange<Double> = -160...160
var body: some View {
GeometryReader { geometry in
let screenPadding: CGFloat = 26
let screenCornerRadius: CGFloat = 28
let screenRect = CGRect(
x: screenPadding,
y: screenPadding,
width: geometry.size.width - (screenPadding * 2),
height: geometry.size.height - (screenPadding * 2)
)
let centerX = screenRect.midX
let weatherBaseY = screenRect.minY + (screenRect.height * 0.28)
let musicBaseY = screenRect.minY + (screenRect.height * 0.68)
let weatherSize = CGSize(width: screenRect.width * 0.42, height: screenRect.height * 0.22)
let musicSize = CGSize(width: screenRect.width * 0.56, height: screenRect.height * 0.34)
ZStack {
RoundedRectangle(cornerRadius: screenCornerRadius, style: .continuous)
.fill(Color(nsColor: .windowBackgroundColor).opacity(0.55))
.frame(width: screenRect.width, height: screenRect.height)
.overlay(
RoundedRectangle(cornerRadius: screenCornerRadius, style: .continuous)
.stroke(Color.white.opacity(0.12), lineWidth: 1)
)
.shadow(color: Color.black.opacity(0.22), radius: 20, x: 0, y: 18)
.position(x: screenRect.midX, y: screenRect.midY)
weatherPanel(size: weatherSize)
.position(x: centerX, y: weatherBaseY - CGFloat(weatherOffset))
.gesture(weatherDragGesture(in: screenRect, baseY: weatherBaseY, panelSize: weatherSize))
musicPanel(size: musicSize)
.position(x: centerX, y: musicBaseY - CGFloat(musicOffset))
.gesture(musicDragGesture(in: screenRect, baseY: musicBaseY, panelSize: musicSize))
}
}
.animation(.interactiveSpring(response: 0.3, dampingFraction: 0.82), value: weatherOffset)
.animation(.interactiveSpring(response: 0.3, dampingFraction: 0.82), value: musicOffset)
}
private func weatherPanel(size: CGSize) -> some View {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(
LinearGradient(
colors: [Color.blue.opacity(0.78), Color.blue.opacity(0.52)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: size.width, height: size.height)
.overlay(alignment: .leading) {
VStack(alignment: .leading, spacing: 4) {
Label("Weather", systemImage: "cloud.sun.fill")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(Color.white)
Text("Inline snapshot preview")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(Color.white.opacity(0.72))
}
.padding(.horizontal, 16)
}
.shadow(color: Color.blue.opacity(0.22), radius: 10, x: 0, y: 8)
}
private func musicPanel(size: CGSize) -> some View {
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(
LinearGradient(
colors: [Color.purple.opacity(0.68), Color.pink.opacity(0.5)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
.frame(width: size.width, height: size.height)
.overlay(alignment: .leading) {
VStack(alignment: .leading, spacing: 6) {
Label("Media", systemImage: "play.square.stack")
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(Color.white)
Text("Lock screen panel preview")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(Color.white.opacity(0.72))
}
.padding(.horizontal, 18)
}
.shadow(color: Color.purple.opacity(0.24), radius: 12, x: 0, y: 9)
}
private func weatherDragGesture(in screenRect: CGRect, baseY: CGFloat, panelSize: CGSize) -> some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
if !isWeatherDragging {
isWeatherDragging = true
weatherStartOffset = weatherOffset
}
let proposed = weatherStartOffset - Double(value.translation.height)
weatherOffset = clampedOffset(
proposed,
baseCenterY: baseY,
panelHeight: panelSize.height,
screenRect: screenRect
)
}
.onEnded { _ in
isWeatherDragging = false
}
}
private func musicDragGesture(in screenRect: CGRect, baseY: CGFloat, panelSize: CGSize) -> some Gesture {
DragGesture(minimumDistance: 0)
.onChanged { value in
if !isMusicDragging {
isMusicDragging = true
musicStartOffset = musicOffset
}
let proposed = musicStartOffset - Double(value.translation.height)
musicOffset = clampedOffset(
proposed,
baseCenterY: baseY,
panelHeight: panelSize.height,
screenRect: screenRect
)
}
.onEnded { _ in
isMusicDragging = false
}
}
private func clampedOffset(
_ proposed: Double,
baseCenterY: CGFloat,
panelHeight: CGFloat,
screenRect: CGRect
) -> Double {
let halfHeight = panelHeight / 2
let minCenterY = screenRect.minY + halfHeight
let maxCenterY = screenRect.maxY - halfHeight
let proposedCenter = baseCenterY - CGFloat(proposed)
let clampedCenter = min(max(proposedCenter, minCenterY), maxCenterY)
let derivedOffset = Double(baseCenterY - clampedCenter)
return min(max(derivedOffset, offsetRange.lowerBound), offsetRange.upperBound)
}
}
struct Shortcuts: View { struct Shortcuts: View {
@Default(.enableTimerFeature) var enableTimerFeature @Default(.enableTimerFeature) var enableTimerFeature
@Default(.enableClipboardManager) var enableClipboardManager @Default(.enableClipboardManager) var enableClipboardManager

View file

@ -23,6 +23,8 @@ class LockScreenPanelManager {
private var panelWindow: NSWindow? private var panelWindow: NSWindow?
private var hasDelegated = false private var hasDelegated = false
private var collapsedFrame: NSRect? private var collapsedFrame: NSRect?
private var isPanelExpanded = false
private var currentAdditionalHeight: CGFloat = 0
private let collapsedPanelCornerRadius: CGFloat = 28 private let collapsedPanelCornerRadius: CGFloat = 28
private let expandedPanelCornerRadius: CGFloat = 52 private let expandedPanelCornerRadius: CGFloat = 52
private(set) var latestFrame: NSRect? private(set) var latestFrame: NSRect?
@ -53,13 +55,11 @@ class LockScreenPanelManager {
return return
} }
let collapsedSize = LockScreenMusicPanel.collapsedSize
let screenFrame = screen.frame let screenFrame = screen.frame
let centerX = screenFrame.origin.x + (screenFrame.width / 2) let targetFrame = collapsedFrame(for: screenFrame)
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 collapsedFrame = targetFrame
isPanelExpanded = false
currentAdditionalHeight = 0
let window: NSWindow let window: NSWindow
@ -152,6 +152,19 @@ class LockScreenPanelManager {
} else { } else {
window.contentView?.layer?.cornerRadius = targetRadius window.contentView?.layer?.cornerRadius = targetRadius
} }
isPanelExpanded = expanded
currentAdditionalHeight = additionalHeight
}
func applyOffsetAdjustment(animated: Bool = true) {
guard let screen = NSScreen.main else { return }
let screenFrame = screen.frame
let newCollapsed = collapsedFrame(for: screenFrame)
collapsedFrame = newCollapsed
guard panelWindow != nil else { return }
updatePanelSize(expanded: isPanelExpanded, additionalHeight: currentAdditionalHeight, animated: animated)
} }
func hidePanel() { func hidePanel() {
@ -177,4 +190,14 @@ class LockScreenPanelManager {
} }
} }
} }
private func collapsedFrame(for screenFrame: NSRect) -> NSRect {
let collapsedSize = LockScreenMusicPanel.collapsedSize
let originX = screenFrame.midX - (collapsedSize.width / 2)
let baseOriginY = screenFrame.origin.y + (screenFrame.height / 2) - collapsedSize.height - 32
let userOffset = CGFloat(Defaults[.lockScreenMusicVerticalOffset])
let clampedOffset = min(max(userOffset, -160), 160)
let originY = baseOriginY + clampedOffset
return NSRect(x: originX, y: originY, width: collapsedSize.width, height: collapsedSize.height)
}
} }

View file

@ -1,6 +1,8 @@
import AppKit import AppKit
import SwiftUI import SwiftUI
import SkyLightWindow import SkyLightWindow
import Defaults
import QuartzCore
@MainActor @MainActor
final class LockScreenWeatherPanelManager { final class LockScreenWeatherPanelManager {
@ -9,6 +11,8 @@ final class LockScreenWeatherPanelManager {
private var window: NSWindow? private var window: NSWindow?
private var hasDelegated = false private var hasDelegated = false
private(set) var latestFrame: NSRect? private(set) var latestFrame: NSRect?
private var lastSnapshot: LockScreenWeatherSnapshot?
private var lastContentSize: CGSize?
private init() {} private init() {}
@ -25,6 +29,8 @@ final class LockScreenWeatherPanelManager {
window.orderOut(nil) window.orderOut(nil)
window.contentView = nil window.contentView = nil
latestFrame = nil latestFrame = nil
lastSnapshot = nil
lastContentSize = nil
} }
private func render(snapshot: LockScreenWeatherSnapshot, makeVisible: Bool) { private func render(snapshot: LockScreenWeatherSnapshot, makeVisible: Bool) {
@ -38,11 +44,13 @@ final class LockScreenWeatherPanelManager {
let fittingSize = hostingView.fittingSize let fittingSize = hostingView.fittingSize
hostingView.frame = NSRect(origin: .zero, size: fittingSize) hostingView.frame = NSRect(origin: .zero, size: fittingSize)
let targetFrame = frame(for: fittingSize, on: screen) let targetFrame = frame(for: fittingSize, snapshot: snapshot, on: screen)
let window = ensureWindow() let window = ensureWindow()
window.setFrame(targetFrame, display: true) window.setFrame(targetFrame, display: true)
latestFrame = targetFrame latestFrame = targetFrame
window.contentView = hostingView window.contentView = hostingView
lastSnapshot = snapshot
lastContentSize = fittingSize
if makeVisible { if makeVisible {
window.orderFrontRegardless() window.orderFrontRegardless()
@ -78,14 +86,38 @@ final class LockScreenWeatherPanelManager {
return newWindow return newWindow
} }
private func frame(for size: CGSize, on screen: NSScreen) -> NSRect { private func frame(for size: CGSize, snapshot: LockScreenWeatherSnapshot, on screen: NSScreen) -> NSRect {
let screenFrame = screen.frame let screenFrame = screen.frame
let originX = screenFrame.midX - (size.width / 2) let originX = screenFrame.midX - (size.width / 2)
let verticalOffset = screenFrame.height * 0.15 let verticalOffset = screenFrame.height * 0.15
let maxY = screenFrame.maxY - size.height - 48 let maxY = screenFrame.maxY - size.height - 48
let baseY = min(maxY, screenFrame.midY + verticalOffset) let baseY = min(maxY, screenFrame.midY + verticalOffset)
let loweredY = baseY - 36 let loweredY = baseY - 36
let clampedY = max(screenFrame.minY + 80, loweredY)
let inlineLift: CGFloat = snapshot.widgetStyle == .inline ? 44 : 0
let userOffset = CGFloat(Defaults[.lockScreenWeatherVerticalOffset])
let clampedOffset = min(max(userOffset, -160), 160)
let adjustedY = loweredY + inlineLift + clampedOffset
let upperClampedY = min(maxY, adjustedY)
let clampedY = max(screenFrame.minY + 80, upperClampedY)
return NSRect(x: originX, y: clampedY, width: size.width, height: size.height) return NSRect(x: originX, y: clampedY, width: size.width, height: size.height)
} }
func refreshPositionForOffsets(animated: Bool = true) {
guard let window, let snapshot = lastSnapshot else { return }
guard let screen = NSScreen.main else { return }
let size = lastContentSize ?? window.frame.size
let targetFrame = frame(for: size, snapshot: snapshot, on: screen)
latestFrame = targetFrame
if animated {
NSAnimationContext.runAnimationGroup { context in
context.duration = 0.22
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
window.animator().setFrame(targetFrame, display: true)
}
} else {
window.setFrame(targetFrame, display: true)
}
}
} }

View file

@ -509,6 +509,28 @@ class MusicManager: ObservableObject {
} }
} }
func seek(by offset: TimeInterval) {
guard !isLiveStream else { return }
let duration = songDuration
guard duration > 0 else { return }
let current = estimatedPlaybackPosition()
let magnitude = abs(offset)
if offset < 0, current <= magnitude {
previousTrack()
return
}
if offset > 0, (duration - current) <= magnitude {
nextTrack()
return
}
let target = min(max(0, current + offset), duration)
seek(to: target)
}
func openMusicApp() { func openMusicApp() {
guard let bundleID = bundleIdentifier else { guard let bundleID = bundleIdentifier else {
print("Error: appBundleIdentifier is nil") print("Error: appBundleIdentifier is nil")

View file

@ -242,6 +242,31 @@ enum MusicAuxiliaryControl: String, CaseIterable, Identifiable, Defaults.Seriali
} }
} }
enum MusicSkipBehavior: String, CaseIterable, Identifiable, Defaults.Serializable {
case track
case tenSecond
var id: String { rawValue }
var displayName: String {
switch self {
case .track:
return "Track Skip"
case .tenSecond:
return "±10 Seconds"
}
}
var description: String {
switch self {
case .track:
return "Standard previous/next track controls"
case .tenSecond:
return "Skip forward or backward by ten seconds"
}
}
}
enum TimerIconColorMode: String, CaseIterable, Identifiable, Defaults.Serializable { enum TimerIconColorMode: String, CaseIterable, Identifiable, Defaults.Serializable {
case adaptive = "Adaptive" case adaptive = "Adaptive"
case solid = "Solid" case solid = "Solid"
@ -421,6 +446,7 @@ extension Defaults.Keys {
static let musicAuxLeftControl = Key<MusicAuxiliaryControl>("musicAuxLeftControl", default: .shuffle) static let musicAuxLeftControl = Key<MusicAuxiliaryControl>("musicAuxLeftControl", default: .shuffle)
static let musicAuxRightControl = Key<MusicAuxiliaryControl>("musicAuxRightControl", default: .repeatMode) static let musicAuxRightControl = Key<MusicAuxiliaryControl>("musicAuxRightControl", default: .repeatMode)
static let didMigrateMusicAuxControls = Key<Bool>("didMigrateMusicAuxControls", default: false) static let didMigrateMusicAuxControls = Key<Bool>("didMigrateMusicAuxControls", default: false)
static let musicSkipBehavior = Key<MusicSkipBehavior>("musicSkipBehavior", default: .track)
// Enable lock screen media widget (shows the standalone panel when screen is locked) // Enable lock screen media widget (shows the standalone panel when screen is locked)
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true) static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true) static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)
@ -438,6 +464,8 @@ extension Defaults.Keys {
static let lockScreenWeatherAQIScale = Key<LockScreenWeatherAirQualityScale>("lockScreenWeatherAQIScale", default: .us) static let lockScreenWeatherAQIScale = Key<LockScreenWeatherAirQualityScale>("lockScreenWeatherAQIScale", default: .us)
static let lockScreenWeatherUsesGaugeTint = Key<Bool>("lockScreenWeatherUsesGaugeTint", default: false) static let lockScreenWeatherUsesGaugeTint = Key<Bool>("lockScreenWeatherUsesGaugeTint", default: false)
static let lockScreenWeatherProviderSource = Key<LockScreenWeatherProviderSource>("lockScreenWeatherProviderSource", default: .openMeteo) static let lockScreenWeatherProviderSource = Key<LockScreenWeatherProviderSource>("lockScreenWeatherProviderSource", default: .openMeteo)
static let lockScreenWeatherVerticalOffset = Key<Double>("lockScreenWeatherVerticalOffset", default: 0)
static let lockScreenMusicVerticalOffset = Key<Double>("lockScreenMusicVerticalOffset", default: 0)
static let lockScreenGlassStyle = Key<LockScreenGlassStyle>("lockScreenGlassStyle", default: .liquid) static let lockScreenGlassStyle = Key<LockScreenGlassStyle>("lockScreenGlassStyle", default: .liquid)
static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false) static let lockScreenShowAppIcon = Key<Bool>("lockScreenShowAppIcon", default: false)
static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: true) static let lockScreenPanelShowsBorder = Key<Bool>("lockScreenPanelShowsBorder", default: true)