From fa1bd97d847e2a4148304a2c618f65cf1fe62301 Mon Sep 17 00:00:00 2001 From: Hariharan Mudaliar Date: Fri, 7 Nov 2025 15:14:14 +0530 Subject: [PATCH] Working LIVE indicator --- DynamicIsland/Localizable.xcstrings | 8 ++ .../Music/LiveStreamProgressIndicator.swift | 68 ++++++++++++++ .../Notch/MinimalisticMusicPlayerView.swift | 91 ++++++++++--------- .../components/Notch/NotchHomeView.swift | 71 +++++++++++---- DynamicIsland/managers/MusicManager.swift | 77 ++++++++++++++++ 5 files changed, 256 insertions(+), 59 deletions(-) create mode 100644 DynamicIsland/components/Music/LiveStreamProgressIndicator.swift diff --git a/DynamicIsland/Localizable.xcstrings b/DynamicIsland/Localizable.xcstrings index 2210001..4df518e 100644 --- a/DynamicIsland/Localizable.xcstrings +++ b/DynamicIsland/Localizable.xcstrings @@ -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, diff --git a/DynamicIsland/components/Music/LiveStreamProgressIndicator.swift b/DynamicIsland/components/Music/LiveStreamProgressIndicator.swift new file mode 100644 index 0000000..5ca0f58 --- /dev/null +++ b/DynamicIsland/components/Music/LiveStreamProgressIndicator.swift @@ -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) + } +} diff --git a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift index 4726843..e10e5f9 100644 --- a/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift +++ b/DynamicIsland/components/Notch/MinimalisticMusicPlayerView.swift @@ -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 { diff --git a/DynamicIsland/components/Notch/NotchHomeView.swift b/DynamicIsland/components/Notch/NotchHomeView.swift index 1744fb5..40eb0d8 100644 --- a/DynamicIsland/components/Notch/NotchHomeView.swift +++ b/DynamicIsland/components/Notch/NotchHomeView.swift @@ -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 diff --git a/DynamicIsland/managers/MusicManager.swift b/DynamicIsland/managers/MusicManager.swift index 3691163..0604315 100644 --- a/DynamicIsland/managers/MusicManager.swift +++ b/DynamicIsland/managers/MusicManager.swift @@ -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 }