From 041d2b0644e12f6825138b453a46348d4d5f0ca8 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 10:45:42 +0200 Subject: [PATCH] fix: make buffered time visible in both player modes --- README.md | 2 +- Sources/App/TerebiApp.swift | 43 +++++++++++++++++++++++++++---------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index b4a57a5..e72ef8f 100644 --- a/README.md +++ b/README.md @@ -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**. -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. diff --git a/Sources/App/TerebiApp.swift b/Sources/App/TerebiApp.swift index b7ffdda..fa152cb 100644 --- a/Sources/App/TerebiApp.swift +++ b/Sources/App/TerebiApp.swift @@ -12,6 +12,7 @@ final class PlayerModel: ObservableObject { @Published private(set) var pendingRevision: UInt64? @Published private(set) var resolvingYouTube = false @Published private(set) var isLiveStream = false + @Published private(set) var isNetworkMedia = false @Published private(set) var youtubeHeights: [Int] = [] @Published private(set) var youtubeHeight: Int? @Published private(set) var youtubeQuality: Int? @@ -246,6 +247,7 @@ final class PlayerModel: ObservableObject { fileName = title fileIcon = icon isLiveStream = live + isNetworkMedia = !url.isFileURL youtubeHeights = [] youtubeHeight = nil youtubeQuality = nil @@ -325,6 +327,7 @@ final class PlayerModel: ObservableObject { fileName = nil fileIcon = nil isLiveStream = false + isNetworkMedia = false youtubeHeights = [] youtubeHeight = nil youtubeQuality = nil @@ -750,6 +753,20 @@ private struct PlayerContent: View { @State private var controlOffset = 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 { let value = Int(min(max(0, seconds.isFinite ? seconds : 0), Double(Int.max / 2))) let hours = value / 3600 @@ -834,16 +851,19 @@ private struct PlayerContent: View { } private var compactControls: some View { - HStack(spacing: 16) { - iconButton(model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill", - model.state.status == PLAYER_PLAYING ? "Pause" : "Play", action: model.togglePlayback) - .disabled(!model.canTogglePlayback) - iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact) - .disabled(model.compactWindow.isTransitioning) - actionsMenu + VStack(spacing: 4) { + HStack(spacing: 16) { + iconButton(model.state.status == PLAYER_PLAYING ? "pause.fill" : "play.fill", + model.state.status == PLAYER_PLAYING ? "Pause" : "Play", action: model.togglePlayback) + .disabled(!model.canTogglePlayback) + iconButton("pip.exit", "Exit Compact Mode", action: model.toggleCompact) + .disabled(model.compactWindow.isTransitioning) + actionsMenu + } + if model.isNetworkMedia { bufferReadout } } .padding(.horizontal, 12) - .padding(.vertical, 8) + .padding(.vertical, model.isNetworkMedia ? 6 : 8) .foregroundStyle(.white.opacity(0.85)) .background(PlayerGlass().clipShape(RoundedRectangle(cornerRadius: 10))) .onHover(perform: model.setControlsHovered) @@ -924,6 +944,7 @@ private struct PlayerContent: View { } .font(.system(size: 11).monospacedDigit()) .padding(.horizontal, 16) + if model.isNetworkMedia { bufferReadout } } .padding(.vertical, 11) // Optical centering accounts for symbol insets and the native seek thumb. @@ -1015,14 +1036,14 @@ private struct PlayerContent: View { .allowsHitTesting(false) .accessibilityHidden(!model.controlsVisible) 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 { titlebar } if model.canTogglePlayback && !model.isCompact { playbackControls - .frame(width: 440, height: 68) + .frame(width: 440, height: controlHeight) .background { PlayerGlass() .clipShape(RoundedRectangle(cornerRadius: 12)) @@ -1040,7 +1061,7 @@ private struct PlayerContent: View { } .scaleEffect(min(1, (geometry.size.width - 32) / 440)) .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 } .position(controllerPosition(in: geometry.size)) .opacity(model.controlsVisible ? 1 : 0)