From 728e3b72869d770d2b5ae1cd456b3edc34273502 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 25 Oct 2026 14:22:26 +0100 Subject: [PATCH] Cut foreground CPU during audio-only playback - MPVRenderView keeps its display link paused while the stream has no video, instead of rendering and presenting 60 empty frames a second - Chapter segments get clamped played/buffered fractions and are equatable, so a time update re-renders only the chapter under the playhead instead of every segment - The mini player pauses its scrolling titles while the expanded player covers it (iOS/tvOS) - Performance logging no longer flags the first sample after a foreground/background transition as high CPU With the expanded player open in audio-only mode on an iPhone 12 mini, app CPU dropped from ~30% to ~12% and wakeups from ~128/s to ~37/s. --- .../Services/Logging/PerformanceMonitor.swift | 6 +- .../Services/Player/MPV/MPVRenderView.swift | 18 ++++++ Yattee/Services/Player/MPVBackend.swift | 4 ++ Yattee/Views/Components/MarqueeText.swift | 12 +++- Yattee/Views/Player/MiniPlayerView.swift | 5 ++ .../Views/Player/SegmentedProgressBar.swift | 59 +++++++++---------- 6 files changed, 69 insertions(+), 35 deletions(-) diff --git a/Yattee/Services/Logging/PerformanceMonitor.swift b/Yattee/Services/Logging/PerformanceMonitor.swift index 5b9b4bcf..28cfbf90 100644 --- a/Yattee/Services/Logging/PerformanceMonitor.swift +++ b/Yattee/Services/Logging/PerformanceMonitor.swift @@ -222,7 +222,9 @@ final class PerformanceMonitor: @unchecked Sendable { lines.append(contentsOf: Self.describeCounts(counts, over: sample.wall)) lines.append("context: \(context)") - log(sample.cpuPercent >= threshold ? .warning : .info, message, details: lines.joined(separator: "\n")) + // The first sample of a period includes the transition work itself. + let isHigh = sample.cpuPercent >= threshold && (period?.sampleCount ?? 0) > 1 + log(isHigh ? .warning : .info, message, details: lines.joined(separator: "\n")) } /// Must run on `queue`. @@ -360,6 +362,7 @@ final class PerformanceMonitor: @unchecked Sendable { /// Totals for one foreground or background period. private struct Period { let background: Bool + var sampleCount = 0 var wall: TimeInterval = 0 var cpuSeconds: Double = 0 var maxCPU: Double = 0 @@ -372,6 +375,7 @@ private struct Period { } mutating func add(_ sample: ResourceSample, counts: [String: Int]) { + sampleCount += 1 wall += sample.wall cpuSeconds += sample.cpuPercent / 100 * sample.wall if sample.wall >= 1 { diff --git a/Yattee/Services/Player/MPV/MPVRenderView.swift b/Yattee/Services/Player/MPV/MPVRenderView.swift index 37a4a63b..55d336dc 100644 --- a/Yattee/Services/Player/MPV/MPVRenderView.swift +++ b/Yattee/Services/Player/MPV/MPVRenderView.swift @@ -134,6 +134,19 @@ final class MPVRenderView: UIView { /// Callback when view is added to a window (for PiP setup) var onDidMoveToWindow: ((UIView) -> Void)? + /// Set while the loaded stream has no video (audio-only). There is + /// nothing to draw, so the display link keeps itself paused instead of + /// rendering at display rate; any resume pauses it again on its first fire. + var hasNoVideo = false { + didSet { + guard hasNoVideo != oldValue else { return } + MPVLogging.log("hasNoVideo = \(hasNoVideo)") + if !hasNoVideo, !isInBackground { + displayLink?.isPaused = false + } + } + } + // MARK: - PiP Frame Capture /// Whether to capture frames for PiP (set by MPVBackend when PiP is active) @@ -937,6 +950,11 @@ final class MPVRenderView: UIView { diagnosticsCounters.increment(\.linkFires) PerfCounter.count("render.displayLink") + if hasNoVideo, !captureFramesForPiP { + displayLink?.isPaused = true + return + } + // Skip if not setup, already rendering, or app is in background (GPU work not permitted) guard isSetup, !isRendering, !isInBackground else { return } diff --git a/Yattee/Services/Player/MPVBackend.swift b/Yattee/Services/Player/MPVBackend.swift index 7b047ee5..47a59c8a 100644 --- a/Yattee/Services/Player/MPVBackend.swift +++ b/Yattee/Services/Player/MPVBackend.swift @@ -495,6 +495,10 @@ final class MPVBackend: PlayerBackend { // Clear the render view to black to hide any old frame from previous video renderView?.clearToBlack() + #if !os(macOS) && !targetEnvironment(simulator) + // Audio-only streams have nothing to draw; don't render at display rate. + renderView?.hasNoVideo = stream.isAudioOnly + #endif // Load the stream do { diff --git a/Yattee/Views/Components/MarqueeText.swift b/Yattee/Views/Components/MarqueeText.swift index b0db5dab..4f69b39f 100644 --- a/Yattee/Views/Components/MarqueeText.swift +++ b/Yattee/Views/Components/MarqueeText.swift @@ -23,6 +23,7 @@ struct MarqueeText: View { @State private var startTime: Date? @Environment(\.scenePhase) private var scenePhase + @Environment(\.isMarqueePaused) private var isMarqueePaused // MARK: - Computed Properties @@ -71,9 +72,9 @@ struct MarqueeText: View { // MARK: - Scrolling Content private var scrollingContent: some View { - // Paused outside the active scene: the timeline otherwise keeps - // re-rendering every frame while the app plays in the background. - TimelineView(.animation(paused: scenePhase != .active)) { context in + // Paused outside the active scene or when the container says it is + // hidden: the timeline otherwise re-renders every frame regardless. + TimelineView(.animation(paused: scenePhase != .active || isMarqueePaused)) { context in let _ = PerfCounter.body("MarqueeText.frame") HStack(spacing: spacing) { textView @@ -203,3 +204,8 @@ private extension View { } .padding() } + +extension EnvironmentValues { + /// Pauses `MarqueeText` scrolling, for containers that are covered or hidden. + @Entry var isMarqueePaused = false +} diff --git a/Yattee/Views/Player/MiniPlayerView.swift b/Yattee/Views/Player/MiniPlayerView.swift index c9399458..ea634512 100644 --- a/Yattee/Views/Player/MiniPlayerView.swift +++ b/Yattee/Views/Player/MiniPlayerView.swift @@ -153,6 +153,11 @@ struct MiniPlayerView: View { } .accessibilityIdentifier("player.miniPlayer") .accessibilityLabel("player.miniPlayer") + #if !os(macOS) + // Hidden under the expanded player: stop the scrolling titles. + // (macOS shows the expanded player in its own window.) + .environment(\.isMarqueePaused, navigationCoordinator?.isPlayerExpanded == true) + #endif .modifier(MiniPlayerContextMenuModifier(video: currentVideo) { closeVideo() }) diff --git a/Yattee/Views/Player/SegmentedProgressBar.swift b/Yattee/Views/Player/SegmentedProgressBar.swift index 08d31a48..99b58725 100644 --- a/Yattee/Views/Player/SegmentedProgressBar.swift +++ b/Yattee/Views/Player/SegmentedProgressBar.swift @@ -100,17 +100,20 @@ struct SegmentedProgressBar: View { let segmentWidth = segmentWidth(for: chapter, index: index, availableWidth: availableWidth) let chapterEnd = nextChapterStart(after: index) + // Fractions are clamped here, so only the chapter under the + // playhead (or buffer edge) gets new inputs on a time update. ChapterSegmentView( chapter: chapter, nextChapterStart: chapterEnd, - currentTime: currentTime, - bufferedTime: bufferedTime, + playedProgress: fraction(of: currentTime, from: chapter.startTime, to: chapterEnd), + bufferedProgress: fraction(of: bufferedTime, from: chapter.startTime, to: chapterEnd), playedColor: playedColor, bufferedColor: bufferedColor, backgroundColor: backgroundColor, sponsorSegments: segmentsInRange(start: chapter.startTime, end: chapterEnd), sponsorBlockSettings: sponsorBlockSettings ) + .equatable() .frame(width: segmentWidth, height: height) } } @@ -167,6 +170,13 @@ struct SegmentedProgressBar: View { return duration } + /// How far `time` is through the range, clamped to 0-1. + private func fraction(of time: TimeInterval, from start: TimeInterval, to end: TimeInterval) -> Double { + let length = end - start + guard length > 0 else { return 0 } + return min(max((time - start) / length, 0), 1) + } + /// Returns sponsor segments that overlap with the given time range. private func segmentsInRange(start: TimeInterval, end: TimeInterval) -> [SponsorBlockSegment] { visibleSponsorSegments.filter { segment in @@ -178,43 +188,30 @@ struct SegmentedProgressBar: View { // MARK: - Chapter Segment View /// A single chapter segment within the progress bar. -private struct ChapterSegmentView: View { +private struct ChapterSegmentView: View, Equatable { let chapter: VideoChapter let nextChapterStart: TimeInterval - let currentTime: TimeInterval - let bufferedTime: TimeInterval + /// How much of this chapter has been played (0-1). + let playedProgress: Double + /// How much of this chapter has been buffered (0-1). + let bufferedProgress: Double let playedColor: Color let bufferedColor: Color let backgroundColor: Color let sponsorSegments: [SponsorBlockSegment] let sponsorBlockSettings: SponsorBlockSegmentSettings - /// How much of this chapter has been played (0-1). - private var playedProgress: Double { - let chapterDuration = nextChapterStart - chapter.startTime - guard chapterDuration > 0 else { return 0 } - - if currentTime <= chapter.startTime { - return 0 - } else if currentTime >= nextChapterStart { - return 1 - } else { - return (currentTime - chapter.startTime) / chapterDuration - } - } - - /// How much of this chapter has been buffered (0-1). - private var bufferedProgress: Double { - let chapterDuration = nextChapterStart - chapter.startTime - guard chapterDuration > 0 else { return 0 } - - if bufferedTime <= chapter.startTime { - return 0 - } else if bufferedTime >= nextChapterStart { - return 1 - } else { - return (bufferedTime - chapter.startTime) / chapterDuration - } + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.chapter.id == rhs.chapter.id + && lhs.chapter.startTime == rhs.chapter.startTime + && lhs.nextChapterStart == rhs.nextChapterStart + && lhs.playedProgress == rhs.playedProgress + && lhs.bufferedProgress == rhs.bufferedProgress + && lhs.playedColor == rhs.playedColor + && lhs.bufferedColor == rhs.bufferedColor + && lhs.backgroundColor == rhs.backgroundColor + && lhs.sponsorSegments.map(\.uuid) == rhs.sponsorSegments.map(\.uuid) + && lhs.sponsorBlockSettings == rhs.sponsorBlockSettings } var body: some View {