Stop re-rendering hidden player UI during background playback

Every mpv time-pos tick updated PlayerState.currentTime and bufferedTime,
re-rendering the invisible player controls, progress bar and chapter
segments several times per second while the app played in the
background, which drained the battery during long audio-only sessions.

- PlayerState keeps currentTime/bufferedTime current for readers but
  stops notifying observers while publishesTimeUpdates is off;
  PlayerService turns it off on entering the background and back on
  (with one catch-up notification) before returning to the foreground
- MarqueeText pauses its animation timeline outside the active scene
- Replace the 100 ms polling loops in RemoteControlCoordinator and
  ToastManager (iOS/tvOS) with observation-driven waits

Main-thread samples during 30 s of background audio-only playback on an
iPhone 12 mini went from 1289 to 248.
This commit is contained in:
Arkadiusz Fal
2026-10-24 17:32:34 +02:00
parent 2a49a5db68
commit 21a6238a16
6 changed files with 106 additions and 23 deletions

View File

@@ -211,6 +211,27 @@ final class PlayerService {
factory?.drainPool()
}
}
// Stop re-rendering the hidden player UI on every time update while
// the app is in the background (see PlayerState.publishesTimeUpdates).
NotificationCenter.default.addObserver(
forName: UIApplication.didEnterBackgroundNotification,
object: nil,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.state.publishesTimeUpdates = false
}
}
NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification,
object: nil,
queue: .main
) { [weak self] _ in
MainActor.assumeIsolated {
self?.state.publishesTimeUpdates = true
}
}
#endif
}

View File

@@ -219,16 +219,60 @@ final class PlayerState {
// MARK: - Time & Progress
/// Current playback time in seconds.
var currentTime: TimeInterval = 0 {
didSet { updateActiveChapter() }
/// Current playback time in seconds. Always current when read; observing
/// views are only notified while `publishesTimeUpdates` is true.
var currentTime: TimeInterval {
get {
access(keyPath: \.currentTime)
return storedCurrentTime
}
set {
if publishesTimeUpdates {
withMutation(keyPath: \.currentTime) { storedCurrentTime = newValue }
} else {
storedCurrentTime = newValue
}
updateActiveChapter()
}
}
@ObservationIgnored private var storedCurrentTime: TimeInterval = 0
/// 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
/// player UI (progress bar, time labels, chapter segments) several times
/// per second, which drains the battery during long background or
/// audio-only playback.
@ObservationIgnored var publishesTimeUpdates = true {
didSet {
// Resuming: publish the times accumulated while suppressed.
guard publishesTimeUpdates, !oldValue else { return }
withMutation(keyPath: \.currentTime) {}
withMutation(keyPath: \.bufferedTime) {}
}
}
/// Total duration in seconds.
var duration: TimeInterval = 0
/// Buffered time in seconds.
var bufferedTime: TimeInterval = 0
/// Buffered time in seconds. Follows `currentTime`, so it is published
/// under the same `publishesTimeUpdates` gate.
var bufferedTime: TimeInterval {
get {
access(keyPath: \.bufferedTime)
return storedBufferedTime
}
set {
if publishesTimeUpdates {
withMutation(keyPath: \.bufferedTime) { storedBufferedTime = newValue }
} else {
storedBufferedTime = newValue
}
}
}
@ObservationIgnored private var storedBufferedTime: TimeInterval = 0
/// Whether duration updates from the player backend should be ignored.
/// Set to true when playing through fast endpoint where duration is known from API

View File

@@ -229,16 +229,11 @@ final class RemoteControlCoordinator {
private func startIncognitoObserver() {
incognitoObserverTask?.cancel()
incognitoObserverTask = Task { [weak self] in
guard let settings = self?.settingsManager else { return }
while !Task.isCancelled {
guard let self, let settings = self.settingsManager else { break }
let wasIncognito = settings.incognitoModeEnabled
_ = withObservationTracking {
settings.incognitoModeEnabled
} onChange: { }
try? await Task.sleep(for: .milliseconds(100))
guard !Task.isCancelled else { break }
await observationChange { _ = settings.incognitoModeEnabled }
guard let self, !Task.isCancelled else { break }
let isIncognito = settings.incognitoModeEnabled
if isIncognito != wasIncognito {

View File

@@ -96,16 +96,14 @@ final class ToastManager {
var lastPlayerVisible = self.isPlayerVisible
while !Task.isCancelled {
// Wait for navigationCoordinator.isPlayerExpanded to change
// (On macOS, ExpandedPlayerWindowManager.isPresented is updated when window shows,
// but we track the NavigationCoordinator state which triggers the window)
if let coordinator = self.navigationCoordinator {
_ = withObservationTracking {
coordinator.isPlayerExpanded
} onChange: { }
}
#if os(macOS)
// ExpandedPlayerWindowManager.isPresented follows the window
// and is not observable, so poll it.
try? await Task.sleep(for: .milliseconds(100))
#else
guard let coordinator = self.navigationCoordinator else { break }
await observationChange { _ = coordinator.isPlayerExpanded }
#endif
guard !Task.isCancelled else { break }
let currentPlayerVisible = self.isPlayerVisible

View File

@@ -0,0 +1,21 @@
//
// ObservationChange.swift
// Yattee
//
import Foundation
import Observation
/// Suspends until a property read in `track` changes, or the task is cancelled.
///
/// Use in an observer loop instead of polling with `Task.sleep`, which keeps
/// waking the main thread for as long as the app runs.
@MainActor
func observationChange(_ track: () -> Void) async {
let (changes, continuation) = AsyncStream<Void>.makeStream()
withObservationTracking(track) {
continuation.yield()
continuation.finish()
}
for await _ in changes { break }
}

View File

@@ -22,6 +22,8 @@ struct MarqueeText: View {
@State private var textWidth: CGFloat = 0
@State private var startTime: Date?
@Environment(\.scenePhase) private var scenePhase
// MARK: - Computed Properties
private var needsScrolling: Bool {
@@ -68,7 +70,9 @@ struct MarqueeText: View {
// MARK: - Scrolling Content
private var scrollingContent: some View {
TimelineView(.animation) { context in
// 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
HStack(spacing: spacing) {
textView
textView