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.
This commit is contained in:
Arkadiusz Fal
2026-10-25 14:22:26 +01:00
parent 9412ca916c
commit 728e3b7286
6 changed files with 69 additions and 35 deletions

View File

@@ -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 {

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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()
})

View File

@@ -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 {