mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Merge pull request #112 from Ebullioscopic/feature/lock-screen
Skip 10s buttons and Lock Screen Widget repositioning
This commit is contained in:
commit
ef18157857
10 changed files with 994 additions and 260 deletions
|
|
@ -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" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,64 +288,30 @@ 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) {
|
lastDragged: $lastDragged,
|
||||||
// Elapsed time
|
color: musicManager.avgColor,
|
||||||
Text(formatTime(progressValue))
|
dragging: $dragging,
|
||||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
currentDate: timeline.date,
|
||||||
.foregroundColor(.white.opacity(0.7))
|
timestampDate: musicManager.timestampDate,
|
||||||
.frame(width: 42, alignment: .leading)
|
elapsedTime: musicManager.elapsedTime,
|
||||||
|
playbackRate: musicManager.playbackRate,
|
||||||
// Progress bar
|
isPlaying: musicManager.isPlaying,
|
||||||
GeometryReader { geometry in
|
isLiveStream: musicManager.isLiveStream,
|
||||||
ZStack(alignment: .leading) {
|
onValueChange: { newValue in
|
||||||
// Background track
|
registerInteraction()
|
||||||
RoundedRectangle(cornerRadius: 3)
|
musicManager.seek(to: newValue)
|
||||||
.fill(Color.white.opacity(0.2))
|
},
|
||||||
.frame(height: 6)
|
labelLayout: .inline,
|
||||||
|
trailingLabel: .remaining,
|
||||||
// Filled portion
|
restingTrackHeight: 7,
|
||||||
RoundedRectangle(cornerRadius: 3)
|
draggingTrackHeight: 11
|
||||||
.fill(sliderColor)
|
)
|
||||||
.frame(
|
|
||||||
width: max(
|
|
||||||
0,
|
|
||||||
geometry.size.width * (progressValue / max(musicManager.songDuration, 1))
|
|
||||||
),
|
|
||||||
height: 6
|
|
||||||
)
|
|
||||||
}
|
|
||||||
.gesture(
|
|
||||||
DragGesture(minimumDistance: 0)
|
|
||||||
.onChanged { value in
|
|
||||||
registerInteraction()
|
|
||||||
dragging = true
|
|
||||||
let duration = max(musicManager.songDuration, 0.0001)
|
|
||||||
let clampedX = min(max(value.location.x, 0), geometry.size.width)
|
|
||||||
let newValue = Double(clampedX / geometry.size.width) * duration
|
|
||||||
sliderValue = min(max(0, newValue), musicManager.songDuration)
|
|
||||||
}
|
|
||||||
.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
|
||||||
|
|
@ -355,25 +323,7 @@ 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,15 +868,32 @@ 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)) {
|
||||||
|
pressOffset = amount
|
||||||
|
}
|
||||||
|
|
||||||
withAnimation(.spring(response: 0.16, dampingFraction: 0.72)) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
|
||||||
pressOffset = amount
|
withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) {
|
||||||
}
|
pressOffset = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .wiggle(let direction):
|
||||||
|
guard #available(macOS 14.0, *) else { return }
|
||||||
|
wiggleToken += 1
|
||||||
|
let angle: Double = direction == .clockwise ? 11 : -11
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {
|
withAnimation(.spring(response: 0.18, dampingFraction: 0.52)) {
|
||||||
withAnimation(.spring(response: 0.26, dampingFraction: 0.8)) {
|
rotationAngle = angle
|
||||||
pressOffset = 0
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
|
||||||
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.76)) {
|
||||||
|
rotationAngle = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,23 +444,73 @@ 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)
|
||||||
CustomSlider(
|
case .inline:
|
||||||
value: $sliderValue,
|
HStack(spacing: 10) {
|
||||||
range: 0 ... duration,
|
Spacer()
|
||||||
color: sliderTint,
|
.frame(width: 42)
|
||||||
dragging: $dragging,
|
LiveStreamProgressIndicator(tint: sliderTint)
|
||||||
lastDragged: $lastDragged,
|
.frame(maxWidth: .infinity)
|
||||||
onValueChange: onValueChange
|
.frame(height: sliderFrameHeight)
|
||||||
)
|
Spacer()
|
||||||
|
.frame(width: 48)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var sliderCore: some View {
|
||||||
|
CustomSlider(
|
||||||
|
value: $sliderValue,
|
||||||
|
range: 0 ... duration,
|
||||||
|
color: sliderTint,
|
||||||
|
dragging: $dragging,
|
||||||
|
lastDragged: $lastDragged,
|
||||||
|
onValueChange: onValueChange,
|
||||||
|
restingTrackHeight: restingTrackHeight,
|
||||||
|
draggingTrackHeight: draggingTrackHeight
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private var sliderTint: Color {
|
private var sliderTint: Color {
|
||||||
switch Defaults[.sliderColor] {
|
switch Defaults[.sliderColor] {
|
||||||
case .albumArt:
|
case .albumArt:
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue