feat: visualize retained network playback buffer
This commit is contained in:
@@ -87,6 +87,13 @@ final class PlayerModel: ObservableObject {
|
||||
var canClose: Bool { handle != nil && (isBusy || state.status != PLAYER_EMPTY || fileName != nil) }
|
||||
var canSetVolume: Bool { handle != nil && !isBusy && state.has_audio && state.status != PLAYER_ERROR }
|
||||
var duration: Double { state.duration.isFinite ? max(0, state.duration) : 0 }
|
||||
var bufferedRange: ClosedRange<Double>? {
|
||||
guard fileName != nil, canSeek, !seekPending, !state.seek_pending,
|
||||
state.buffered_start.isFinite, state.buffered_end.isFinite,
|
||||
state.buffered_start >= 0, state.buffered_end > state.buffered_start,
|
||||
state.buffered_end <= duration else { return nil }
|
||||
return state.buffered_start...state.buffered_end
|
||||
}
|
||||
var errorText: String? {
|
||||
if let commandError { return commandError }
|
||||
guard !isBusy, state.status == PLAYER_ERROR else { return nil }
|
||||
@@ -592,6 +599,7 @@ private struct PlaybackSlider: NSViewRepresentable {
|
||||
let range: ClosedRange<Double>
|
||||
var volume = false
|
||||
var enabled = true
|
||||
var bufferedRange: ClosedRange<Double>?
|
||||
var onEditingChanged: (Bool) -> Void = { _ in }
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
||||
@@ -614,6 +622,10 @@ private struct PlaybackSlider: NSViewRepresentable {
|
||||
slider.doubleValue = value
|
||||
slider.isEnabled = enabled
|
||||
slider.editingChanged = onEditingChanged
|
||||
if let cell = slider.cell as? Cell, cell.bufferedRange != bufferedRange {
|
||||
cell.bufferedRange = bufferedRange
|
||||
slider.needsDisplay = true
|
||||
}
|
||||
slider.setAccessibilityLabel(volume ? "Volume" : "Playback Position")
|
||||
}
|
||||
|
||||
@@ -643,6 +655,7 @@ private struct PlaybackSlider: NSViewRepresentable {
|
||||
|
||||
final class Cell: NSSliderCell {
|
||||
var volume = false
|
||||
var bufferedRange: ClosedRange<Double>?
|
||||
override func drawBar(inside rect: NSRect, flipped: Bool) {
|
||||
let bar = NSRect(x: rect.minX + knobThickness / 2, y: rect.midY - 1.5,
|
||||
width: max(0, rect.width - knobThickness), height: 3)
|
||||
@@ -653,6 +666,14 @@ private struct PlaybackSlider: NSViewRepresentable {
|
||||
let fill = NSRect(x: bar.minX, y: bar.minY, width: bar.width * fraction, height: bar.height)
|
||||
NSColor.systemOrange.withAlphaComponent(0.85).setFill()
|
||||
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
|
||||
} else if let bufferedRange, maxValue > minValue {
|
||||
let lower = min(maxValue, max(minValue, bufferedRange.lowerBound))
|
||||
let upper = min(maxValue, max(minValue, bufferedRange.upperBound))
|
||||
let scale = bar.width / (maxValue - minValue)
|
||||
let fill = NSRect(x: bar.minX + (lower - minValue) * scale, y: bar.minY,
|
||||
width: (upper - lower) * scale, height: bar.height)
|
||||
NSColor.white.withAlphaComponent(0.4).setFill()
|
||||
NSBezierPath(roundedRect: fill, xRadius: 1.5, yRadius: 1.5).fill()
|
||||
}
|
||||
}
|
||||
override func drawKnob(_ rect: NSRect) {
|
||||
@@ -832,7 +853,11 @@ private struct PlayerContent: View {
|
||||
}
|
||||
|
||||
private var playbackControls: some View {
|
||||
VStack(spacing: 8) {
|
||||
let buffered = model.bufferedRange
|
||||
let bufferedStatus = buffered.map {
|
||||
"Buffered through \(time($0.upperBound)) — \(String(format: "%.1f", $0.upperBound - $0.lowerBound)) seconds ahead"
|
||||
}
|
||||
return VStack(spacing: 8) {
|
||||
HStack(spacing: 0) {
|
||||
HStack(spacing: 4) {
|
||||
iconButton(model.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
||||
@@ -875,12 +900,13 @@ private struct PlayerContent: View {
|
||||
HStack(spacing: 10) {
|
||||
Text(time(model.seekPosition)).frame(minWidth: 32, alignment: .trailing)
|
||||
PlaybackSlider(value: $model.seekPosition, range: 0...max(model.duration, 0.001),
|
||||
enabled: model.canSeek, onEditingChanged: { editing in
|
||||
enabled: model.canSeek, bufferedRange: buffered, onEditingChanged: { editing in
|
||||
if editing { model.beginSeeking() } else { model.finishSeeking() }
|
||||
})
|
||||
.frame(height: 14)
|
||||
.accessibilityLabel("Playback Position")
|
||||
.accessibilityValue(time(model.seekPosition))
|
||||
.accessibilityValue(time(model.seekPosition) + (bufferedStatus.map { ", " + $0 } ?? ""))
|
||||
.help(bufferedStatus ?? "Playback Position")
|
||||
Button {
|
||||
showsRemainingTime.toggle()
|
||||
model.revealControls()
|
||||
|
||||
Reference in New Issue
Block a user