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