Ported the LIVE indicator in lock screen

This commit is contained in:
Hariharan Mudaliar 2025-11-18 21:10:50 +05:30
commit 57a0cc4d76

View file

@ -26,6 +26,10 @@ struct LockScreenMusicPanel: View {
@Default(.lockScreenPanelShowsBorder) var showPanelBorder @Default(.lockScreenPanelShowsBorder) var showPanelBorder
@Default(.lockScreenPanelUsesBlur) var enableBlur @Default(.lockScreenPanelUsesBlur) var enableBlur
@Default(.showMediaOutputControl) var showMediaOutputControl @Default(.showMediaOutputControl) var showMediaOutputControl
@Default(.showShuffleAndRepeat) var showShuffleAndRepeat
@Default(.musicAuxLeftControl) private var leftAuxControl
@Default(.musicAuxRightControl) private var rightAuxControl
@Default(.enableLyrics) private var enableLyrics
private let collapsedPanelCornerRadius: CGFloat = 28 private let collapsedPanelCornerRadius: CGFloat = 28
private let expandedPanelCornerRadius: CGFloat = 52 private let expandedPanelCornerRadius: CGFloat = 52
@ -272,62 +276,89 @@ struct LockScreenMusicPanel: View {
// MARK: - Progress Bar // MARK: - Progress Bar
private var progressBar: some View { private var progressBar: some View {
TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in TimelineView(
let currentElapsed = currentSliderValue(timeline.date) .animation(
minimumInterval: (!musicManager.isLiveStream && musicManager.playbackRate > 0) ? 0.1 : nil
HStack(spacing: 8) { )
// Elapsed time ) { timeline in
Text(formatTime(dragging ? sliderValue : currentElapsed)) let progressValue = dragging ? sliderValue : currentSliderValue(timeline.date)
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.7)) if musicManager.isLiveStream {
.frame(width: 42, alignment: .leading) LiveStreamProgressIndicator(tint: sliderColor)
.frame(maxWidth: .infinity)
// Progress bar } else {
GeometryReader { geometry in HStack(spacing: 8) {
ZStack(alignment: .leading) { // Elapsed time
// Background track Text(formatTime(progressValue))
RoundedRectangle(cornerRadius: 3) .font(.system(size: 11, weight: .medium, design: .monospaced))
.fill(Color.white.opacity(0.2)) .foregroundColor(.white.opacity(0.7))
.frame(height: 6) .frame(width: 42, alignment: .leading)
// Filled portion // Progress bar
RoundedRectangle(cornerRadius: 3) GeometryReader { geometry in
.fill(sliderColor) ZStack(alignment: .leading) {
.frame(width: max(0, geometry.size.width * (currentSliderValue(timeline.date) / max(musicManager.songDuration, 1))), height: 6) // 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
}
)
} }
.gesture( .frame(height: 6)
DragGesture(minimumDistance: 0)
.onChanged { value in // Time remaining
registerInteraction() Text("-\(formatTime(max(musicManager.songDuration - progressValue, 0)))")
dragging = true .font(.system(size: 11, weight: .medium, design: .monospaced))
let newValue = min(max(0, Double(value.location.x / geometry.size.width) * musicManager.songDuration), musicManager.songDuration) .foregroundColor(.white.opacity(0.7))
sliderValue = newValue .frame(width: 48, alignment: .trailing)
}
.onEnded { _ in
registerInteraction()
musicManager.seek(to: sliderValue)
dragging = false
}
)
} }
.frame(height: 6)
// Time remaining
Text("-\(formatTime(musicManager.songDuration - (dragging ? sliderValue : currentElapsed)))")
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(.white.opacity(0.7))
.frame(width: 48, alignment: .trailing)
} }
} }
.onAppear { .onAppear {
sliderValue = musicManager.elapsedTime sliderValue = musicManager.elapsedTime
} }
.onChange(of: musicManager.isLiveStream) { _, isLive in
if isLive {
dragging = false
sliderValue = 0
}
}
} }
private func currentSliderValue(_ date: Date) -> Double { private func currentSliderValue(_ date: Date) -> Double {
if dragging { if dragging {
return sliderValue return sliderValue
} }
if musicManager.isLiveStream {
return 0
}
if musicManager.isPlaying { if musicManager.isPlaying {
let timeSinceLastUpdate = date.timeIntervalSince(musicManager.timestampDate) let timeSinceLastUpdate = date.timeIntervalSince(musicManager.timestampDate)
@ -375,9 +406,11 @@ struct LockScreenMusicPanel: View {
} }
private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View { private func controlsRow(alignment: Alignment, spacing: CGFloat) -> some View {
HStack(spacing: spacing) { let controls = resolvedAuxControls
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
musicManager.toggleShuffle() return HStack(spacing: spacing) {
if let leftControl = controls.left {
auxButton(for: leftControl)
} }
controlButton(icon: "backward.fill", size: 18) { controlButton(icon: "backward.fill", size: 18) {
@ -390,12 +423,8 @@ struct LockScreenMusicPanel: View {
musicManager.nextTrack() musicManager.nextTrack()
} }
if showMediaOutputControl { if let rightControl = controls.right {
mediaOutputControlButton auxButton(for: rightControl)
} else {
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
musicManager.toggleRepeat()
}
} }
} }
.frame(maxWidth: .infinity, alignment: alignment) .frame(maxWidth: .infinity, alignment: alignment)
@ -418,7 +447,13 @@ struct LockScreenMusicPanel: View {
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
} }
private func controlButton(icon: String, size: CGFloat = 18, isActive: Bool = false, action: @escaping () -> Void) -> some View { private func controlButton(
icon: String,
size: CGFloat = 18,
isActive: Bool = false,
activeColor: Color = .red,
action: @escaping () -> Void
) -> some View {
let frameSize: CGFloat = isExpanded ? 56 : 32 let frameSize: CGFloat = isExpanded ? 56 : 32
let iconSize: CGFloat = isExpanded ? max(size, 24) : size let iconSize: CGFloat = isExpanded ? max(size, 24) : size
@ -428,7 +463,7 @@ struct LockScreenMusicPanel: View {
}) { }) {
Image(systemName: icon) Image(systemName: icon)
.font(.system(size: iconSize, weight: .medium)) .font(.system(size: iconSize, weight: .medium))
.foregroundColor(isActive ? .red : .white.opacity(0.8)) .foregroundColor(isActive ? activeColor : .white.opacity(0.8))
.frame(width: frameSize, height: frameSize) .frame(width: frameSize, height: frameSize)
.contentShape(Rectangle()) .contentShape(Rectangle())
} }
@ -450,6 +485,60 @@ struct LockScreenMusicPanel: View {
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
} }
private var availableAuxControls: [MusicAuxiliaryControl] {
MusicAuxiliaryControl.allCases.filter { control in
control != .mediaOutput || showMediaOutputControl
}
}
private var resolvedAuxControls: (left: MusicAuxiliaryControl?, right: MusicAuxiliaryControl?) {
guard showShuffleAndRepeat else { return (nil, nil) }
let options = availableAuxControls
guard !options.isEmpty else { return (nil, nil) }
let left = options.contains(leftAuxControl) ? leftAuxControl : options.first!
var rightCandidate = options.contains(rightAuxControl) ? rightAuxControl : options.first!
if options.count == 1 {
return (left, nil)
}
if rightCandidate == left, let alternative = options.first(where: { $0 != left }) {
rightCandidate = alternative
}
if rightCandidate == left {
return (left, nil)
}
return (left, rightCandidate)
}
@ViewBuilder
private func auxButton(for control: MusicAuxiliaryControl) -> some View {
switch control {
case .shuffle:
controlButton(icon: "shuffle", isActive: musicManager.isShuffled) {
musicManager.toggleShuffle()
}
case .repeatMode:
controlButton(icon: repeatIcon, isActive: musicManager.repeatMode != .off) {
musicManager.toggleRepeat()
}
case .mediaOutput:
mediaOutputControlButton
case .lyrics:
controlButton(
icon: enableLyrics ? "quote.bubble.fill" : "quote.bubble",
isActive: enableLyrics,
activeColor: .accentColor
) {
enableLyrics.toggle()
}
}
}
private var repeatIcon: String { private var repeatIcon: String {
switch musicManager.repeatMode { switch musicManager.repeatMode {
case .off: return "repeat" case .off: return "repeat"
@ -517,6 +606,11 @@ struct LockScreenMusicPanel: View {
} }
private func toggleVolumeSlider() { private func toggleVolumeSlider() {
guard showMediaOutputControl else {
isVolumeSliderVisible = false
return
}
registerInteraction() registerInteraction()
let newState = !isVolumeSliderVisible let newState = !isVolumeSliderVisible
if newState { if newState {