Working LIVE indicator

This commit is contained in:
Hariharan Mudaliar 2025-11-07 15:14:14 +05:30
commit fa1bd97d84
5 changed files with 255 additions and 58 deletions

View file

@ -10479,6 +10479,10 @@
}
}
},
"LIVE" : {
"comment" : "A label indicating that the music is live.",
"isCommentAutoGenerated" : true
},
"Live Monitoring" : {
"localizations" : {
"fr" : {
@ -10499,6 +10503,10 @@
}
}
},
"Live stream indicator" : {
"comment" : "A visual indicator that appears when a music stream is live.",
"isCommentAutoGenerated" : true
},
"Load Average" : {
"comment" : "A label displayed above the load average values.",
"isCommentAutoGenerated" : true,

View file

@ -0,0 +1,68 @@
//
// LiveStreamProgressIndicator.swift
// DynamicIsland
//
// Shared indicator used for live stream playback timelines.
//
import SwiftUI
struct LiveStreamProgressIndicator: View {
let tint: Color
private var baseGradient: LinearGradient {
LinearGradient(
colors: [
tint.opacity(0.18),
tint.opacity(0.42),
tint.opacity(0.18)
],
startPoint: .leading,
endPoint: .trailing
)
}
private var centerShade: LinearGradient {
LinearGradient(
stops: [
.init(color: .black.opacity(0.0), location: 0.0),
.init(color: .black.opacity(0.38), location: 0.5),
.init(color: .black.opacity(0.0), location: 1.0)
],
startPoint: .leading,
endPoint: .trailing
)
}
var body: some View {
Capsule()
.fill(Color.white.opacity(0.07))
.overlay(
Capsule()
.fill(baseGradient)
.opacity(0.9)
.blendMode(.plusLighter)
)
.overlay(
Capsule()
.fill(centerShade)
.blendMode(.multiply)
)
.overlay(
Capsule()
.stroke(tint.opacity(0.6), lineWidth: 0.7)
.blendMode(.screen)
)
.frame(height: 10)
.overlay {
Text("LIVE")
.font(.system(size: 12, weight: .black, design: .rounded))
.tracking(1.4)
.foregroundStyle(Color.white)
.shadow(color: .black.opacity(0.65), radius: 4, y: 1)
}
.allowsHitTesting(false)
.accessibilityLabel("Live stream indicator")
.opacity(0.95)
}
}

View file

@ -89,49 +89,56 @@ struct MinimalisticMusicPlayerView: View {
private var progressBar: some View {
TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in
let currentElapsed = currentSliderValue(timeline.date)
HStack(spacing: 8) {
// Elapsed time - left
Text(formatTime(dragging ? sliderValue : currentElapsed))
.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 * (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
}
)
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)
}
.frame(height: 6)
// Time remaining - right
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 {

View file

@ -170,8 +170,10 @@ struct MusicControlsView: View {
timestampDate: musicManager.timestampDate,
elapsedTime: musicManager.elapsedTime,
playbackRate: musicManager.playbackRate,
isPlaying: musicManager.isPlaying
isPlaying: musicManager.isPlaying,
isLiveStream: musicManager.isLiveStream
) { newValue in
guard !musicManager.isLiveStream else { return }
MusicManager.shared.seek(to: newValue)
}
.padding(.top, 5)
@ -296,38 +298,71 @@ struct MusicSliderView: View {
let elapsedTime: Double
let playbackRate: Double
let isPlaying: Bool
let isLiveStream: Bool
var onValueChange: (Double) -> Void
var body: some View {
VStack {
sliderContent
.frame(height: 10, alignment: .center)
if !isLiveStream {
HStack {
Text(timeString(from: sliderValue))
Spacer()
Text(timeString(from: duration))
}
.fontWeight(.medium)
.foregroundColor(timeLabelColor)
.font(.caption)
}
}
.onChange(of: currentDate) { newDate in
guard !isLiveStream else { return }
guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -1 else { return }
sliderValue = MusicManager.shared.estimatedPlaybackPosition(at: newDate)
}
.onChange(of: isLiveStream) { isLive in
if isLive {
sliderValue = 0
}
}
}
@ViewBuilder
private var sliderContent: some View {
if isLiveStream {
LiveStreamProgressIndicator(tint: sliderTint)
.frame(maxWidth: .infinity)
} else {
CustomSlider(
value: $sliderValue,
range: 0 ... duration,
color: Defaults[.sliderColor] == SliderColorEnum.albumArt ? Color(
nsColor: color
).ensureMinimumBrightness(factor: 0.8) : Defaults[.sliderColor] == SliderColorEnum.accent ? .accentColor : .white,
color: sliderTint,
dragging: $dragging,
lastDragged: $lastDragged,
onValueChange: onValueChange
)
.frame(height: 10, alignment: .center)
HStack {
Text(timeString(from: sliderValue))
Spacer()
Text(timeString(from: duration))
}
.fontWeight(.medium)
.foregroundColor(Defaults[.playerColorTinting] ? Color(nsColor: color)
.ensureMinimumBrightness(factor: 0.6) : .gray)
.font(.caption)
}
.onChange(of: currentDate) { newDate in
guard !dragging, timestampDate.timeIntervalSince(lastDragged) > -1 else { return }
sliderValue = MusicManager.shared.estimatedPlaybackPosition(at: newDate)
}
private var sliderTint: Color {
switch Defaults[.sliderColor] {
case .albumArt:
return Color(nsColor: color).ensureMinimumBrightness(factor: 0.8)
case .accent:
return .accentColor
case .white:
return .white
}
}
private var timeLabelColor: Color {
Defaults[.playerColorTinting]
? Color(nsColor: color).ensureMinimumBrightness(factor: 0.6)
: .gray
}
func timeString(from seconds: Double) -> String {
let totalMinutes = Int(seconds) / 60
let remainingSeconds = Int(seconds) % 60
@ -340,8 +375,10 @@ struct MusicSliderView: View {
return String(format: "%d:%02d", minutes, remainingSeconds)
}
}
}
struct CustomSlider: View {
@Binding var value: Double
var range: ClosedRange<Double>

View file

@ -44,11 +44,17 @@ class MusicManager: ObservableObject {
@Published var playbackRate: Double = 1
@Published var isShuffled: Bool = false
@Published var repeatMode: RepeatMode = .off
@Published var isLiveStream: Bool = false
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
@Published var usingAppIconForArtwork: Bool = false
private var artworkData: Data? = nil
private var liveStreamUnknownDurationCount: Int = 0
private var liveStreamEdgeObservationCount: Int = 0
private var liveStreamCompletionObservationCount: Int = 0
private var liveStreamCompletionReleaseCount: Int = 0
// Store last values at the time artwork was changed
private var lastArtworkTitle: String = "I'm Handsome"
private var lastArtworkArtist: String = "Me"
@ -262,6 +268,7 @@ class MusicManager: ObservableObject {
self.repeatMode = state.repeatMode
}
updateLiveStreamState(with: state)
self.timestampDate = state.lastUpdated
}
@ -271,6 +278,76 @@ class MusicManager: ObservableObject {
}
}
private func updateLiveStreamState(with state: PlaybackState) {
let duration = state.duration
let current = max(state.currentTime, elapsedTime)
let hasKnownDuration = duration.isFinite && duration > 0
let isPlaying = state.isPlaying
if hasKnownDuration {
liveStreamUnknownDurationCount = 0
let remaining = duration - current
let clampedDuration = max(duration, 0)
let clampedCurrent = clampedDuration > 0
? max(0, min(current, clampedDuration))
: max(0, current)
let progress = clampedDuration > 0 ? clampedCurrent / clampedDuration : 0
let sliderAppearsComplete = isPlaying && clampedDuration > 0 && progress >= 0.999
let nearDurationEdge = isPlaying && remaining.isFinite && remaining <= 1.0 && clampedCurrent >= 10
if sliderAppearsComplete {
liveStreamCompletionObservationCount = min(liveStreamCompletionObservationCount + 1, 8)
liveStreamCompletionReleaseCount = 0
} else {
liveStreamCompletionReleaseCount = min(liveStreamCompletionReleaseCount + 1, 8)
if liveStreamCompletionObservationCount > 0 {
liveStreamCompletionObservationCount = max(liveStreamCompletionObservationCount - 1, 0)
}
}
if nearDurationEdge || sliderAppearsComplete {
liveStreamEdgeObservationCount = min(liveStreamEdgeObservationCount + 1, 12)
} else if liveStreamEdgeObservationCount > 0 {
liveStreamEdgeObservationCount = max(liveStreamEdgeObservationCount - 1, 0)
}
if !isLiveStream {
if liveStreamCompletionObservationCount >= 3 || liveStreamEdgeObservationCount >= 5 {
isLiveStream = true
}
} else {
let shouldClearForKnownDuration =
!isPlaying
|| (duration > 10 && remaining > 5)
|| (liveStreamCompletionObservationCount == 0
&& liveStreamEdgeObservationCount == 0
&& liveStreamCompletionReleaseCount >= 4)
if shouldClearForKnownDuration {
isLiveStream = false
}
}
} else if isPlaying {
liveStreamEdgeObservationCount = max(liveStreamEdgeObservationCount - 1, 0)
liveStreamCompletionObservationCount = max(liveStreamCompletionObservationCount - 1, 0)
liveStreamCompletionReleaseCount = 0
liveStreamUnknownDurationCount = min(liveStreamUnknownDurationCount + 1, 8)
if liveStreamUnknownDurationCount >= 3 && !isLiveStream {
isLiveStream = true
}
} else {
liveStreamUnknownDurationCount = 0
liveStreamEdgeObservationCount = 0
liveStreamCompletionObservationCount = 0
liveStreamCompletionReleaseCount = 0
if !hasKnownDuration && isLiveStream {
isLiveStream = false
}
}
}
private func updateArtwork(_ artworkData: Data) {
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
guard let self = self else { return }