diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index a0917e7..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" : { @@ -17157,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" : { 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 7ee68b3..8c735de 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,23 +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, pressNudge: -skipNudge) { - musicManager.previousTrack() - } + controlButton( + icon: backwardConfig.icon, + size: 18, + interaction: backwardConfig.interaction, + symbolEffect: backwardConfig.symbol, + action: backwardConfig.action + ) playPauseButton - controlButton(icon: "forward.fill", size: 18, pressNudge: skipNudge) { - 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) @@ -467,7 +451,8 @@ struct LockScreenMusicPanel: View { size: CGFloat = 18, isActive: Bool = false, activeColor: Color = .red, - pressNudge: CGFloat? = nil, + interaction: PanelControlButton.Interaction = .none, + symbolEffect: PanelControlButton.SymbolEffectStyle = .replace, action: @escaping () -> Void ) -> some View { let frameSize: CGFloat = isExpanded ? 56 : 32 @@ -481,7 +466,8 @@ struct LockScreenMusicPanel: View { iconSize: iconSize, iconColor: iconColor, backgroundOpacity: backgroundOpacity, - pressNudge: pressNudge + interaction: interaction, + symbolEffect: symbolEffect ) { registerInteraction() action() @@ -498,7 +484,8 @@ struct LockScreenMusicPanel: View { iconSize: iconSize, iconColor: shouldShowVolumeSlider ? .accentColor : .white.opacity(0.8), backgroundOpacity: shouldShowVolumeSlider ? 0.22 : 0.0, - pressNudge: nil, + interaction: .none, + symbolEffect: .replace, action: toggleVolumeSlider ) .accessibilityLabel("Media output") @@ -805,11 +792,14 @@ private struct PanelControlButton: View { let iconSize: CGFloat let iconColor: Color let backgroundOpacity: Double - let pressNudge: CGFloat? + 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: { @@ -819,15 +809,13 @@ private struct PanelControlButton: View { RoundedRectangle(cornerRadius: frameSize / 2, style: .continuous) .fill(backgroundColor) .overlay( - Image(systemName: icon) - .font(.system(size: iconSize, weight: .medium)) - .foregroundStyle(iconColor) - .contentTransition(.symbolEffect(.replace)) + iconView ) } .frame(width: frameSize, height: frameSize) .buttonStyle(PlainButtonStyle()) .offset(x: pressOffset) + .rotationEffect(.degrees(rotationAngle)) .onHover { hovering in withAnimation(.smooth(duration: 0.24)) { isHovering = hovering @@ -842,16 +830,78 @@ private struct PanelControlButton: View { } private func triggerPressEffect() { - guard let pressNudge else { return } + switch interaction { + case .none: + return + case .nudge(let amount): + withAnimation(.spring(response: 0.2, dampingFraction: 0.55)) { + pressOffset = amount + } - withAnimation(.spring(response: 0.2, dampingFraction: 0.55)) { - pressOffset = pressNudge - } + 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 - DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) { - withAnimation(.spring(response: 0.28, dampingFraction: 0.72)) { - pressOffset = 0 + 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..4dc43ec 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) @@ -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 + } } } } @@ -908,17 +921,44 @@ private struct MinimalisticSquircircleButton: View { } else { image } + case .replaceAndBounce: + if #available(macOS 14.0, *) { + image + .contentTransition(.symbolEffect(.replace)) + .symbolEffect(.bounce, value: icon) + .id(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..f7be331 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -361,21 +361,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 +401,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 +485,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 +527,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 +543,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 a221933..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", diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index f9d0906..3e91650 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -509,6 +509,16 @@ class MusicManager: ObservableObject { } } + func seek(by offset: TimeInterval) { + guard !isLiveStream else { return } + let duration = songDuration + guard duration > 0 else { return } + + let current = estimatedPlaybackPosition() + 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 c3eb0eb..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)