From c09e66cb5a66b3d16101c16f23b14a1eb921df70 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 26 Oct 2026 19:35:12 +0100 Subject: [PATCH] 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. --- .../Services/Player/MPV/MPVRenderView.swift | 26 +++++++++++++++++++ Yattee/Services/Player/MPVBackend.swift | 3 +++ Yattee/Services/Player/PlayerService.swift | 17 +++++++++--- Yattee/Services/Player/PlayerState.swift | 8 ++++++ .../Player/ControlsSectionRenderer.swift | 22 +++++++++++----- Yattee/Views/Player/PlayerControlsView.swift | 13 +++++++--- 6 files changed, 75 insertions(+), 14 deletions(-) diff --git a/Yattee/Services/Player/MPV/MPVRenderView.swift b/Yattee/Services/Player/MPV/MPVRenderView.swift index 55d336dc..3c71d5f1 100644 --- a/Yattee/Services/Player/MPV/MPVRenderView.swift +++ b/Yattee/Services/Player/MPV/MPVRenderView.swift @@ -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 diff --git a/Yattee/Services/Player/MPVBackend.swift b/Yattee/Services/Player/MPVBackend.swift index 47a59c8a..7d9c699f 100644 --- a/Yattee/Services/Player/MPVBackend.swift +++ b/Yattee/Services/Player/MPVBackend.swift @@ -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) diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index 441d120c..566b9c0e 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -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, diff --git a/Yattee/Services/Player/PlayerState.swift b/Yattee/Services/Player/PlayerState.swift index 04c9bf32..7fb7b4e0 100644 --- a/Yattee/Services/Player/PlayerState.swift +++ b/Yattee/Services/Player/PlayerState.swift @@ -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). diff --git a/Yattee/Views/Player/ControlsSectionRenderer.swift b/Yattee/Views/Player/ControlsSectionRenderer.swift index 18bc53cf..6235ef85 100644 --- a/Yattee/Views/Player/ControlsSectionRenderer.swift +++ b/Yattee/Views/Player/ControlsSectionRenderer.swift @@ -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 } diff --git a/Yattee/Views/Player/PlayerControlsView.swift b/Yattee/Views/Player/PlayerControlsView.swift index 2c3a41f6..fcffed1f 100644 --- a/Yattee/Views/Player/PlayerControlsView.swift +++ b/Yattee/Views/Player/PlayerControlsView.swift @@ -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