367 lines
22 KiB
Swift
367 lines
22 KiB
Swift
import AppKit
|
|
import MediaPlayer
|
|
import TerebiConfiguration
|
|
|
|
@main
|
|
@MainActor
|
|
struct SystemPlaybackChecks {
|
|
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
|
|
if !condition() { fatalError("FAIL: " + message) }
|
|
}
|
|
|
|
static func until(_ message: String, _ condition: () -> Bool) {
|
|
let deadline = ContinuousClock.now.advanced(by: .seconds(10))
|
|
while !condition(), ContinuousClock.now < deadline {
|
|
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.01))
|
|
}
|
|
require(condition(), message)
|
|
}
|
|
|
|
static func checkCompactProgress() {
|
|
let times: [(Double, Double, String)] = [
|
|
(12.9, 45.9, "12 / 45 s"),
|
|
(59, 59.999, "59 / 59 s"),
|
|
(0, 60, "00:00 / 01:00 m"),
|
|
(312, 2528, "05:12 / 42:08 m"),
|
|
(3599, 3599.999, "59:59 / 59:59 m"),
|
|
(59, 3600, "00:00 / 01:00 h"),
|
|
(4980, 7800, "01:23 / 02:10 h"),
|
|
(-5, 45, "0 / 45 s"),
|
|
(80, 45, "45 / 45 s"),
|
|
]
|
|
for (position, duration, expected) in times {
|
|
require(compactProgressText(position: position, duration: duration, format: .time) == expected,
|
|
"compact time uses total-duration units, truncation and bounded progress: \(expected)")
|
|
}
|
|
for (position, expected) in [(59.9, "59.9"), (59.99, "59.9"), (0.0, "0.0"),
|
|
(99.89, "99.8"), (99.9, "100.0"), (99.99, "100.0"),
|
|
(100.0, "100.0"), (105.0, "100.0"), (-1.0, "0.0")] {
|
|
require(compactProgressText(position: position, duration: 100, format: .percentage) == expected,
|
|
"compact percentage has one decimal and stays bounded: \(expected)")
|
|
}
|
|
for duration in [0.0, -1.0, .nan, .infinity] {
|
|
require(compactProgressText(position: 10, duration: duration, format: .time) == nil &&
|
|
compactProgressText(position: 10, duration: duration, format: .percentage) == nil,
|
|
"unknown/invalid duration has no compact progress")
|
|
}
|
|
require(compactProgressText(position: .nan, duration: 100, format: .time) == nil &&
|
|
compactProgressText(position: .infinity, duration: 100, format: .percentage) == nil,
|
|
"nonfinite positions never appear as compact progress")
|
|
require(compactProgressText(position: 59, duration: 100, format: .none) == nil,
|
|
"disabled compact progress leaves only the title")
|
|
}
|
|
|
|
static func checkQueue(directory: URL, defaults: UserDefaults) {
|
|
let model = PlayerModel(defaults: defaults)
|
|
defer { model.shutdown() }
|
|
model.setVolume(0)
|
|
let video = directory.appendingPathComponent("av.mp4")
|
|
let audio = directory.appendingPathComponent("audio.wav")
|
|
model.openFiles([video, audio])
|
|
until("queue opens its naturally sorted first file") {
|
|
model.queue.currentURL == audio && model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
|
|
}
|
|
let current = model.queue.currentID!
|
|
require(model.setPlayback(playing: false), "pause queue before editing")
|
|
until("queue pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
|
|
let revision = model.state.revision
|
|
model.addToQueue([video])
|
|
model.moveQueueItems(fromOffsets: IndexSet(integer: 0), toOffset: 2)
|
|
require(model.queue.currentID == current && model.state.revision == revision &&
|
|
model.state.status == PLAYER_PAUSED, "append and reorder do not reopen current playback")
|
|
model.removeQueueItem(model.queue.items.last!.id)
|
|
require(model.queue.currentID == current && model.state.revision == revision,
|
|
"removing another queue entry leaves native playback untouched")
|
|
model.removeQueueItem(current)
|
|
until("removing final current entry stops despite earlier remaining entries") {
|
|
model.queue.currentID == nil && model.state.status == PLAYER_EMPTY
|
|
}
|
|
require(model.queue.items.count == 1, "stopping at queue end preserves earlier entries")
|
|
model.selectQueueItem(model.queue.items[0].id)
|
|
until("explicit queue selection opens remaining entry") {
|
|
model.queue.currentURL == video && model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
|
|
}
|
|
model.openFiles([video, audio])
|
|
until("replacement queue starts with audio") {
|
|
model.queue.currentURL == audio && model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
|
|
}
|
|
require(model.seekRelative(model.duration - model.state.position - 0.2), "queue seeks near first EOF")
|
|
until("natural EOF advances to next queue file") {
|
|
model.queue.currentURL == video && model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
|
|
}
|
|
require(model.seekRelative(model.duration - model.state.position - 0.2), "queue seeks near final EOF")
|
|
until("final queue EOF stops without wrapping") { model.state.status == PLAYER_ENDED }
|
|
require(model.queue.currentURL == video && !model.queue.canNext, "final EOF retains last selection")
|
|
model.openFiles([directory.appendingPathComponent("hdr.mkv"), video])
|
|
model.selectQueueItem(model.queue.items.first { $0.url.lastPathComponent == "hdr.mkv" }!.id)
|
|
until("queued playback error is acknowledged") { model.state.status == PLAYER_ERROR && !model.isBusy }
|
|
require(model.queue.currentURL?.lastPathComponent == "hdr.mkv",
|
|
"playback failure stops on failed queue entry rather than skipping")
|
|
}
|
|
|
|
static func main() {
|
|
checkCompactProgress()
|
|
_ = NSApplication.shared
|
|
NSApp.setActivationPolicy(.prohibited)
|
|
let suite = "terebi.system-playback-check.\(UUID().uuidString)"
|
|
let defaults = UserDefaults(suiteName: suite)!
|
|
defer { defaults.removePersistentDomain(forName: suite) }
|
|
checkQueue(directory: URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true), defaults: defaults)
|
|
defaults.removePersistentDomain(forName: suite)
|
|
let initialModel = PlayerModel(defaults: defaults)
|
|
require(!initialModel.showsBufferInfo, "buffer readout defaults off before playback")
|
|
initialModel.setShowsBufferInfo(true)
|
|
require(initialModel.showsBufferInfo && initialModel.controlsVisible,
|
|
"buffer readout can be enabled before playback and reveals controls")
|
|
initialModel.shutdown()
|
|
let restoredModel = PlayerModel(defaults: defaults)
|
|
require(!restoredModel.showsBufferInfo, "session opt-in does not survive model recreation")
|
|
restoredModel.shutdown()
|
|
let configuredModel = PlayerModel(defaults: defaults, configuration:
|
|
PlayerConfiguration(showBufferInfo: true, automaticQuality: true, snapToEdges: false))
|
|
require(configuredModel.showsBufferInfo && configuredModel.automaticYouTubeQuality
|
|
&& !configuredModel.compactWindow.snappingEnabled, "configuration initializes all three controls")
|
|
configuredModel.setShowsBufferInfo(false)
|
|
configuredModel.setAutomaticYouTubeQuality(false)
|
|
configuredModel.compactWindow.snappingEnabled = true
|
|
configuredModel.shutdown()
|
|
let model = PlayerModel(defaults: defaults)
|
|
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()
|
|
let commands = MPRemoteCommandCenter.shared()
|
|
let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
|
|
styleMask: [.titled, .closable, .miniaturizable, .resizable],
|
|
backing: .buffered, defer: false)
|
|
window.isReleasedWhenClosed = false
|
|
defer { window.close() }
|
|
model.attachWindow(window)
|
|
let videoFrame = window.frame
|
|
model.showsQueue = true
|
|
require(abs(window.frame.width - videoFrame.width - 260) < 0.01 &&
|
|
abs(window.frame.height - videoFrame.height) < 0.01,
|
|
"opening queue grows window without shrinking video")
|
|
model.showsQueue = false
|
|
require(abs(window.frame.width - videoFrame.width) < 0.01 &&
|
|
abs(window.frame.height - videoFrame.height) < 0.01,
|
|
"closing queue restores video-only window size")
|
|
model.open(directory.appendingPathComponent("anamorphic.mp4"))
|
|
require(model.isBusy && model.perform(.enterCompact) && model.isCompact,
|
|
"compact entry is available before native media opening acknowledges")
|
|
until("decoded anamorphic metadata replaces provisional compact geometry") {
|
|
model.state.status == PLAYER_PLAYING && model.isCompact && !model.compactWindow.isTransitioning &&
|
|
abs(window.frame.width / window.frame.height - 8.0 / 3.0) < 0.01
|
|
}
|
|
model.closeFile()
|
|
require(!model.isCompact && !model.compactWindow.isTransitioning,
|
|
"closing loading/loaded media restores immediately")
|
|
until("anamorphic source closes before the ordinary playback checks") { model.state.status == PLAYER_EMPTY }
|
|
require(!model.canPerform(.enterCompact), "empty playback does not enable compact entry")
|
|
|
|
model.open(directory.appendingPathComponent("av.mp4"))
|
|
if !NSWorkspace.shared.isVoiceOverEnabled {
|
|
model.setPointerInsideWindow(false)
|
|
require(!model.controlsVisible, "pointer exit hides playback chrome while media is loading")
|
|
model.setPointerInsideWindow(true)
|
|
}
|
|
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)
|
|
model.setTitleHovered(true)
|
|
model.setPointerInsideWindow(false)
|
|
require(!model.controlsVisible,
|
|
"pointer exit hides paused playback chrome despite stale hover and inactive window")
|
|
model.setControlsHovered(false)
|
|
model.setTitleHovered(false)
|
|
model.updateWindowState()
|
|
require(!model.controlsVisible,
|
|
"late hover/focus updates cannot reshow chrome while the pointer is outside")
|
|
model.revealControls(forKeyboardNavigation: true)
|
|
model.revealControls()
|
|
require(model.controlsVisible, "Tab navigation can reveal controls without moving the pointer")
|
|
model.setPointerInsideWindow(false)
|
|
require(!model.controlsVisible, "another pointer exit dismisses keyboard-revealed controls")
|
|
model.setPointerInsideWindow(true)
|
|
require(model.controlsVisible, "pointer reentry reveals paused playback chrome")
|
|
}
|
|
require(model.setPlayback(playing: false), "repeated pause accepted")
|
|
until("idempotent pause does not restart playback") {
|
|
model.state.status == PLAYER_PAUSED && center.playbackState == .paused
|
|
}
|
|
require(model.state.position == pausedPosition, "repeated pause preserves position")
|
|
require(center.nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] as? Double == 0,
|
|
"paused system timeline does not extrapolate")
|
|
let playbackKey = try! KeyChord("space")
|
|
let oppositeIntentRevision = model.state.revision
|
|
require(model.handleKey(playbackKey) && model.effectivePlaying,
|
|
"play binding records intent before backend acknowledgement")
|
|
require(model.handleKey(playbackKey) && !model.effectivePlaying,
|
|
"same binding resolves pause against pending play intent")
|
|
require(model.handleKey(playbackKey, isRepeat: true) && !model.effectivePlaying,
|
|
"held paired shortcut is consumed without oscillating")
|
|
until("rapid opposite intents acknowledge play before the final pause") {
|
|
model.state.revision != oppositeIntentRevision && model.state.status == PLAYER_PLAYING
|
|
}
|
|
let playingRevision = model.state.revision
|
|
until("rapid opposite intents settle to an acknowledged native pause") {
|
|
model.state.revision != playingRevision && model.state.status == PLAYER_PAUSED &&
|
|
!model.effectivePlaying && center.playbackState == .paused
|
|
}
|
|
require(center.nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] as? Double == 0,
|
|
"latest pause intent stops the system timeline after native acknowledgement")
|
|
let settledPosition = model.state.position
|
|
require(!model.perform(.pause) && !model.effectivePlaying,
|
|
"explicit pause cannot resume an already paused player")
|
|
|
|
let seekableState = model.state
|
|
var nonseekableState = seekableState
|
|
nonseekableState.can_seek = false
|
|
model.applyState(nonseekableState)
|
|
require(commands.playCommand.isEnabled && !commands.nextTrackCommand.isEnabled &&
|
|
!commands.skipBackwardCommand.isEnabled && !model.seekRelative(1),
|
|
"nonseekable input retains playback but rejects system seeking")
|
|
model.applyState(seekableState)
|
|
require(commands.nextTrackCommand.isEnabled,
|
|
"seekability changes re-enable commands even when playback metadata is unchanged")
|
|
|
|
for _ in 0..<64 { require(model.seekRelative(1.0 / 64), "rapid native skips retain accumulated target") }
|
|
until("paused seek acknowledged") {
|
|
model.state.status == PLAYER_PAUSED && !model.state.seek_pending &&
|
|
abs(model.state.position - settledPosition - 1) < 0.05
|
|
}
|
|
require(model.seekRelative(-100), "backward boundary seek accepted")
|
|
until("backward seek clamps at start") { !model.state.seek_pending && model.state.position == 0 }
|
|
require(!model.seekRelative(.nan), "nonfinite external seek rejected")
|
|
require(model.setPlayback(playing: true), "explicit play accepted")
|
|
until("play resumes advancing timeline") { model.state.status == PLAYER_PLAYING && model.state.position > 0.1 }
|
|
require(model.setPlayback(playing: true), "repeated play accepted")
|
|
until("repeated play keeps playing") { model.state.status == PLAYER_PLAYING && model.state.position > 0.2 }
|
|
|
|
if !NSWorkspace.shared.isVoiceOverEnabled {
|
|
require(model.perform(.enterCompact), "compact playback is available for hidden-keyboard seeking")
|
|
until("compact entry completes before hiding controls") { !model.compactWindow.isTransitioning }
|
|
model.hideControls(hideCursor: false)
|
|
require(!model.controlsVisible, "playing chrome can be hidden before a keyboard seek")
|
|
require(model.handleKey(try! KeyChord("left")), "keyboard seek is accepted while chrome is hidden")
|
|
require(!model.controlsVisible, "keyboard seek must not flash playback chrome")
|
|
until("hidden keyboard seek acknowledges the start boundary") {
|
|
require(!model.controlsVisible, "seek acknowledgement must preserve hidden chrome")
|
|
return !model.state.seek_pending && model.state.position < 0.15
|
|
}
|
|
require(model.perform(.exitCompact), "keyboard seek check restores normal mode")
|
|
until("compact exit completes") { !model.compactWindow.isTransitioning }
|
|
}
|
|
|
|
// Closing must hide the last polled retained range before the next state poll.
|
|
var retainedSnapshot = model.state
|
|
retainedSnapshot.buffered_start = retainedSnapshot.position
|
|
retainedSnapshot.buffered_end = min(retainedSnapshot.duration, retainedSnapshot.position + 1)
|
|
model.applyState(retainedSnapshot)
|
|
require(model.bufferedRange != nil, "loaded selection exposes retained media")
|
|
let retainedRange = model.bufferedRange
|
|
model.setShowsBufferInfo(true)
|
|
require(model.bufferedRange == retainedRange, "enabling buffer readout preserves retained media")
|
|
model.setShowsBufferInfo(false)
|
|
require(model.bufferedRange == retainedRange, "disabling buffer readout preserves retained media")
|
|
model.closeFile()
|
|
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")
|
|
|
|
let localURL = directory.appendingPathComponent("av.mp4")
|
|
let progress = PlaybackProgress(defaults: defaults)
|
|
let savedPosition = seekableState.duration / 2
|
|
progress.save(savedPosition, for: localURL)
|
|
var openRevision = model.state.revision
|
|
model.open(localURL)
|
|
until("local saved position is restored before native autoplay") {
|
|
guard model.state.revision != openRevision else { return false }
|
|
if model.state.status == PLAYER_PLAYING && !model.state.seek_pending {
|
|
require(model.state.position >= savedPosition,
|
|
"local autoplay never acknowledges playback before the saved position")
|
|
return model.state.position < savedPosition + 0.5
|
|
}
|
|
require(progress.position(for: localURL) == savedPosition,
|
|
"pre-restoration snapshots cannot overwrite saved history")
|
|
return false
|
|
}
|
|
require(model.setPlayback(playing: false), "restored local playback can pause")
|
|
until("restored local pause acknowledged") { model.state.status == PLAYER_PAUSED && !model.state.seek_pending }
|
|
let restoredPosition = model.state.position
|
|
model.closeFile()
|
|
require(progress.position(for: localURL) == restoredPosition,
|
|
"close persists acknowledged restored position")
|
|
openRevision = model.state.revision
|
|
model.open(localURL)
|
|
until("close and reopen resumes the persisted native position") {
|
|
guard model.state.revision != openRevision && model.state.status == PLAYER_PLAYING &&
|
|
!model.state.seek_pending else { return false }
|
|
require(model.state.position >= restoredPosition,
|
|
"reopened local autoplay starts at acknowledged persisted progress")
|
|
return model.state.position < restoredPosition + 0.5
|
|
}
|
|
model.closeFile()
|
|
let invalidPosition = seekableState.duration + 1
|
|
progress.save(invalidPosition, for: localURL)
|
|
openRevision = model.state.revision
|
|
model.open(localURL)
|
|
until("duration-exceeded bookmark is discarded and native playback advances") {
|
|
model.state.revision != openRevision && model.state.status == PLAYER_PLAYING &&
|
|
!model.state.seek_pending && model.state.position > 0.1 && model.state.position < savedPosition
|
|
}
|
|
require((progress.position(for: localURL) ?? 0) < seekableState.duration,
|
|
"duration-exceeded bookmark is removed from exposed history")
|
|
model.closeFile()
|
|
let replacementPosition = progress.position(for: localURL)!
|
|
require(replacementPosition > 0 && replacementPosition < savedPosition,
|
|
"close replaces invalid history with acknowledged playback progress")
|
|
openRevision = model.state.revision
|
|
model.open(localURL)
|
|
until("reopening no longer repeats a duration-exceeded restoration") {
|
|
guard model.state.revision != openRevision && model.state.status == PLAYER_PLAYING &&
|
|
!model.state.seek_pending else { return false }
|
|
require(model.state.position >= replacementPosition,
|
|
"replacement bookmark restores before reopened autoplay")
|
|
return model.state.position < replacementPosition + 0.5
|
|
}
|
|
model.closeFile()
|
|
model.open(directory.appendingPathComponent("hdr.mkv"))
|
|
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")
|
|
}
|
|
}
|