mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-27 15:14:33 +00:00
Working LIVE indicator
This commit is contained in:
parent
b053c4ca33
commit
fa1bd97d84
5 changed files with 255 additions and 58 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue