fix: prevent idle sleep during playback

This commit is contained in:
2026-10-17 22:05:27 +02:00
parent f49eb6d084
commit 2b96a165d2
7 changed files with 82 additions and 7 deletions
+12
View File
@@ -75,6 +75,8 @@ struct CastPlaybackCheck {
try await until(model, "pending LOAD follows acknowledged local pause") { model.state.status == PLAYER_PAUSED }
}
}
requirePlaybackPower(system: !active || scenario == "disconnect-active", display: scenario == "cancel-connect",
"handoff keeps the relay awake even after the local decoder pauses")
if scenario == "reject-load" {
try signal("observed")
try await until(model, "LOAD rejection restores original local playback", permitError: true) {
@@ -107,6 +109,7 @@ struct CastPlaybackCheck {
if scenario.hasPrefix("shutdown-") {
require(model.castDevice == nil && !model.castConnecting && !model.canOpen && !model.canClose,
"shutdown releases Cast ownership and permanently disables the local player")
requirePlaybackPower(system: false, display: false, "interrupted Cast shutdown releases sleep prevention")
require(!model.setPlayback(playing: true) && !model.seekRelative(5) && model.volume == 0,
"late receiver traffic cannot resume a destroyed player or replace local volume")
print("PASS: " + scenario)
@@ -187,29 +190,36 @@ struct CastPlaybackCheck {
model.setVolume(0)
model.open(source)
try await until(model, "local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
requirePlaybackPower(system: true, display: true, "local video prevents system and display sleep")
require(model.setPlayback(playing: false), "local pause accepted")
try await until(model, "local pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
requirePlaybackPower(system: false, display: false, "local pause permits sleep")
require(model.seekRelative(9.25 - model.state.position), "local restoration position accepted")
require(!model.canCast, "Cast cannot transfer an unacknowledged local seek")
try await until(model, "local position acknowledged") { !model.state.seek_pending && abs(model.state.position - 9.25) < 0.15 }
model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port))
requirePlaybackPower(system: true, display: false, "pending Cast handoff protects the relay")
try await until(model, "receiver load acknowledges paused handoff") {
model.castDevice != nil && !model.castConnecting && model.canSeek && model.state.status == PLAYER_PAUSED
}
requirePlaybackPower(system: false, display: false, "paused Cast permits sleep")
require(abs(model.state.position - 9.25) < 0.15, "Cast starts at acknowledged local position")
require(!model.canStepFrame && model.bufferedRange == nil, "local decoder-only controls are not exposed while casting")
require(model.setPlayback(playing: true), "remote play accepted")
try await until(model, "receiver play acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil }
requirePlaybackPower(system: true, display: false, "playing Cast protects the relay but permits display sleep")
for _ in 0..<64 { require(model.seekRelative(0.125), "rapid remote skips accumulate beyond queue capacity") }
require(model.seekRelative(7), "final remote skip retains accumulated target")
try await until(model, "receiver seek acknowledged") { !model.state.seek_pending && abs(model.state.position - 24.25) < 0.15 }
require(model.setPlayback(playing: false), "remote pause accepted")
try await until(model, "receiver pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
requirePlaybackPower(system: false, display: false, "receiver pause permits sleep")
model.stopCasting()
try await until(model, "local decoder restores receiver position paused") {
model.castDevice == nil && model.state.status == PLAYER_PAUSED && !model.state.seek_pending &&
abs(model.state.position - 24.25) < 0.15 && !model.isBusy
}
requirePlaybackPower(system: false, display: false, "paused local restoration permits sleep")
require(abs((PlaybackProgress(defaults: defaults).position(for: source) ?? 0) - 24.25) < 0.15,
"remote history is not overwritten by the abandoned local position")
require(model.volume == 0, "local volume survives receiver volume changes")
@@ -220,8 +230,10 @@ struct CastPlaybackCheck {
model.castDevice == nil && model.commandError != nil && model.state.status == PLAYER_PLAYING
}
require(model.canSeek && model.state.position >= 24, "connection failure preserves a usable local timeline")
requirePlaybackPower(system: true, display: true, "failed Cast restores local display protection")
model.closeFile()
try await until(model, "closed media releases Cast and decoder") { model.state.status == PLAYER_EMPTY }
requirePlaybackPower(system: false, display: false, "closing Cast media releases sleep prevention")
print("PASS: real local decode → TLS Cast LOAD/play/seek/pause → local restoration/history; failed connection recovery")
}
}
+19
View File
@@ -0,0 +1,19 @@
import Foundation
import IOKit.pwr_mgt
// Inspect this process, not global assertions from audio drivers or other apps.
func requirePlaybackPower(system: Bool, display: Bool, _ context: String) {
var result: Unmanaged<CFDictionary>?
precondition(IOPMCopyAssertionsByProcess(&result) == kIOReturnSuccess,
"Could not inspect macOS power assertions")
let byProcess = result!.takeRetainedValue() as NSDictionary
let assertions = byProcess[NSNumber(value: ProcessInfo.processInfo.processIdentifier)] as? [[String: Any]] ?? []
let active = assertions.filter { ($0[kIOPMAssertionLevelKey] as? NSNumber)?.intValue != 0 }
let types = Set(active.compactMap { $0[kIOPMAssertionTypeKey] as? String })
guard types.contains(kIOPMAssertPreventUserIdleSystemSleep) == system &&
types.contains(kIOPMAssertPreventUserIdleDisplaySleep) == display else {
FileHandle.standardError.write(Data(
"FAIL: \(context): expected system=\(system), display=\(display); actual=\(types)\n".utf8))
exit(EXIT_FAILURE)
}
}
+20
View File
@@ -78,6 +78,7 @@ struct SystemPlaybackChecks {
defer { model.shutdown() }
require(!model.showsBufferInfo && !model.automaticYouTubeQuality && model.compactWindow.snappingEnabled,
"session overrides never replace built-in settings")
requirePlaybackPower(system: false, display: false, "idle player permits sleep")
model.setVolume(0)
let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
let center = MPNowPlayingInfoCenter.default()
@@ -109,8 +110,14 @@ struct SystemPlaybackChecks {
}
until("open starts authoritative playback") { model.canTogglePlayback && model.state.status == PLAYER_PLAYING }
require(center.playbackState == .playing, "playing publishes macOS playback ownership")
requirePlaybackPower(system: true, display: true, "local video keeps the Mac and display awake")
var bufferingState = model.state
bufferingState.buffering = true
model.applyState(bufferingState)
requirePlaybackPower(system: true, display: true, "buffering does not drop video sleep prevention")
require(model.setPlayback(playing: false), "explicit pause accepted")
until("pause acknowledged") { model.state.status == PLAYER_PAUSED }
requirePlaybackPower(system: false, display: false, "pause releases sleep prevention")
let pausedPosition = model.state.position
if !NSWorkspace.shared.isVoiceOverEnabled {
model.setControlsHovered(true)
@@ -214,6 +221,7 @@ struct SystemPlaybackChecks {
require(model.bufferedRange == nil, "accepted close immediately hides a stale retained-media snapshot")
require(center.nowPlayingInfo == nil && center.playbackState == .stopped,
"accepted close releases system playback immediately")
requirePlaybackPower(system: false, display: false, "close releases sleep prevention immediately")
require(!commands.togglePlayPauseCommand.isEnabled && !commands.nextTrackCommand.isEnabled,
"closed source cannot receive system playback or seek commands")
@@ -278,9 +286,21 @@ struct SystemPlaybackChecks {
until("decode error acknowledged") { model.state.status == PLAYER_ERROR && !model.isBusy }
require(center.nowPlayingInfo == nil && !commands.playCommand.isEnabled,
"failed source does not claim playback keys")
requirePlaybackPower(system: false, display: false, "playback failure permits sleep")
model.open(localURL)
until("playback resumes before end-of-media check") {
model.state.status == PLAYER_PLAYING && model.canSeek && !model.state.seek_pending
}
require(model.seekRelative(model.duration - model.state.position - 0.2), "seek near end accepted")
until("video reaches its natural end") { model.state.status == PLAYER_ENDED }
requirePlaybackPower(system: false, display: false, "end of media permits sleep")
model.open(directory.appendingPathComponent("audio.wav"))
until("audio-only playback starts") { model.state.status == PLAYER_PLAYING && !model.state.has_video }
requirePlaybackPower(system: true, display: false, "audio keeps the Mac awake but permits display sleep")
model.shutdown()
require(center.nowPlayingInfo == nil && !commands.pauseCommand.isEnabled,
"shutdown releases system playback")
requirePlaybackPower(system: false, display: false, "shutdown releases active sleep prevention")
print("System playback checks passed")
}
}