mirror of
https://github.com/FBukovina/Atoll.git
synced 2026-10-28 15:44:32 +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" : {
|
"Live Monitoring" : {
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
"fr" : {
|
"fr" : {
|
||||||
|
|
@ -10499,6 +10503,10 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"Live stream indicator" : {
|
||||||
|
"comment" : "A visual indicator that appears when a music stream is live.",
|
||||||
|
"isCommentAutoGenerated" : true
|
||||||
|
},
|
||||||
"Load Average" : {
|
"Load Average" : {
|
||||||
"comment" : "A label displayed above the load average values.",
|
"comment" : "A label displayed above the load average values.",
|
||||||
"isCommentAutoGenerated" : true,
|
"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 {
|
private var progressBar: some View {
|
||||||
TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in
|
TimelineView(.animation(minimumInterval: musicManager.playbackRate > 0 ? 0.1 : nil)) { timeline in
|
||||||
let currentElapsed = currentSliderValue(timeline.date)
|
if musicManager.isLiveStream {
|
||||||
|
HStack(spacing: 8) {
|
||||||
HStack(spacing: 8) {
|
Spacer()
|
||||||
// Elapsed time - left
|
.frame(width: 42)
|
||||||
Text(formatTime(dragging ? sliderValue : currentElapsed))
|
LiveStreamProgressIndicator(tint: sliderColor)
|
||||||
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
.frame(maxWidth: .infinity, minHeight: 6, maxHeight: 6)
|
||||||
.foregroundColor(.white.opacity(0.7))
|
Spacer()
|
||||||
.frame(width: 42, alignment: .leading)
|
.frame(width: 48)
|
||||||
|
}
|
||||||
// Progress bar
|
.allowsHitTesting(false)
|
||||||
GeometryReader { geometry in
|
} else {
|
||||||
ZStack(alignment: .leading) {
|
let currentElapsed = currentSliderValue(timeline.date)
|
||||||
// Background track
|
|
||||||
RoundedRectangle(cornerRadius: 3)
|
HStack(spacing: 8) {
|
||||||
.fill(Color.white.opacity(0.2))
|
Text(formatTime(dragging ? sliderValue : currentElapsed))
|
||||||
.frame(height: 6)
|
.font(.system(size: 11, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundColor(.white.opacity(0.7))
|
||||||
// Filled portion
|
.frame(width: 42, alignment: .leading)
|
||||||
RoundedRectangle(cornerRadius: 3)
|
|
||||||
.fill(sliderColor)
|
GeometryReader { geometry in
|
||||||
.frame(width: max(0, geometry.size.width * (currentSliderValue(timeline.date) / max(musicManager.songDuration, 1))), height: 6)
|
ZStack(alignment: .leading) {
|
||||||
}
|
RoundedRectangle(cornerRadius: 3)
|
||||||
.gesture(
|
.fill(Color.white.opacity(0.2))
|
||||||
DragGesture(minimumDistance: 0)
|
.frame(height: 6)
|
||||||
.onChanged { value in
|
|
||||||
dragging = true
|
RoundedRectangle(cornerRadius: 3)
|
||||||
let newValue = min(max(0, Double(value.location.x / geometry.size.width) * musicManager.songDuration), musicManager.songDuration)
|
.fill(sliderColor)
|
||||||
sliderValue = newValue
|
.frame(width: max(0, geometry.size.width * (currentSliderValue(timeline.date) / max(musicManager.songDuration, 1))), height: 6)
|
||||||
lastDragged = Date()
|
}
|
||||||
}
|
.gesture(
|
||||||
.onEnded { _ in
|
DragGesture(minimumDistance: 0)
|
||||||
musicManager.seek(to: sliderValue)
|
.onChanged { value in
|
||||||
dragging = false
|
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 {
|
.onAppear {
|
||||||
|
|
|
||||||
|
|
@ -170,8 +170,10 @@ struct MusicControlsView: View {
|
||||||
timestampDate: musicManager.timestampDate,
|
timestampDate: musicManager.timestampDate,
|
||||||
elapsedTime: musicManager.elapsedTime,
|
elapsedTime: musicManager.elapsedTime,
|
||||||
playbackRate: musicManager.playbackRate,
|
playbackRate: musicManager.playbackRate,
|
||||||
isPlaying: musicManager.isPlaying
|
isPlaying: musicManager.isPlaying,
|
||||||
|
isLiveStream: musicManager.isLiveStream
|
||||||
) { newValue in
|
) { newValue in
|
||||||
|
guard !musicManager.isLiveStream else { return }
|
||||||
MusicManager.shared.seek(to: newValue)
|
MusicManager.shared.seek(to: newValue)
|
||||||
}
|
}
|
||||||
.padding(.top, 5)
|
.padding(.top, 5)
|
||||||
|
|
@ -296,38 +298,71 @@ struct MusicSliderView: View {
|
||||||
let elapsedTime: Double
|
let elapsedTime: Double
|
||||||
let playbackRate: Double
|
let playbackRate: Double
|
||||||
let isPlaying: Bool
|
let isPlaying: Bool
|
||||||
|
let isLiveStream: Bool
|
||||||
var onValueChange: (Double) -> Void
|
var onValueChange: (Double) -> Void
|
||||||
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack {
|
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(
|
CustomSlider(
|
||||||
value: $sliderValue,
|
value: $sliderValue,
|
||||||
range: 0 ... duration,
|
range: 0 ... duration,
|
||||||
color: Defaults[.sliderColor] == SliderColorEnum.albumArt ? Color(
|
color: sliderTint,
|
||||||
nsColor: color
|
|
||||||
).ensureMinimumBrightness(factor: 0.8) : Defaults[.sliderColor] == SliderColorEnum.accent ? .accentColor : .white,
|
|
||||||
dragging: $dragging,
|
dragging: $dragging,
|
||||||
lastDragged: $lastDragged,
|
lastDragged: $lastDragged,
|
||||||
onValueChange: onValueChange
|
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 {
|
func timeString(from seconds: Double) -> String {
|
||||||
let totalMinutes = Int(seconds) / 60
|
let totalMinutes = Int(seconds) / 60
|
||||||
let remainingSeconds = Int(seconds) % 60
|
let remainingSeconds = Int(seconds) % 60
|
||||||
|
|
@ -340,8 +375,10 @@ struct MusicSliderView: View {
|
||||||
return String(format: "%d:%02d", minutes, remainingSeconds)
|
return String(format: "%d:%02d", minutes, remainingSeconds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
struct CustomSlider: View {
|
struct CustomSlider: View {
|
||||||
@Binding var value: Double
|
@Binding var value: Double
|
||||||
var range: ClosedRange<Double>
|
var range: ClosedRange<Double>
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,17 @@ class MusicManager: ObservableObject {
|
||||||
@Published var playbackRate: Double = 1
|
@Published var playbackRate: Double = 1
|
||||||
@Published var isShuffled: Bool = false
|
@Published var isShuffled: Bool = false
|
||||||
@Published var repeatMode: RepeatMode = .off
|
@Published var repeatMode: RepeatMode = .off
|
||||||
|
@Published var isLiveStream: Bool = false
|
||||||
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
@ObservedObject var coordinator = DynamicIslandViewCoordinator.shared
|
||||||
@Published var usingAppIconForArtwork: Bool = false
|
@Published var usingAppIconForArtwork: Bool = false
|
||||||
|
|
||||||
private var artworkData: Data? = nil
|
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
|
// Store last values at the time artwork was changed
|
||||||
private var lastArtworkTitle: String = "I'm Handsome"
|
private var lastArtworkTitle: String = "I'm Handsome"
|
||||||
private var lastArtworkArtist: String = "Me"
|
private var lastArtworkArtist: String = "Me"
|
||||||
|
|
@ -262,6 +268,7 @@ class MusicManager: ObservableObject {
|
||||||
self.repeatMode = state.repeatMode
|
self.repeatMode = state.repeatMode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateLiveStreamState(with: state)
|
||||||
self.timestampDate = state.lastUpdated
|
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) {
|
private func updateArtwork(_ artworkData: Data) {
|
||||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue