diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index ac86707..b757544 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -207,6 +207,7 @@ }, "-%@" : { "comment" : "A label displaying the duration of the current song. The argument is the duration of the song in seconds.", + "extractionState" : "stale", "isCommentAutoGenerated" : true, "localizations" : { "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).", "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" : { "localizations" : { @@ -10225,6 +10237,9 @@ } } } + }, + "Inline snapshot preview" : { + }, "Install updates automatically" : { "extractionState" : "stale", @@ -10587,6 +10602,14 @@ }, "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" : { "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" : { "localizations" : { "ar" : { @@ -19766,6 +19797,10 @@ } } }, + "Weather" : { + "comment" : "A label and its accompanying text displayed in a lock screen panel.", + "isCommentAutoGenerated" : true + }, "Weather data provider" : { }, diff --git a/DynamicIsland/components/HoverButton.swift b/DynamicIsland/components/HoverButton.swift index 1b96caf..3c38c8b 100644 --- a/DynamicIsland/components/HoverButton.swift +++ b/DynamicIsland/components/HoverButton.swift @@ -17,6 +17,8 @@ struct HoverButton: View { @State private var isHovering = false @State private var pressOffset: CGFloat = 0 + @State private var wiggleAngle: Double = 0 + @State private var wiggleToken: Int = 0 var body: some View { let size = CGFloat(scale == .large ? 40 : 30) @@ -34,15 +36,31 @@ struct HoverButton: View { .fill(isHovering ? Color.gray.opacity(0.2) : .clear) .frame(width: size, height: size) .overlay { - Image(systemName: icon) + let baseImage = Image(systemName: icon) .foregroundColor(iconColor) .contentTransition(contentTransition) .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()) .offset(x: pressOffset) + .rotationEffect(.degrees(wiggleAngle)) .onHover { hovering in withAnimation(.smooth(duration: 0.3)) { isHovering = hovering @@ -63,10 +81,30 @@ struct HoverButton: View { 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 { case nudge(CGFloat) + case wiggle(WiggleDirection) + } + + enum WiggleDirection { + case clockwise + case counterClockwise } } diff --git a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift index 78889bc..adf3d70 100644 --- a/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift +++ b/DynamicIsland/components/LockScreen/LockScreenMusicPanel.swift @@ -18,6 +18,7 @@ struct LockScreenMusicPanel: View { @ObservedObject private var animator: LockScreenPanelAnimator @State private var sliderValue: Double = 0 @State private var dragging: Bool = false + @State private var lastDragged: Date = .distantPast @State private var isActive = true @State private var isExpanded = false @State private var isVolumeSliderVisible = false @@ -30,6 +31,7 @@ struct LockScreenMusicPanel: View { @Default(.showShuffleAndRepeat) var showShuffleAndRepeat @Default(.musicAuxLeftControl) private var leftAuxControl @Default(.musicAuxRightControl) private var rightAuxControl + @Default(.musicSkipBehavior) private var musicSkipBehavior @Default(.enableLyrics) private var enableLyrics init(animator: LockScreenPanelAnimator) { @@ -286,64 +288,30 @@ struct LockScreenMusicPanel: View { minimumInterval: (!musicManager.isLiveStream && musicManager.playbackRate > 0) ? 0.1 : nil ) ) { timeline in - let progressValue = dragging ? sliderValue : currentSliderValue(timeline.date) - - if musicManager.isLiveStream { - LiveStreamProgressIndicator(tint: sliderColor) - .frame(maxWidth: .infinity) - } 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 - ) - } - .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) - } - } + MusicSliderView( + sliderValue: $sliderValue, + duration: Binding( + get: { musicManager.songDuration }, + set: { musicManager.songDuration = $0 } + ), + lastDragged: $lastDragged, + color: musicManager.avgColor, + dragging: $dragging, + currentDate: timeline.date, + timestampDate: musicManager.timestampDate, + elapsedTime: musicManager.elapsedTime, + playbackRate: musicManager.playbackRate, + isPlaying: musicManager.isPlaying, + isLiveStream: musicManager.isLiveStream, + onValueChange: { newValue in + registerInteraction() + musicManager.seek(to: newValue) + }, + labelLayout: .inline, + trailingLabel: .remaining, + restingTrackHeight: 7, + draggingTrackHeight: 11 + ) } .onAppear { 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 { switch Defaults[.sliderColor] { 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 private func playbackControls(alignment: Alignment) -> some View { @@ -419,21 +363,63 @@ struct LockScreenMusicPanel: View { private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View { 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) { if let leftControl = controls.left { auxButton(for: leftControl) } - controlButton(icon: "backward.fill", size: 18) { - musicManager.previousTrack() - } + controlButton( + icon: backwardConfig.icon, + size: 18, + interaction: backwardConfig.interaction, + symbolEffect: backwardConfig.symbol, + action: backwardConfig.action + ) playPauseButton - controlButton(icon: "forward.fill", size: 18) { - musicManager.nextTrack() - } + controlButton( + icon: forwardConfig.icon, + size: 18, + interaction: forwardConfig.interaction, + symbolEffect: forwardConfig.symbol, + action: forwardConfig.action + ) if let rightControl = controls.right { auxButton(for: rightControl) @@ -443,20 +429,19 @@ struct LockScreenMusicPanel: View { } private var playPauseButton: some View { - let frameSize: CGFloat = isExpanded ? 72 : 48 - let symbolSize: CGFloat = isExpanded ? 30 : 24 + let frameSize: CGFloat = isExpanded ? 80 : 54 + let iconName = musicManager.isPlaying ? "pause.fill" : "play.fill" - return Button(action: { + return HoverButton( + icon: iconName, + iconColor: .white, + scale: .large, + pressEffect: nil + ) { registerInteraction() 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( @@ -464,37 +449,44 @@ struct LockScreenMusicPanel: View { size: CGFloat = 18, isActive: Bool = false, activeColor: Color = .red, + interaction: PanelControlButton.Interaction = .none, + symbolEffect: PanelControlButton.SymbolEffectStyle = .replace, action: @escaping () -> Void ) -> some View { let frameSize: CGFloat = isExpanded ? 56 : 32 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() 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 { let frameSize: CGFloat = isExpanded ? 56 : 32 let iconSize: CGFloat = isExpanded ? 26 : 18 - return Button(action: toggleVolumeSlider) { - Image(systemName: mediaOutputIcon) - .font(.system(size: iconSize, weight: .medium)) - .foregroundColor(shouldShowVolumeSlider ? .accentColor : .white.opacity(0.8)) - .frame(width: frameSize, height: frameSize) - .contentShape(Rectangle()) - } + return PanelControlButton( + icon: mediaOutputIcon, + frameSize: frameSize, + iconSize: iconSize, + iconColor: shouldShowVolumeSlider ? .accentColor : .white.opacity(0.8), + backgroundOpacity: shouldShowVolumeSlider ? 0.22 : 0.0, + interaction: .none, + symbolEffect: .replace, + action: toggleVolumeSlider + ) .accessibilityLabel("Media output") - .buttonStyle(PlainButtonStyle()) } private func lyricsSection(alignment: Alignment) -> some View { @@ -791,3 +783,123 @@ struct LockScreenMusicPanel: View { 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 + } +} diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 0a505cd..0005295 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -19,6 +19,7 @@ struct MinimalisticMusicPlayerView: View { let albumArtNamespace: Namespace.ID @Default(.musicAuxLeftControl) private var leftAuxControl @Default(.musicAuxRightControl) private var rightAuxControl + @Default(.musicSkipBehavior) private var musicSkipBehavior @ObservedObject private var reminderManager = ReminderLiveActivityManager.shared @Default(.enableReminderLiveActivity) private var enableReminderLiveActivity @Default(.enableLyrics) private var enableLyrics @@ -507,115 +508,107 @@ private struct MinimalisticReminderDetailsView: View { @State private var lastDragged: Date = .distantPast private var progressBar: some View { - TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in - if musicManager.isLiveStream { - HStack(spacing: 8) { - Spacer() - .frame(width: 42) - LiveStreamProgressIndicator(tint: sliderColor) - .frame(maxWidth: .infinity, minHeight: 6, maxHeight: 6) - Spacer() - .frame(width: 48) - } - .allowsHitTesting(false) - } else { - let currentElapsed = currentSliderValue(timeline.date) - - HStack(spacing: 8) { - Text(formatTime(dragging ? sliderValue : currentElapsed)) - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundColor(.white.opacity(0.7)) - .frame(width: 42, alignment: .leading) - - GeometryReader { geometry in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 3) - .fill(Color.white.opacity(0.2)) - .frame(height: 6) - - RoundedRectangle(cornerRadius: 3) - .fill(sliderColor) - .frame(width: max(0, geometry.size.width * (currentSliderValue(timeline.date) / max(musicManager.songDuration, 1))), height: 6) - } - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - dragging = true - let newValue = min(max(0, Double(value.location.x / geometry.size.width) * musicManager.songDuration), musicManager.songDuration) - sliderValue = newValue - lastDragged = Date() - } - .onEnded { _ in - musicManager.seek(to: sliderValue) - dragging = false - } - ) - } - .frame(height: 6) - - 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) - } - } + TimelineView( + .animation( + minimumInterval: (!musicManager.isLiveStream && musicManager.playbackRate > 0) ? 0.1 : nil + ) + ) { timeline in + MusicSliderView( + sliderValue: $sliderValue, + duration: Binding( + get: { musicManager.songDuration }, + set: { musicManager.songDuration = $0 } + ), + lastDragged: $lastDragged, + color: musicManager.avgColor, + dragging: $dragging, + currentDate: timeline.date, + timestampDate: musicManager.timestampDate, + elapsedTime: musicManager.elapsedTime, + playbackRate: musicManager.playbackRate, + isPlaying: musicManager.isPlaying, + isLiveStream: musicManager.isLiveStream, + onValueChange: { newValue in + musicManager.seek(to: newValue) + }, + labelLayout: .inline, + trailingLabel: .remaining, + restingTrackHeight: 7, + draggingTrackHeight: 11 + ) } .onAppear { sliderValue = musicManager.elapsedTime } - } - - private func currentSliderValue(_ date: Date) -> Double { - if dragging { - 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 + .onChange(of: musicManager.isLiveStream) { _, isLive in + if isLive { + dragging = false + sliderValue = 0 + } } } - - 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) private var playbackControls: some View { 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) { if Defaults[.showShuffleAndRepeat] { auxButton(for: controls.left) } - controlButton(icon: "backward.fill", size: 18, pressEffect: .nudge(-8)) { - Task { await musicManager.previousTrack() } - } + controlButton( + icon: backwardConfig.icon, + size: 18, + pressEffect: backwardConfig.press, + symbolEffect: backwardConfig.symbol, + action: backwardConfig.action + ) playPauseButton - controlButton(icon: "forward.fill", size: 18, pressEffect: .nudge(8)) { - Task { await musicManager.nextTrack() } - } + controlButton( + icon: forwardConfig.icon, + size: 18, + pressEffect: forwardConfig.press, + symbolEffect: forwardConfig.symbol, + action: forwardConfig.action + ) if Defaults[.showShuffleAndRepeat] { auxButton(for: controls.right) @@ -628,15 +621,15 @@ private struct MinimalisticReminderDetailsView: View { private var playPauseButton: some View { MinimalisticSquircircleButton( icon: musicManager.isPlaying ? "pause.fill" : "play.fill", - fontSize: 24, + fontSize: 28, fontWeight: .semibold, - frameSize: CGSize(width: 54, height: 54), - cornerRadius: 20, + frameSize: CGSize(width: 60, height: 60), + cornerRadius: 24, foregroundColor: .white, pressEffect: .none, symbolEffectStyle: .replace, action: { - Task { await musicManager.togglePlay() } + musicManager.togglePlay() } ) } @@ -826,6 +819,8 @@ private struct MinimalisticSquircircleButton: View { @State private var isHovering = false @State private var pressOffset: CGFloat = 0 + @State private var rotationAngle: Double = 0 + @State private var wiggleToken: Int = 0 init( icon: String, @@ -864,6 +859,7 @@ private struct MinimalisticSquircircleButton: View { } .buttonStyle(PlainButtonStyle()) .offset(x: pressOffset) + .rotationEffect(.degrees(rotationAngle)) .onHover { hovering in withAnimation(.easeOut(duration: 0.18)) { isHovering = hovering @@ -872,15 +868,32 @@ private struct MinimalisticSquircircleButton: View { } 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)) { - pressOffset = amount - } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { + 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.26, dampingFraction: 0.8)) { - pressOffset = 0 + 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 + } } } } @@ -896,9 +909,7 @@ private struct MinimalisticSquircircleButton: View { image case .replace: if #available(macOS 14.0, *) { - image - .contentTransition(.symbolEffect(.replace)) - .id(icon) + image.contentTransition(.symbolEffect(.replace)) } else { image } @@ -908,17 +919,43 @@ private struct MinimalisticSquircircleButton: View { } else { 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 { case none case nudge(CGFloat) + case wiggle(WiggleDirection) } enum SymbolEffectStyle { case none case replace case bounce + case replaceAndBounce + case wiggle + } + + enum WiggleDirection { + case clockwise + case counterClockwise } } diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 56b74cd..8f554f7 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -122,6 +122,7 @@ struct MusicControlsView: View { let showShuffleAndRepeat: Bool @Default(.musicAuxLeftControl) private var leftAuxControl @Default(.musicAuxRightControl) private var rightAuxControl + @Default(.musicSkipBehavior) private var musicSkipBehavior @Default(.enableLyrics) private var enableLyrics var body: some View { @@ -214,20 +215,62 @@ struct MusicControlsView: View { private var playbackControls: some View { 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) { if showShuffleAndRepeat { 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) { 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 { auxButton(for: controls.right) } @@ -361,21 +404,32 @@ struct MusicSliderView: View { let isPlaying: Bool let isLiveStream: Bool 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 { - VStack { - sliderContent - .frame(height: 10, alignment: .center) - if !isLiveStream { - HStack { - Text(timeString(from: sliderValue)) - Spacer() - Text(timeString(from: duration)) + Group { + if isLiveStream { + liveStreamView + } else { + switch labelLayout { + case .stacked: + stackedContent + case .inline: + inlineContent } - .fontWeight(.medium) - .foregroundColor(timeLabelColor) - .font(.caption) } } .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 - private var sliderContent: some View { - if isLiveStream { + private var liveStreamView: some View { + switch labelLayout { + case .stacked: LiveStreamProgressIndicator(tint: sliderTint) .frame(maxWidth: .infinity) - } else { - CustomSlider( - value: $sliderValue, - range: 0 ... duration, - color: sliderTint, - dragging: $dragging, - lastDragged: $lastDragged, - onValueChange: onValueChange - ) + .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( + value: $sliderValue, + range: 0 ... duration, + color: sliderTint, + dragging: $dragging, + lastDragged: $lastDragged, + onValueChange: onValueChange, + restingTrackHeight: restingTrackHeight, + draggingTrackHeight: draggingTrackHeight + ) + } + private var sliderTint: Color { switch Defaults[.sliderColor] { case .albumArt: @@ -424,6 +528,24 @@ struct MusicSliderView: View { : .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 { let totalMinutes = Int(seconds) / 60 let remainingSeconds = Int(seconds) % 60 @@ -448,11 +570,13 @@ struct CustomSlider: View { @Binding var lastDragged: Date var onValueChange: ((Double) -> Void)? var thumbSize: CGFloat = 12 + var restingTrackHeight: CGFloat = 5 + var draggingTrackHeight: CGFloat = 9 var body: some View { GeometryReader { geometry in let width = geometry.size.width - let height = CGFloat(dragging ? 9 : 5) + let trackHeight = CGFloat(dragging ? draggingTrackHeight : restingTrackHeight) let rangeSpan = range.upperBound - range.lowerBound let progress = rangeSpan == .zero ? 0 : (value - range.lowerBound) / rangeSpan @@ -462,15 +586,15 @@ struct CustomSlider: View { // Background track Rectangle() .fill(.gray.opacity(0.3)) - .frame(height: height) + .frame(height: trackHeight) // Filled track Rectangle() .fill(color) - .frame(width: filledTrackWidth, height: height) + .frame(width: filledTrackWidth, height: trackHeight) } - .cornerRadius(height / 2) - .frame(height: 10) + .cornerRadius(trackHeight / 2) + .frame(height: max(restingTrackHeight, draggingTrackHeight)) .contentShape(Rectangle()) .highPriorityGesture( DragGesture(minimumDistance: 0) diff --git a/DynamicIsland/components/Settings/SettingsView.swift b/DynamicIsland/components/Settings/SettingsView.swift index d7aa82f..aef213f 100644 --- a/DynamicIsland/components/Settings/SettingsView.swift +++ b/DynamicIsland/components/Settings/SettingsView.swift @@ -857,6 +857,7 @@ struct Media: View { @Default(.showShuffleAndRepeat) private var showShuffleAndRepeat @Default(.musicAuxLeftControl) private var musicAuxLeftControl @Default(.musicAuxRightControl) private var musicAuxRightControl + @Default(.musicSkipBehavior) private var musicSkipBehavior @State private var previousLeftAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxLeftControl] @State private var previousRightAuxControl: MusicAuxiliaryControl = Defaults[.musicAuxRightControl] @@ -918,6 +919,20 @@ struct Media: View { } header: { 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 { Toggle( "Enable music live activity", @@ -1436,6 +1451,8 @@ struct Appearance: View { Text("General") } + LockScreenPositioningControls() + Section { Defaults.Toggle("Enable colored spectrograms", key: .coloredSpectrogram) 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 = -160...160 + + var body: some View { + Section { + let weatherBinding = Binding( + get: { weatherOffset }, + set: { newValue in + let clampedValue = clamp(newValue) + if weatherOffset != clampedValue { + weatherOffset = clampedValue + } + propagateWeatherOffsetChange(animated: false) + } + ) + + let musicBinding = Binding( + 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 = -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 { @Default(.enableTimerFeature) var enableTimerFeature @Default(.enableClipboardManager) var enableClipboardManager diff --git a/DynamicIsland/managers/LockScreenPanelManager.swift b/DynamicIsland/managers/LockScreenPanelManager.swift index 4703304..3e6eda8 100644 --- a/DynamicIsland/managers/LockScreenPanelManager.swift +++ b/DynamicIsland/managers/LockScreenPanelManager.swift @@ -23,6 +23,8 @@ class LockScreenPanelManager { private var panelWindow: NSWindow? private var hasDelegated = false private var collapsedFrame: NSRect? + private var isPanelExpanded = false + private var currentAdditionalHeight: CGFloat = 0 private let collapsedPanelCornerRadius: CGFloat = 28 private let expandedPanelCornerRadius: CGFloat = 52 private(set) var latestFrame: NSRect? @@ -53,13 +55,11 @@ class LockScreenPanelManager { return } - let collapsedSize = LockScreenMusicPanel.collapsedSize let screenFrame = screen.frame - let centerX = screenFrame.origin.x + (screenFrame.width / 2) - let originX = centerX - (collapsedSize.width / 2) - let originY = screenFrame.origin.y + (screenFrame.height / 2) - collapsedSize.height - 32 - let targetFrame = NSRect(x: originX, y: originY, width: collapsedSize.width, height: collapsedSize.height) + let targetFrame = collapsedFrame(for: screenFrame) collapsedFrame = targetFrame + isPanelExpanded = false + currentAdditionalHeight = 0 let window: NSWindow @@ -152,6 +152,19 @@ class LockScreenPanelManager { } else { 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() { @@ -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) + } } diff --git a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift index d5966d6..f95df89 100644 --- a/DynamicIsland/managers/LockScreenWeatherPanelManager.swift +++ b/DynamicIsland/managers/LockScreenWeatherPanelManager.swift @@ -1,6 +1,8 @@ import AppKit import SwiftUI import SkyLightWindow +import Defaults +import QuartzCore @MainActor final class LockScreenWeatherPanelManager { @@ -9,6 +11,8 @@ final class LockScreenWeatherPanelManager { private var window: NSWindow? private var hasDelegated = false private(set) var latestFrame: NSRect? + private var lastSnapshot: LockScreenWeatherSnapshot? + private var lastContentSize: CGSize? private init() {} @@ -25,6 +29,8 @@ final class LockScreenWeatherPanelManager { window.orderOut(nil) window.contentView = nil latestFrame = nil + lastSnapshot = nil + lastContentSize = nil } private func render(snapshot: LockScreenWeatherSnapshot, makeVisible: Bool) { @@ -38,11 +44,13 @@ final class LockScreenWeatherPanelManager { let fittingSize = hostingView.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() window.setFrame(targetFrame, display: true) latestFrame = targetFrame window.contentView = hostingView + lastSnapshot = snapshot + lastContentSize = fittingSize if makeVisible { window.orderFrontRegardless() @@ -78,14 +86,38 @@ final class LockScreenWeatherPanelManager { 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 originX = screenFrame.midX - (size.width / 2) let verticalOffset = screenFrame.height * 0.15 let maxY = screenFrame.maxY - size.height - 48 let baseY = min(maxY, screenFrame.midY + verticalOffset) 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) } + + 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) + } + } } diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index f9d0906..4aad931 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -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() { guard let bundleID = bundleIdentifier else { print("Error: appBundleIdentifier is nil") diff --git a/DynamicIsland/models/Constants.swift b/DynamicIsland/models/Constants.swift index 55cf754..4f7607b 100644 --- a/DynamicIsland/models/Constants.swift +++ b/DynamicIsland/models/Constants.swift @@ -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 { case adaptive = "Adaptive" case solid = "Solid" @@ -421,6 +446,7 @@ extension Defaults.Keys { static let musicAuxLeftControl = Key("musicAuxLeftControl", default: .shuffle) static let musicAuxRightControl = Key("musicAuxRightControl", default: .repeatMode) static let didMigrateMusicAuxControls = Key("didMigrateMusicAuxControls", default: false) + static let musicSkipBehavior = Key("musicSkipBehavior", default: .track) // Enable lock screen media widget (shows the standalone panel when screen is locked) static let enableLockScreenMediaWidget = Key("enableLockScreenMediaWidget", default: true) static let enableLockScreenWeatherWidget = Key("enableLockScreenWeatherWidget", default: true) @@ -438,6 +464,8 @@ extension Defaults.Keys { static let lockScreenWeatherAQIScale = Key("lockScreenWeatherAQIScale", default: .us) static let lockScreenWeatherUsesGaugeTint = Key("lockScreenWeatherUsesGaugeTint", default: false) static let lockScreenWeatherProviderSource = Key("lockScreenWeatherProviderSource", default: .openMeteo) + static let lockScreenWeatherVerticalOffset = Key("lockScreenWeatherVerticalOffset", default: 0) + static let lockScreenMusicVerticalOffset = Key("lockScreenMusicVerticalOffset", default: 0) static let lockScreenGlassStyle = Key("lockScreenGlassStyle", default: .liquid) static let lockScreenShowAppIcon = Key("lockScreenShowAppIcon", default: false) static let lockScreenPanelShowsBorder = Key("lockScreenPanelShowsBorder", default: true)