mirror of
https://github.com/yattee/yattee.git
synced 2026-10-29 07:57:17 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user