Cut foreground CPU while playing and paused

- Send Now Playing elapsed time once per 5-second bucket; the modulo check
  fired for every frame of that second (12 updates/s on average).
- Skip mpv renders while paused unless mpv requests a redraw (seek, new
  frame, PiP change, new load) instead of redrawing the same frame at
  display rate.
- Read playback time untracked in the progress bar and time labels while
  the controls are hidden, so they are not re-rendered on every tick.
- Only clear the current SponsorBlock segment when one is set.
This commit is contained in:
Arkadiusz Fal
2026-10-26 19:35:12 +01:00
parent 73d491c56b
commit c09e66cb5a
6 changed files with 75 additions and 14 deletions

View File

@@ -147,6 +147,20 @@ final class MPVRenderView: UIView {
}
}
/// Set while playback is paused. The frame on screen stays current until
/// mpv asks for a redraw (seek, track or subtitle change), so the display
/// link skips rendering instead of redrawing the same frame at display rate.
var isPlaybackPaused = false {
didSet {
guard isPlaybackPaused != oldValue else { return }
needsRedraw = true
}
}
/// Set by mpv's render update callback; cleared when a render is queued.
/// Main thread only.
private var needsRedraw = true
// MARK: - PiP Frame Capture
/// Whether to capture frames for PiP (set by MPVBackend when PiP is active)
@@ -168,6 +182,8 @@ final class MPVRenderView: UIView {
/// Whether PiP is currently active (skip presenting to main view to save performance)
var isPiPActive = false {
didSet {
// The main view wasn't presented to during PiP
needsRedraw = true
// When PiP ends and view is not in hierarchy, stop the display link
// (we kept it running for PiP frame capture, now it can be stopped)
if !isPiPActive && superview == nil && !captureFramesForPiP {
@@ -665,6 +681,7 @@ final class MPVRenderView: UIView {
// Set up render update callback (for general redraws)
client.onRenderUpdate = { [weak self] in
DispatchQueue.main.async {
self?.needsRedraw = true
self?.setNeedsDisplay()
}
}
@@ -923,6 +940,7 @@ final class MPVRenderView: UIView {
frameGeneration += 1 // Invalidate any pending callbacks from previous video
hasRenderedFirstFrame = false
mpvHasFrameReady = false
needsRedraw = true
}
/// Clear the render view to black (call when switching videos to hide old frame)
@@ -958,6 +976,14 @@ final class MPVRenderView: UIView {
// Skip if not setup, already rendering, or app is in background (GPU work not permitted)
guard isSetup, !isRendering, !isInBackground else { return }
// Paused with the current frame already on screen: nothing new to draw.
// PiP capture keeps feeding its sample buffer layer at its own pace.
if isPlaybackPaused, !needsRedraw, hasRenderedFirstFrame, !captureFramesForPiP {
PerfCounter.count("render.skippedPaused")
return
}
needsRedraw = false
isRendering = true
// Dispatch rendering to background queue to avoid blocking main thread

View File

@@ -1553,6 +1553,9 @@ extension MPVBackend: MPVClientDelegate {
if let paused = value as? Bool {
LoggingService.shared.debug("MPV: pause = \(paused), isReady = \(isReady), hasReachedEOF = \(hasReachedEOF)", category: .mpv)
isPlaying = !paused
#if !os(macOS) && !targetEnvironment(simulator)
renderView?.isPlaybackPaused = paused
#endif
// Only send state changes after we're ready (have actual video)
// This prevents premature transition from loading to playing
// Don't override ended state when MPV pauses at EOF (keep-open=yes)

View File

@@ -143,6 +143,9 @@ final class PlayerService {
/// Tracks the last skipped segment to prevent duplicate skips.
private var lastSkippedSegmentID: String?
/// 5-second bucket of the last Now Playing elapsed-time update.
private var lastNowPlayingTimeBucket: Int?
/// Tracks if the current video ended naturally (reached EOF).
/// Used to prevent saving progress when switching to next video after natural completion.
private var videoEndedNaturally = false
@@ -2906,7 +2909,9 @@ final class PlayerService {
}
}
} else {
state.currentSegment = nil
if state.currentSegment != nil {
state.currentSegment = nil
}
// Clear last skipped when not in any segment (allows re-skip if user seeks back)
lastSkippedSegmentID = nil
}
@@ -2942,6 +2947,7 @@ final class PlayerService {
progressSaveTimer?.invalidate()
progressSaveTimer = nil
lastNowPlayingTimeBucket = nil
// Remove audio session interruption observer
#if os(iOS) || os(tvOS)
@@ -3051,9 +3057,12 @@ extension PlayerService: PlayerBackendDelegate {
delegate?.playerService(self, didUpdateTime: time)
checkSponsorBlockSegments(at: time)
// Update Now Playing time periodically (every ~5 seconds to avoid excessive updates)
let shouldUpdateNowPlaying = Int(time) % 5 == 0
if shouldUpdateNowPlaying {
// Update Now Playing time once per 5-second bucket. Time updates arrive
// every frame, so a plain `Int(time) % 5 == 0` check would fire for every
// frame of that second.
let nowPlayingBucket = Int(time) / 5
if nowPlayingBucket != lastNowPlayingTimeBucket {
lastNowPlayingTimeBucket = nowPlayingBucket
nowPlayingService.updatePlaybackTime(
currentTime: time,
duration: state.duration,

View File

@@ -240,6 +240,11 @@ final class PlayerState {
@ObservationIgnored private var storedCurrentTime: TimeInterval = 0
/// `currentTime` without observing it, for views that are hidden: they
/// show the latest value whenever they render for another reason but are
/// not re-rendered on every time update.
var untrackedCurrentTime: TimeInterval { storedCurrentTime }
/// When false, `currentTime` and `bufferedTime` keep updating without
/// invalidating views that read them. Turned off while the app is in the
/// background: every mpv time-pos tick otherwise re-renders the hidden
@@ -276,6 +281,9 @@ final class PlayerState {
@ObservationIgnored private var storedBufferedTime: TimeInterval = 0
/// `bufferedTime` without observing it (see `untrackedCurrentTime`).
var untrackedBufferedTime: TimeInterval { storedBufferedTime }
/// Whether duration updates from the player backend should be ignored.
/// Set to true when playing through fast endpoint where duration is known from API
/// but file size is unknown (progressive download).

View File

@@ -624,13 +624,13 @@ struct ControlsSectionRenderer: View {
switch format {
case .currentOnly:
Text(playerState.formattedCurrentTime)
Text(formattedCurrentTime)
.font(timeFont)
.foregroundStyle(.white)
case .currentAndTotal:
HStack(spacing: 0) {
Text(playerState.formattedCurrentTime)
Text(formattedCurrentTime)
.font(timeFont)
.foregroundStyle(.white)
@@ -646,7 +646,7 @@ struct ControlsSectionRenderer: View {
case .currentAndTotalExcludingSponsor:
// TODO: Calculate SponsorBlock-adjusted duration
HStack(spacing: 0) {
Text(playerState.formattedCurrentTime)
Text(formattedCurrentTime)
.font(timeFont)
.foregroundStyle(.white)
@@ -661,7 +661,7 @@ struct ControlsSectionRenderer: View {
case .currentAndRemaining:
HStack(spacing: 0) {
Text(playerState.formattedCurrentTime)
Text(formattedCurrentTime)
.font(timeFont)
.foregroundStyle(.white)
@@ -677,7 +677,7 @@ struct ControlsSectionRenderer: View {
case .currentAndRemainingExcludingSponsor:
// TODO: Calculate SponsorBlock-adjusted remaining
HStack(spacing: 0) {
Text(playerState.formattedCurrentTime)
Text(formattedCurrentTime)
.font(timeFont)
.foregroundStyle(.white)
@@ -697,8 +697,18 @@ struct ControlsSectionRenderer: View {
.truncationMode(.middle)
}
/// Current time for the time display. Read untracked while the controls
/// are hidden so the invisible labels aren't re-rendered on every time update.
private var displayedCurrentTime: TimeInterval {
controlsVisible ? actions.playerState.currentTime : actions.playerState.untrackedCurrentTime
}
private var formattedCurrentTime: String {
displayedCurrentTime.formattedAsTimestamp
}
private var formattedRemainingTime: String {
let remaining = max(0, actions.playerState.duration - actions.playerState.currentTime)
let remaining = max(0, actions.playerState.duration - displayedCurrentTime)
return remaining.formattedAsTimestamp
}

View File

@@ -1028,12 +1028,16 @@ struct PlayerControlsView: View {
.frame(height: 4)
.clipShape(Capsule())
} else {
// Regular VOD progress bar with chapter segments
// Regular VOD progress bar with chapter segments.
// While the controls are hidden, read the times untracked so
// the invisible bar isn't re-rendered on every time update.
let isVisible = shouldShowControls
SegmentedProgressBar(
chapters: activeLayout.progressBarSettings.showChapters ? playerState.chapters : [],
duration: playerState.duration,
currentTime: (isDragging || isPendingSeek) ? dragProgress * playerState.duration : playerState.currentTime,
bufferedTime: playerState.bufferedTime,
currentTime: (isDragging || isPendingSeek) ? dragProgress * playerState.duration
: isVisible ? playerState.currentTime : playerState.untrackedCurrentTime,
bufferedTime: isVisible ? playerState.bufferedTime : playerState.untrackedBufferedTime,
height: 4,
gapWidth: 2,
playedColor: activeLayout.progressBarSettings.playedColor.color,
@@ -1101,7 +1105,8 @@ struct PlayerControlsView: View {
}
}
}
.onChange(of: playerState.progress) { _, newProgress in
// Only observed while a seek is pending (progress changes every time update).
.onChange(of: isPendingSeek ? playerState.progress : -1) { _, newProgress in
// Clear isPendingSeek when playerState.progress converges to target (within 2%)
if isPendingSeek && abs(newProgress - targetSeekProgress) < 0.02 {
isPendingSeek = false