Change in the slider and added +10 -10 controls

This commit is contained in:
Hariharan Mudaliar 2025-11-20 13:17:16 +05:30
commit d88d9acb60
8 changed files with 497 additions and 228 deletions

View file

@ -207,6 +207,7 @@
},
"-%@" : {
"comment" : "A label displaying the duration of the current song. The argument is the duration of the song in seconds.",
"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" : {

View file

@ -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
}
}

View file

@ -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,65 +288,31 @@ 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))
MusicSliderView(
sliderValue: $sliderValue,
duration: Binding(
get: { musicManager.songDuration },
set: { musicManager.songDuration = $0 }
),
height: 6
)
}
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
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()
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
}
musicManager.seek(to: newValue)
},
labelLayout: .inline,
trailingLabel: .remaining,
restingTrackHeight: 7,
draggingTrackHeight: 11
)
}
.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 {
sliderValue = musicManager.elapsedTime
}
@ -356,24 +324,6 @@ struct LockScreenMusicPanel: View {
}
}
private func currentSliderValue(_ date: Date) -> Double {
if dragging {
return sliderValue
}
if musicManager.isLiveStream {
return 0
}
if musicManager.isPlaying {
let timeSinceLastUpdate = date.timeIntervalSince(musicManager.timestampDate)
let estimatedElapsed = musicManager.elapsedTime + (timeSinceLastUpdate * musicManager.playbackRate)
return min(estimatedElapsed, musicManager.songDuration)
}
return musicManager.elapsedTime
}
private var sliderColor: Color {
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,10 +830,12 @@ 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 = pressNudge
pressOffset = amount
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) {
@ -853,5 +843,65 @@ private struct PanelControlButton: 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)) {
rotationAngle = angle
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
withAnimation(.spring(response: 0.32, dampingFraction: 0.75)) {
rotationAngle = 0
}
}
}
}
@ViewBuilder
private var iconView: some View {
let base = Image(systemName: icon)
.font(.system(size: iconSize, weight: .medium))
.foregroundStyle(iconColor)
switch symbolEffect {
case .replace:
base.contentTransition(.symbolEffect(.replace))
case .replaceAndBounce:
if #available(macOS 14.0, *) {
base
.contentTransition(.symbolEffect(.replace))
.symbolEffect(.bounce, value: icon)
} else {
base.contentTransition(.symbolEffect(.replace))
}
case .wiggle:
if #available(macOS 14.0, *) {
base
.contentTransition(.symbolEffect(.replace))
.symbolEffect(.wiggle.byLayer, options: .nonRepeating, value: wiggleToken)
} else {
base.contentTransition(.symbolEffect(.replace))
}
}
}
enum Interaction {
case none
case nudge(CGFloat)
case wiggle(WiggleDirection)
}
enum SymbolEffectStyle {
case replace
case replaceAndBounce
case wiggle
}
enum WiggleDirection {
case clockwise
case counterClockwise
}
}

View file

@ -19,6 +19,7 @@ struct MinimalisticMusicPlayerView: View {
let albumArtNamespace: Namespace.ID
@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
}
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
)
}
.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 {
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,8 +868,10 @@ 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
}
@ -883,6 +881,21 @@ private struct MinimalisticSquircircleButton: View {
pressOffset = 0
}
}
case .wiggle(let direction):
guard #available(macOS 14.0, *) else { return }
wiggleToken += 1
let angle: Double = direction == .clockwise ? 11 : -11
withAnimation(.spring(response: 0.18, dampingFraction: 0.52)) {
rotationAngle = angle
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.18) {
withAnimation(.spring(response: 0.32, dampingFraction: 0.76)) {
rotationAngle = 0
}
}
}
}
@ViewBuilder
@ -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
}
}

View file

@ -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,22 +401,72 @@ struct MusicSliderView: View {
}
}
private var stackedContent: some View {
VStack(spacing: 6) {
sliderCore
.frame(height: sliderFrameHeight)
HStack {
Text(timeString(from: sliderValue))
Spacer()
Text(trailingTimeText)
}
.fontWeight(.medium)
.foregroundColor(timeLabelColor)
.font(.caption)
}
}
private var inlineContent: some View {
HStack(spacing: 10) {
Text(timeString(from: sliderValue))
.font(inlineLabelFont)
.foregroundColor(timeLabelColor)
.frame(width: 42, alignment: .leading)
sliderCore
.frame(height: sliderFrameHeight)
.frame(maxWidth: .infinity)
Text(trailingTimeText)
.font(inlineLabelFont)
.foregroundColor(timeLabelColor)
.frame(width: 48, alignment: .trailing)
}
}
@ViewBuilder
private var sliderContent: some View {
if isLiveStream {
private var liveStreamView: some View {
switch labelLayout {
case .stacked:
LiveStreamProgressIndicator(tint: sliderTint)
.frame(maxWidth: .infinity)
} else {
.frame(height: sliderFrameHeight)
case .inline:
HStack(spacing: 10) {
Spacer()
.frame(width: 42)
LiveStreamProgressIndicator(tint: sliderTint)
.frame(maxWidth: .infinity)
.frame(height: sliderFrameHeight)
Spacer()
.frame(width: 48)
}
}
}
private var sliderCore: some View {
CustomSlider(
value: $sliderValue,
range: 0 ... duration,
color: sliderTint,
dragging: $dragging,
lastDragged: $lastDragged,
onValueChange: onValueChange
onValueChange: onValueChange,
restingTrackHeight: restingTrackHeight,
draggingTrackHeight: draggingTrackHeight
)
}
}
private var sliderTint: Color {
switch Defaults[.sliderColor] {
@ -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)

View file

@ -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",

View file

@ -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")

View file

@ -242,6 +242,31 @@ enum MusicAuxiliaryControl: String, CaseIterable, Identifiable, Defaults.Seriali
}
}
enum MusicSkipBehavior: String, CaseIterable, Identifiable, Defaults.Serializable {
case track
case tenSecond
var id: String { rawValue }
var displayName: String {
switch self {
case .track:
return "Track Skip"
case .tenSecond:
return "±10 Seconds"
}
}
var description: String {
switch self {
case .track:
return "Standard previous/next track controls"
case .tenSecond:
return "Skip forward or backward by ten seconds"
}
}
}
enum TimerIconColorMode: String, CaseIterable, Identifiable, Defaults.Serializable {
case adaptive = "Adaptive"
case solid = "Solid"
@ -421,6 +446,7 @@ extension Defaults.Keys {
static let musicAuxLeftControl = Key<MusicAuxiliaryControl>("musicAuxLeftControl", default: .shuffle)
static let musicAuxRightControl = Key<MusicAuxiliaryControl>("musicAuxRightControl", default: .repeatMode)
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)
static let enableLockScreenMediaWidget = Key<Bool>("enableLockScreenMediaWidget", default: true)
static let enableLockScreenWeatherWidget = Key<Bool>("enableLockScreenWeatherWidget", default: true)