fix: make buffered time visible in both player modes
This commit is contained in:
@@ -64,7 +64,7 @@ Use **⇧⌘O → Open YouTube URL…**, the playback-actions menu, or drop a Yo
|
|||||||
|
|
||||||
yt-dlp defaults to the highest available SDR resolution at or below **1080p**, falling down to a lower resolution when 1080p is unavailable, with the best available audio. FFmpeg plays the selected HTTP(S) streams or native HLS manifests directly, including separate video/audio inputs or a combined stream: no embedded browser, temporary full-video download, or muxing step. Playback, coordinated seeking, frame stepping, volume, and same-window compact mode use the existing native player. **Buffering…** freezes the playback clock without disabling pause, close, or replacement; live/non-seekable inputs disable seeking and frame stepping, and live sources show **LIVE**.
|
yt-dlp defaults to the highest available SDR resolution at or below **1080p**, falling down to a lower resolution when 1080p is unavailable, with the best available audio. FFmpeg plays the selected HTTP(S) streams or native HLS manifests directly, including separate video/audio inputs or a combined stream: no embedded browser, temporary full-video download, or muxing step. Playback, coordinated seeking, frame stepping, volume, and same-window compact mode use the existing native player. **Buffering…** freezes the playback clock without disabling pause, close, or replacement; live/non-seekable inputs disable seeking and frame stepping, and live sources show **LIVE**.
|
||||||
|
|
||||||
The normal-mode seek bar highlights the **buffered range** in lighter gray ahead of the playhead. Hover the bar for the buffered-through timestamp and seconds retained ahead; VoiceOver includes the same information. This is a conservative playable range constrained by both required audio and video inputs, including decoded/output buffers—not a percentage downloaded or a persistent cache of the whole video. It resets on seeks, quality/source changes, and close. Local playback and live/unknown-duration media do not display it. On long videos the segment can be very short because the retained buffers are bounded.
|
Network playback controls show **Buffered: 1.6s ahead** in both normal and compact mode, so short buffers remain visible even on a multi-hour timeline without opening a tooltip. **Buffered: —** means no estimate is available, including during seeks or for live/unknown-duration media. The normal-mode seek bar also highlights the **buffered range** in lighter gray ahead of the playhead, drawn to scale; hover for the buffered-through timestamp, which VoiceOver also includes. This is a conservative playable range constrained by both required audio and video inputs, including decoded/output buffers—not a percentage downloaded or a persistent cache of the whole video. It resets on seeks, quality/source changes, and close. Local playback displays neither indicator. On long videos the shaded segment can be sub-pixel because the retained buffers are bounded; the readout remains legible whenever the controls are visible.
|
||||||
|
|
||||||
While playing YouTube, choose **gear → Quality** in either normal or compact mode. The dropdown lists the video's supported SDR resolutions, including 1440p/4K when available, and shows the current resolution. **Default (up to 1080p)** restores the cap. Switching resolves fresh stream URLs and briefly pauses while reopening, preserving the position for seekable recorded videos, play/pause state, and the same window, size, and mode. An extraction failure leaves the previous stream selected and restores its playback state. Live/non-seekable streams reopen without position restoration. New video opens use the default again; manual quality choices are not persisted.
|
While playing YouTube, choose **gear → Quality** in either normal or compact mode. The dropdown lists the video's supported SDR resolutions, including 1440p/4K when available, and shows the current resolution. **Default (up to 1080p)** restores the cap. Switching resolves fresh stream URLs and briefly pauses while reopening, preserving the position for seekable recorded videos, play/pause state, and the same window, size, and mode. An extraction failure leaves the previous stream selected and restores its playback state. Live/non-seekable streams reopen without position restoration. New video opens use the default again; manual quality choices are not persisted.
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
@Published private(set) var pendingRevision: UInt64?
|
@Published private(set) var pendingRevision: UInt64?
|
||||||
@Published private(set) var resolvingYouTube = false
|
@Published private(set) var resolvingYouTube = false
|
||||||
@Published private(set) var isLiveStream = false
|
@Published private(set) var isLiveStream = false
|
||||||
|
@Published private(set) var isNetworkMedia = false
|
||||||
@Published private(set) var youtubeHeights: [Int] = []
|
@Published private(set) var youtubeHeights: [Int] = []
|
||||||
@Published private(set) var youtubeHeight: Int?
|
@Published private(set) var youtubeHeight: Int?
|
||||||
@Published private(set) var youtubeQuality: Int?
|
@Published private(set) var youtubeQuality: Int?
|
||||||
@@ -246,6 +247,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
fileName = title
|
fileName = title
|
||||||
fileIcon = icon
|
fileIcon = icon
|
||||||
isLiveStream = live
|
isLiveStream = live
|
||||||
|
isNetworkMedia = !url.isFileURL
|
||||||
youtubeHeights = []
|
youtubeHeights = []
|
||||||
youtubeHeight = nil
|
youtubeHeight = nil
|
||||||
youtubeQuality = nil
|
youtubeQuality = nil
|
||||||
@@ -325,6 +327,7 @@ final class PlayerModel: ObservableObject {
|
|||||||
fileName = nil
|
fileName = nil
|
||||||
fileIcon = nil
|
fileIcon = nil
|
||||||
isLiveStream = false
|
isLiveStream = false
|
||||||
|
isNetworkMedia = false
|
||||||
youtubeHeights = []
|
youtubeHeights = []
|
||||||
youtubeHeight = nil
|
youtubeHeight = nil
|
||||||
youtubeQuality = nil
|
youtubeQuality = nil
|
||||||
@@ -750,6 +753,20 @@ private struct PlayerContent: View {
|
|||||||
@State private var controlOffset = CGSize.zero
|
@State private var controlOffset = CGSize.zero
|
||||||
@State private var dragOrigin = CGSize.zero
|
@State private var dragOrigin = CGSize.zero
|
||||||
|
|
||||||
|
private var controlHeight: CGFloat { model.isNetworkMedia ? 88 : 68 }
|
||||||
|
private var compactControlHeight: CGFloat { model.isNetworkMedia ? 52 : 40 }
|
||||||
|
|
||||||
|
private var bufferReadout: some View {
|
||||||
|
Text(model.bufferedRange.map {
|
||||||
|
"Buffered: \(String(format: "%.1f", $0.upperBound - $0.lowerBound))s ahead"
|
||||||
|
} ?? "Buffered: —")
|
||||||
|
.font(.system(size: 10).monospacedDigit())
|
||||||
|
.foregroundStyle(.white.opacity(0.75))
|
||||||
|
.lineLimit(1)
|
||||||
|
.frame(height: 12)
|
||||||
|
.help("Conservative playable buffer ahead; — means the estimate is unavailable.")
|
||||||
|
}
|
||||||
|
|
||||||
private func time(_ seconds: Double) -> String {
|
private func time(_ seconds: Double) -> String {
|
||||||
let value = Int(min(max(0, seconds.isFinite ? seconds : 0), Double(Int.max / 2)))
|
let value = Int(min(max(0, seconds.isFinite ? seconds : 0), Double(Int.max / 2)))
|
||||||
let hours = value / 3600
|
let hours = value / 3600
|
||||||
@@ -834,6 +851,7 @@ private struct PlayerContent: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var compactControls: some View {
|
private var compactControls: some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
HStack(spacing: 16) {
|
HStack(spacing: 16) {
|
||||||
iconButton(model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill",
|
iconButton(model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill",
|
||||||
model.state.status == PLAYER_PLAYING ? "Pause" : "Play", action: model.togglePlayback)
|
model.state.status == PLAYER_PLAYING ? "Pause" : "Play", action: model.togglePlayback)
|
||||||
@@ -842,8 +860,10 @@ private struct PlayerContent: View {
|
|||||||
.disabled(model.compactWindow.isTransitioning)
|
.disabled(model.compactWindow.isTransitioning)
|
||||||
actionsMenu
|
actionsMenu
|
||||||
}
|
}
|
||||||
|
if model.isNetworkMedia { bufferReadout }
|
||||||
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, model.isNetworkMedia ? 6 : 8)
|
||||||
.foregroundStyle(.white.opacity(0.85))
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 10)))
|
.background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 10)))
|
||||||
.onHover(perform: model.setControlsHovered)
|
.onHover(perform: model.setControlsHovered)
|
||||||
@@ -924,6 +944,7 @@ private struct PlayerContent: View {
|
|||||||
}
|
}
|
||||||
.font(.system(size: 11).monospacedDigit())
|
.font(.system(size: 11).monospacedDigit())
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
|
if model.isNetworkMedia { bufferReadout }
|
||||||
}
|
}
|
||||||
.padding(.vertical, 11)
|
.padding(.vertical, 11)
|
||||||
// Optical centering accounts for symbol insets and the native seek thumb.
|
// Optical centering accounts for symbol insets and the native seek thumb.
|
||||||
@@ -1015,14 +1036,14 @@ private struct PlayerContent: View {
|
|||||||
.allowsHitTesting(false)
|
.allowsHitTesting(false)
|
||||||
.accessibilityHidden(!model.controlsVisible)
|
.accessibilityHidden(!model.controlsVisible)
|
||||||
compactControls
|
compactControls
|
||||||
.position(x: geometry.size.width / 2, y: geometry.size.height - 28)
|
.position(x: geometry.size.width / 2, y: geometry.size.height - compactControlHeight / 2 - 8)
|
||||||
} else {
|
} else {
|
||||||
titlebar
|
titlebar
|
||||||
}
|
}
|
||||||
|
|
||||||
if model.canTogglePlayback && !model.isCompact {
|
if model.canTogglePlayback && !model.isCompact {
|
||||||
playbackControls
|
playbackControls
|
||||||
.frame(width: 440, height: 68)
|
.frame(width: 440, height: controlHeight)
|
||||||
.background {
|
.background {
|
||||||
PlayerGlass()
|
PlayerGlass()
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
@@ -1040,7 +1061,7 @@ private struct PlayerContent: View {
|
|||||||
}
|
}
|
||||||
.scaleEffect(min(1, (geometry.size.width - 32) / 440))
|
.scaleEffect(min(1, (geometry.size.width - 32) / 440))
|
||||||
.frame(width: min(440, geometry.size.width - 32),
|
.frame(width: min(440, geometry.size.width - 32),
|
||||||
height: 68 * min(1, (geometry.size.width - 32) / 440))
|
height: controlHeight * min(1, (geometry.size.width - 32) / 440))
|
||||||
.onGeometryChange(for: CGSize.self) { $0.size } action: { controlSize = $0 }
|
.onGeometryChange(for: CGSize.self) { $0.size } action: { controlSize = $0 }
|
||||||
.position(controllerPosition(in: geometry.size))
|
.position(controllerPosition(in: geometry.size))
|
||||||
.opacity(model.controlsVisible ? 1 : 0)
|
.opacity(model.controlsVisible ? 1 : 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user