From 21a6238a16fd8177d021ddafd3618329531c7d07 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sat, 24 Oct 2026 17:32:34 +0200 Subject: [PATCH] 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. --- Yattee/Services/Player/PlayerService.swift | 21 ++++++++ Yattee/Services/Player/PlayerState.swift | 54 +++++++++++++++++-- .../RemoteControlCoordinator.swift | 11 ++-- Yattee/Services/ToastManager.swift | 16 +++--- Yattee/Utilities/ObservationChange.swift | 21 ++++++++ Yattee/Views/Components/MarqueeText.swift | 6 ++- 6 files changed, 106 insertions(+), 23 deletions(-) create mode 100644 Yattee/Utilities/ObservationChange.swift diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index 11758804..e78f375b 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -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 } diff --git a/Yattee/Services/Player/PlayerState.swift b/Yattee/Services/Player/PlayerState.swift index 09e0ec87..df6b8981 100644 --- a/Yattee/Services/Player/PlayerState.swift +++ b/Yattee/Services/Player/PlayerState.swift @@ -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 diff --git a/Yattee/Services/RemoteControl/RemoteControlCoordinator.swift b/Yattee/Services/RemoteControl/RemoteControlCoordinator.swift index b3b40a11..2bec7b13 100644 --- a/Yattee/Services/RemoteControl/RemoteControlCoordinator.swift +++ b/Yattee/Services/RemoteControl/RemoteControlCoordinator.swift @@ -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 { diff --git a/Yattee/Services/ToastManager.swift b/Yattee/Services/ToastManager.swift index 8a0e812c..5b290e7c 100644 --- a/Yattee/Services/ToastManager.swift +++ b/Yattee/Services/ToastManager.swift @@ -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 diff --git a/Yattee/Utilities/ObservationChange.swift b/Yattee/Utilities/ObservationChange.swift new file mode 100644 index 00000000..120cf942 --- /dev/null +++ b/Yattee/Utilities/ObservationChange.swift @@ -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.makeStream() + withObservationTracking(track) { + continuation.yield() + continuation.finish() + } + for await _ in changes { break } +} diff --git a/Yattee/Views/Components/MarqueeText.swift b/Yattee/Views/Components/MarqueeText.swift index fce83fe3..9be4e07d 100644 --- a/Yattee/Views/Components/MarqueeText.swift +++ b/Yattee/Views/Components/MarqueeText.swift @@ -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