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 } 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" { if scenario == "reject-load" {
try signal("observed") try signal("observed")
try await until(model, "LOAD rejection restores original local playback", permitError: true) { try await until(model, "LOAD rejection restores original local playback", permitError: true) {
@@ -107,6 +109,7 @@ struct CastPlaybackCheck {
if scenario.hasPrefix("shutdown-") { if scenario.hasPrefix("shutdown-") {
require(model.castDevice == nil && !model.castConnecting && !model.canOpen && !model.canClose, require(model.castDevice == nil && !model.castConnecting && !model.canOpen && !model.canClose,
"shutdown releases Cast ownership and permanently disables the local player") "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, require(!model.setPlayback(playing: true) && !model.seekRelative(5) && model.volume == 0,
"late receiver traffic cannot resume a destroyed player or replace local volume") "late receiver traffic cannot resume a destroyed player or replace local volume")
print("PASS: " + scenario) print("PASS: " + scenario)
@@ -187,29 +190,36 @@ struct CastPlaybackCheck {
model.setVolume(0) model.setVolume(0)
model.open(source) model.open(source)
try await until(model, "local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek } 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") require(model.setPlayback(playing: false), "local pause accepted")
try await until(model, "local pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil } 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.seekRelative(9.25 - model.state.position), "local restoration position accepted")
require(!model.canCast, "Cast cannot transfer an unacknowledged local seek") 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 } 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)) 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") { try await until(model, "receiver load acknowledges paused handoff") {
model.castDevice != nil && !model.castConnecting && model.canSeek && model.state.status == PLAYER_PAUSED 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(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.canStepFrame && model.bufferedRange == nil, "local decoder-only controls are not exposed while casting")
require(model.setPlayback(playing: true), "remote play accepted") require(model.setPlayback(playing: true), "remote play accepted")
try await until(model, "receiver play acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil } 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") } 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") 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 } 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") require(model.setPlayback(playing: false), "remote pause accepted")
try await until(model, "receiver pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil } 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() model.stopCasting()
try await until(model, "local decoder restores receiver position paused") { try await until(model, "local decoder restores receiver position paused") {
model.castDevice == nil && model.state.status == PLAYER_PAUSED && !model.state.seek_pending && model.castDevice == nil && model.state.status == PLAYER_PAUSED && !model.state.seek_pending &&
abs(model.state.position - 24.25) < 0.15 && !model.isBusy 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, require(abs((PlaybackProgress(defaults: defaults).position(for: source) ?? 0) - 24.25) < 0.15,
"remote history is not overwritten by the abandoned local position") "remote history is not overwritten by the abandoned local position")
require(model.volume == 0, "local volume survives receiver volume changes") 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 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") 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() model.closeFile()
try await until(model, "closed media releases Cast and decoder") { model.state.status == PLAYER_EMPTY } 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") 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() } defer { model.shutdown() }
require(!model.showsBufferInfo && !model.automaticYouTubeQuality && model.compactWindow.snappingEnabled, require(!model.showsBufferInfo && !model.automaticYouTubeQuality && model.compactWindow.snappingEnabled,
"session overrides never replace built-in settings") "session overrides never replace built-in settings")
requirePlaybackPower(system: false, display: false, "idle player permits sleep")
model.setVolume(0) model.setVolume(0)
let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true) let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
let center = MPNowPlayingInfoCenter.default() let center = MPNowPlayingInfoCenter.default()
@@ -109,8 +110,14 @@ struct SystemPlaybackChecks {
} }
until("open starts authoritative playback") { model.canTogglePlayback && model.state.status == PLAYER_PLAYING } until("open starts authoritative playback") { model.canTogglePlayback && model.state.status == PLAYER_PLAYING }
require(center.playbackState == .playing, "playing publishes macOS playback ownership") 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") require(model.setPlayback(playing: false), "explicit pause accepted")
until("pause acknowledged") { model.state.status == PLAYER_PAUSED } until("pause acknowledged") { model.state.status == PLAYER_PAUSED }
requirePlaybackPower(system: false, display: false, "pause releases sleep prevention")
let pausedPosition = model.state.position let pausedPosition = model.state.position
if !NSWorkspace.shared.isVoiceOverEnabled { if !NSWorkspace.shared.isVoiceOverEnabled {
model.setControlsHovered(true) model.setControlsHovered(true)
@@ -214,6 +221,7 @@ struct SystemPlaybackChecks {
require(model.bufferedRange == nil, "accepted close immediately hides a stale retained-media snapshot") require(model.bufferedRange == nil, "accepted close immediately hides a stale retained-media snapshot")
require(center.nowPlayingInfo == nil && center.playbackState == .stopped, require(center.nowPlayingInfo == nil && center.playbackState == .stopped,
"accepted close releases system playback immediately") "accepted close releases system playback immediately")
requirePlaybackPower(system: false, display: false, "close releases sleep prevention immediately")
require(!commands.togglePlayPauseCommand.isEnabled && !commands.nextTrackCommand.isEnabled, require(!commands.togglePlayPauseCommand.isEnabled && !commands.nextTrackCommand.isEnabled,
"closed source cannot receive system playback or seek commands") "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 } until("decode error acknowledged") { model.state.status == PLAYER_ERROR && !model.isBusy }
require(center.nowPlayingInfo == nil && !commands.playCommand.isEnabled, require(center.nowPlayingInfo == nil && !commands.playCommand.isEnabled,
"failed source does not claim playback keys") "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() model.shutdown()
require(center.nowPlayingInfo == nil && !commands.pauseCommand.isEnabled, require(center.nowPlayingInfo == nil && !commands.pauseCommand.isEnabled,
"shutdown releases system playback") "shutdown releases system playback")
requirePlaybackPower(system: false, display: false, "shutdown releases active sleep prevention")
print("System playback checks passed") print("System playback checks passed")
} }
} }
+4 -4
View File
@@ -263,8 +263,8 @@ $(BUILD_DIR)/ffmpeg-runtime-check: Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE)
check-runtime: $(BUILD_DIR)/ffmpeg-runtime-check $(BUNDLE_STAMP) check-runtime: $(BUILD_DIR)/ffmpeg-runtime-check $(BUNDLE_STAMP)
$(BUILD_DIR)/ffmpeg-runtime-check "$(APP)" $(BUILD_DIR)/ffmpeg-runtime-check "$(APP)"
$(BUILD_DIR)/system-playback-check: Checks/system-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies $(BUILD_DIR)/system-playback-check: Checks/system-playback.swift Checks/playback-power.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/system-playback.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@ $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/system-playback.swift Checks/playback-power.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
check-media: $(BUILD_DIR)/system-playback-check check-fixtures check-media: $(BUILD_DIR)/system-playback-check check-fixtures
$(BUILD_DIR)/system-playback-check $(BUILD_DIR)/fixtures $(BUILD_DIR)/system-playback-check $(BUILD_DIR)/fixtures
@@ -278,8 +278,8 @@ $(BUILD_DIR)/cast_media-check.o: Sources/Core/cast_media.cpp Sources/Core/cast_m
$(BUILD_DIR)/cast-media-check: Checks/cast-media.cpp $(BUILD_DIR)/cast_media-check.o Makefile | check-dependencies $(BUILD_DIR)/cast-media-check: Checks/cast-media.cpp $(BUILD_DIR)/cast_media-check.o Makefile | check-dependencies
$(CXX) $(CXXFLAGS) -ISources/Core -DCAST_MEDIA_CHECK $< $(BUILD_DIR)/cast_media-check.o $(FF_LIBS) -o $@ $(CXX) $(CXXFLAGS) -ISources/Core -DCAST_MEDIA_CHECK $< $(BUILD_DIR)/cast_media-check.o $(FF_LIBS) -o $@
$(BUILD_DIR)/cast-playback-check: Checks/cast-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies $(BUILD_DIR)/cast-playback-check: Checks/cast-playback.swift Checks/playback-power.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $< $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@ $(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $< Checks/playback-power.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
check-cast: $(BUILD_DIR)/cast-session-check $(BUILD_DIR)/cast-media-check $(BUILD_DIR)/cast-playback-check check-fixtures check-cast: $(BUILD_DIR)/cast-session-check $(BUILD_DIR)/cast-media-check $(BUILD_DIR)/cast-playback-check check-fixtures
$(BUILD_DIR)/cast-session-check $(BUILD_DIR)/cast-session-check
+3 -1
View File
@@ -51,6 +51,8 @@ Compact mode keeps playback in the same floating window; drag to move it and dou
Playback controls stay anchored at the bottom center of the video and cannot be dragged. Playback controls stay anchored at the bottom center of the video and cannot be dragged.
Active playback prevents idle system sleep. Local video also keeps the display awake; audio-only playback allows display sleep. Pause, end, close, playback failure and quit release sleep prevention.
### YouTube ### YouTube
Playback defaults to the best available SDR resolution up to **1080p**. Use **gear → Quality** to select another available resolution, including 4K, or enable automatic quality (off by default). **Show Buffer Info** displays the retained network buffer. Playback defaults to the best available SDR resolution up to **1080p**. Use **gear → Quality** to select another available resolution, including 4K, or enable automatic quality (off by default). **Show Buffer Info** displays the retained network buffer.
@@ -59,7 +61,7 @@ Only individual videos are supported, not playlists or channels. Private, age-re
### Chromecast ### Chromecast
Open media, select the **Cast** icon or **Playback → Cast to Device → Find Cast Devices…**, and choose a receiver on the same LAN. Allow macOS Local Network access and incoming firewall connections. Keep the Mac awake and connected: it relays the media to the receiver. Open media, select the **Cast** icon or **Playback → Cast to Device → Find Cast Devices…**, and choose a receiver on the same LAN. Allow macOS Local Network access and incoming firewall connections. Terebi keeps the Mac awake during the handoff and active Cast playback while allowing the screensaver and display sleep once local playback stops. Keep the Mac connected: it relays media to the receiver. Closing the lid, explicitly sleeping the Mac or critical-battery sleep can still interrupt playback.
Existing controls operate remote playback. **Play on This Mac** returns to local playback at the receiver's position. Opening different media ends casting. Frame stepping and YouTube quality changes are local-only; live Cast playback is not seekable. Cast output is limited to 1080p/30 fps. The screen-and-triangle icon does not provide AirPlay support. Existing controls operate remote playback. **Play on This Mac** returns to local playback at the receiver's position. Opening different media ends casting. Frame stepping and YouTube quality changes are local-only; live Cast playback is not seekable. Cast output is limited to 1080p/30 fps. The screen-and-triangle icon does not provide AirPlay support.
+22
View File
@@ -23,6 +23,8 @@ final class SystemPlaybackControls {
private let infoCenter = MPNowPlayingInfoCenter.default() private let infoCenter = MPNowPlayingInfoCenter.default()
private var targets: [(MPRemoteCommand, Any)] = [] private var targets: [(MPRemoteCommand, Any)] = []
private var snapshot: Snapshot? private var snapshot: Snapshot?
private var activity: NSObjectProtocol?
private var activityOptions: ProcessInfo.ActivityOptions = []
init(model: PlayerModel) { init(model: PlayerModel) {
self.model = model self.model = model
@@ -75,6 +77,23 @@ final class SystemPlaybackControls {
} }
func update() { func update() {
var options: ProcessInfo.ActivityOptions = []
if let model, model.fileName != nil,
model.state.status == PLAYER_PLAYING || model.castConnecting {
options = .userInitiated
if model.state.status == PLAYER_PLAYING, model.state.has_video,
model.castDevice == nil || model.castConnecting {
options.insert(.idleDisplaySleepDisabled)
}
}
if options != activityOptions {
// Acquire before releasing so a local/Cast handoff cannot briefly allow sleep.
let next = options.isEmpty ? nil :
ProcessInfo.processInfo.beginActivity(options: options, reason: "Playing media")
if let activity { ProcessInfo.processInfo.endActivity(activity) }
activity = next
activityOptions = options
}
let ready = model?.fileName != nil && model?.canTogglePlayback == true let ready = model?.fileName != nil && model?.canTogglePlayback == true
let seekable = ready && model?.canSeek == true let seekable = ready && model?.canSeek == true
for (command, _) in targets { for (command, _) in targets {
@@ -117,6 +136,9 @@ final class SystemPlaybackControls {
} }
func shutdown() { func shutdown() {
if let activity { ProcessInfo.processInfo.endActivity(activity) }
activity = nil
activityOptions = []
for (command, target) in targets { for (command, target) in targets {
command.removeTarget(target) command.removeTarget(target)
command.isEnabled = false command.isEnabled = false
+2 -2
View File
@@ -1,7 +1,7 @@
- [x] improve chrome cast subtitle rendering - [x] improve chrome cast subtitle rendering
- native WebVTT style: white sans-serif, black outline, transparent background; physical-TV font-scale tuning remains unverified - native WebVTT style: white sans-serif, black outline, transparent background; physical-TV font-scale tuning remains unverified
- [ ] currently, computer falls asleep even if video is playing. this should not be the case. - [x] currently, computer falls asleep even if video is playing. this should not be the case.
- when casting, screensaver is ok, but the video stream should NOT be cut. - native playback activity prevents idle system sleep; local video keeps the display awake, casting allows screensaver/display sleep; native assertion checks and loopback Cast transitions passed, physical idle-TV verification remains unverified
- [ ] add playlist support - [ ] add playlist support
- maybe by allowing multiple file selection? 🤔 - maybe by allowing multiple file selection? 🤔
- [ ] in the "choose what to play" screen, we should change the youtube button to general streaming - [ ] in the "choose what to play" screen, we should change the youtube button to general streaming