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