From 2b96a165d2356c890f66813dc5f223522626e893 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sat, 17 Oct 2026 22:05:27 +0200 Subject: [PATCH] fix: prevent idle sleep during playback --- Checks/cast-playback.swift | 12 ++++++++++++ Checks/playback-power.swift | 19 +++++++++++++++++++ Checks/system-playback.swift | 20 ++++++++++++++++++++ Makefile | 8 ++++---- README.md | 4 +++- Sources/App/SystemPlaybackControls.swift | 22 ++++++++++++++++++++++ TODO.md | 4 ++-- 7 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 Checks/playback-power.swift diff --git a/Checks/cast-playback.swift b/Checks/cast-playback.swift index 7e6184b..d8c4d57 100644 --- a/Checks/cast-playback.swift +++ b/Checks/cast-playback.swift @@ -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") } } diff --git a/Checks/playback-power.swift b/Checks/playback-power.swift new file mode 100644 index 0000000..513e791 --- /dev/null +++ b/Checks/playback-power.swift @@ -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? + 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) + } +} diff --git a/Checks/system-playback.swift b/Checks/system-playback.swift index affa93c..465236c 100644 --- a/Checks/system-playback.swift +++ b/Checks/system-playback.swift @@ -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") } } diff --git a/Makefile b/Makefile index 0f51323..78d8299 100644 --- a/Makefile +++ b/Makefile @@ -263,8 +263,8 @@ $(BUILD_DIR)/ffmpeg-runtime-check: Checks/ffmpeg-runtime.swift $(RUNTIME_SOURCE) check-runtime: $(BUILD_DIR)/ffmpeg-runtime-check $(BUNDLE_STAMP) $(BUILD_DIR)/ffmpeg-runtime-check "$(APP)" -$(BUILD_DIR)/system-playback-check: Checks/system-playback.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 $@ +$(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 Checks/playback-power.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@ check-media: $(BUILD_DIR)/system-playback-check check-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 $(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 - $(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 $@ +$(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 $< 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 $(BUILD_DIR)/cast-session-check diff --git a/README.md b/README.md index c5d5da3..6317c63 100644 --- a/README.md +++ b/README.md @@ -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. +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 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 -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. diff --git a/Sources/App/SystemPlaybackControls.swift b/Sources/App/SystemPlaybackControls.swift index 863e9a4..61d4a56 100644 --- a/Sources/App/SystemPlaybackControls.swift +++ b/Sources/App/SystemPlaybackControls.swift @@ -23,6 +23,8 @@ final class SystemPlaybackControls { private let infoCenter = MPNowPlayingInfoCenter.default() private var targets: [(MPRemoteCommand, Any)] = [] private var snapshot: Snapshot? + private var activity: NSObjectProtocol? + private var activityOptions: ProcessInfo.ActivityOptions = [] init(model: PlayerModel) { self.model = model @@ -75,6 +77,23 @@ final class SystemPlaybackControls { } 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 seekable = ready && model?.canSeek == true for (command, _) in targets { @@ -117,6 +136,9 @@ final class SystemPlaybackControls { } func shutdown() { + if let activity { ProcessInfo.processInfo.endActivity(activity) } + activity = nil + activityOptions = [] for (command, target) in targets { command.removeTarget(target) command.isEnabled = false diff --git a/TODO.md b/TODO.md index 2e2804c..15f692b 100644 --- a/TODO.md +++ b/TODO.md @@ -1,7 +1,7 @@ - [x] improve chrome cast subtitle rendering - 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. - - when casting, screensaver is ok, but the video stream should NOT be cut. +- [x] currently, computer falls asleep even if video is playing. this should not be the case. + - 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 - maybe by allowing multiple file selection? 🤔 - [ ] in the "choose what to play" screen, we should change the youtube button to general streaming