6d653e8875
Use background-only native drag gestures so scroll content and overlays remain draggable without stealing control, text, or queue-row input. Compact mode uses the same explicit drag surfaces rather than broad native background movement.
545 lines
33 KiB
Swift
545 lines
33 KiB
Swift
import AppKit
|
|
import MediaPlayer
|
|
import TerebiConfiguration
|
|
|
|
// Native focus eligibility normally depends on the user's Full Keyboard Access setting.
|
|
// Force eligibility only; keep the controls' real AppKit responder/key behavior.
|
|
private final class KeyboardCheckButton: NSButton {
|
|
override var acceptsFirstResponder: Bool { true }
|
|
}
|
|
|
|
private final class KeyboardCheckSlider: NSSlider {
|
|
override var acceptsFirstResponder: Bool { true }
|
|
}
|
|
|
|
private final class KeyboardCheckLabel: NSTextField {
|
|
override var acceptsFirstResponder: Bool { true }
|
|
}
|
|
|
|
@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)
|
|
}
|
|
|
|
private final class DragCheckWindow: NSWindow {
|
|
var drags = 0
|
|
var fullscreenToggles = 0
|
|
override func performDrag(with event: NSEvent) { drags += 1 }
|
|
override func toggleFullScreen(_ sender: Any?) { fullscreenToggles += 1 }
|
|
}
|
|
|
|
static func checkWindowSurface(defaults: UserDefaults) {
|
|
let model = PlayerModel(defaults: defaults)
|
|
defer { model.shutdown() }
|
|
let window = DragCheckWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
|
|
styleMask: [.titled, .resizable, .fullSizeContentView],
|
|
backing: .buffered, defer: false)
|
|
window.isReleasedWhenClosed = false
|
|
defer { window.close() }
|
|
let surface = PlaybackSurface.DragView(model: model)
|
|
window.contentView!.addSubview(surface)
|
|
model.attachWindow(window)
|
|
surface.frame = window.contentView!.bounds
|
|
@MainActor func event(_ type: NSEvent.EventType, clicks: Int = 1) -> NSEvent {
|
|
NSEvent.mouseEvent(with: type, location: NSPoint(x: 100, y: 100), modifierFlags: [],
|
|
timestamp: 0, windowNumber: window.windowNumber, context: nil,
|
|
eventNumber: 0, clickCount: clicks, pressure: 0)!
|
|
}
|
|
require(surface.hitTest(NSPoint(x: 100, y: 100)) === surface,
|
|
"video renderer and unused background route mouse input to their owning surface")
|
|
require(!surface.mouseDownCanMoveWindow, "native background movement cannot turn clicks or resize into drags")
|
|
for point in [NSPoint(x: 1, y: 100), NSPoint(x: 639, y: 100),
|
|
NSPoint(x: 100, y: 1), NSPoint(x: 100, y: surface.bounds.maxY - 1)] {
|
|
require(surface.hitTest(point) == nil, "playback drag surface leaves all native resize edges untouched")
|
|
}
|
|
surface.mouseDown(with: event(.leftMouseDown))
|
|
require(window.drags == 0, "mouse-down alone never begins window dragging")
|
|
surface.mouseUp(with: event(.leftMouseUp))
|
|
require(window.drags == 0 && window.fullscreenToggles == 0, "single video click never drags or enters fullscreen")
|
|
surface.mouseDown(with: event(.leftMouseDown))
|
|
surface.mouseDragged(with: event(.leftMouseDragged))
|
|
surface.mouseUp(with: event(.leftMouseUp))
|
|
require(window.drags == 1 && window.fullscreenToggles == 0, "real mouse-drag callback invokes native window dragging once")
|
|
surface.mouseDown(with: event(.leftMouseDown, clicks: 2))
|
|
surface.mouseUp(with: event(.leftMouseUp, clicks: 2))
|
|
require(window.drags == 1 && window.fullscreenToggles == 1, "double video click retains fullscreen instead of dragging")
|
|
}
|
|
|
|
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 checkKeyboardRouting(defaults: UserDefaults) {
|
|
let bindings = try! KeyBindings(overrides: [
|
|
"play": [], "pause": [], "seek_backward_5s": [],
|
|
"show_buffer_info": ["space", "return", "left", "shift+left", "tab", "shift+tab",
|
|
"cmd+b", "f12", "ctrl+alt+b"]
|
|
])
|
|
let model = PlayerModel(defaults: defaults, configuration: PlayerConfiguration(keys: bindings))
|
|
defer { model.shutdown() }
|
|
let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
|
|
styleMask: [.titled, .resizable], backing: .buffered, defer: false)
|
|
window.isReleasedWhenClosed = false
|
|
defer { window.close() }
|
|
func event(_ code: UInt16, _ text: String, _ flags: NSEvent.ModifierFlags = []) -> NSEvent {
|
|
NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
|
|
windowNumber: window.windowNumber, context: nil, characters: text,
|
|
charactersIgnoringModifiers: text, isARepeat: false, keyCode: code)!
|
|
}
|
|
let shortcuts = [event(49, " "), event(36, "\r"), event(123, "\u{F702}"),
|
|
event(123, "\u{F702}", .shift), event(11, "b", .command),
|
|
event(111, "\u{F70F}", .function)]
|
|
func focus(_ responder: NSView) {
|
|
window.makeFirstResponder(nil)
|
|
window.contentView?.subviews.forEach { $0.removeFromSuperview() }
|
|
responder.frame = NSRect(x: 0, y: 0, width: 300, height: 200)
|
|
window.contentView?.addSubview(responder)
|
|
require(window.makeFirstResponder(responder), "keyboard regression fixture accepts actual window focus")
|
|
let focusedResponder = (responder as? NSTextField)?.currentEditor() ?? responder
|
|
require(window.firstResponder === focusedResponder,
|
|
"keyboard regression fixture owns actual control or field-editor focus")
|
|
}
|
|
let lists = [NSTableView(), NSOutlineView()]
|
|
for list in lists {
|
|
list.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("title")))
|
|
focus(list)
|
|
for shortcut in shortcuts {
|
|
model.setShowsBufferInfo(false)
|
|
require(model.handleKeyEvent(shortcut, in: window) && model.showsBufferInfo,
|
|
"focused queue list dispatches configured \(KeyChord.from(shortcut)!) binding")
|
|
require(window.firstResponder === list, "playback shortcuts do not steal queue focus")
|
|
}
|
|
let traversalModifiers: [NSEvent.ModifierFlags] = [[], .shift]
|
|
for flags in traversalModifiers {
|
|
model.setShowsBufferInfo(false)
|
|
require(!model.handleKeyEvent(event(48, "\t", flags), in: window) && !model.showsBufferInfo,
|
|
"Tab and Shift-Tab remain native traversal even when configured")
|
|
}
|
|
}
|
|
let label = KeyboardCheckLabel(labelWithString: "Queue item")
|
|
focus(label)
|
|
model.setShowsBufferInfo(false)
|
|
require(model.handleKeyEvent(shortcuts[0], in: window) && model.showsBufferInfo,
|
|
"noneditable nonselectable queue label does not block playback")
|
|
let controls: [NSControl] = [KeyboardCheckButton(), KeyboardCheckSlider()]
|
|
for control in controls {
|
|
focus(control)
|
|
for shortcut in shortcuts.prefix(4) {
|
|
model.setShowsBufferInfo(false)
|
|
require(!model.handleKeyEvent(shortcut, in: window) && !model.showsBufferInfo,
|
|
"deliberately focused native button/slider retains activation and navigation")
|
|
require(window.firstResponder === control, "native control keyboard focus is preserved")
|
|
}
|
|
require(model.handleKeyEvent(shortcuts[4], in: window) && model.showsBufferInfo,
|
|
"modified configured bindings still work with native control focus")
|
|
}
|
|
let editor = NSTextView()
|
|
focus(editor)
|
|
for shortcut in shortcuts {
|
|
model.setShowsBufferInfo(false)
|
|
require(!model.handleKeyEvent(shortcut, in: window) && !model.showsBufferInfo,
|
|
"text editing retains configured keys instead of dispatching playback")
|
|
}
|
|
editor.keyDown(with: shortcuts[0])
|
|
require(editor.string == " ", "unconsumed Space reaches actual text entry")
|
|
label.isSelectable = true
|
|
focus(label)
|
|
require(!model.handleKeyEvent(shortcuts[0], in: window),
|
|
"selectable text retains native text interaction")
|
|
label.isSelectable = false
|
|
label.isEditable = true
|
|
require(!model.handleKeyEvent(shortcuts[0], in: window),
|
|
"editable text field retains native text entry")
|
|
focus(lists[0])
|
|
let voiceOverChord = event(11, "b", [.control, .option])
|
|
require(model.handleKeyEvent(voiceOverChord, in: window) != NSWorkspace.shared.isVoiceOverEnabled,
|
|
"VoiceOver retains its Control-Option chords when enabled")
|
|
let sheet = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 200, height: 100),
|
|
styleMask: .titled, backing: .buffered, defer: false)
|
|
sheet.isReleasedWhenClosed = false
|
|
window.beginSheet(sheet)
|
|
require(!model.handleKeyEvent(shortcuts[0], in: window), "attached sheet blocks player shortcuts")
|
|
window.endSheet(sheet)
|
|
sheet.close()
|
|
let modal = NSApp.beginModalSession(for: window)
|
|
require(!model.handleKeyEvent(shortcuts[0], in: window), "modal session blocks player shortcuts")
|
|
NSApp.endModalSession(modal)
|
|
}
|
|
|
|
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) }
|
|
checkKeyboardRouting(defaults: defaults)
|
|
checkQueue(directory: URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true), defaults: defaults)
|
|
checkWindowSurface(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
|
|
until("queue opening settles") { !model.isQueueTransitioning }
|
|
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
|
|
until("queue closing settles") { !model.isQueueTransitioning }
|
|
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 }
|
|
model.showsQueue = true
|
|
until("queue opens before live resize") { !model.isQueueTransitioning }
|
|
for delta in [NSSize(width: 160, height: 0), NSSize(width: 0, height: 90),
|
|
NSSize(width: -100, height: 0), NSSize(width: 0, height: -60)] {
|
|
let proposal = NSSize(width: window.frame.width + delta.width, height: window.frame.height + delta.height)
|
|
guard let constrained = window.delegate?.windowWillResize?(window, to: proposal) else {
|
|
fatalError("FAIL: native resize delegate must constrain queue video geometry")
|
|
}
|
|
var frame = window.frame
|
|
frame.size = constrained
|
|
window.setFrame(frame, display: true)
|
|
let content = window.contentRect(forFrameRect: window.frame)
|
|
require(abs((content.width - 260) / content.height - model.state.video_aspect_ratio) < 0.001,
|
|
"horizontal and vertical queue resizing preserves video aspect with fixed sidebar width")
|
|
require(delta.width == 0 ? abs(frame.height - proposal.height) < 1 : abs(frame.width - proposal.width) < 1,
|
|
"aspect-locked queue window follows the dragged axis")
|
|
}
|
|
model.showsQueue = false
|
|
until("queue closes after live resize") { !model.isQueueTransitioning }
|
|
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")
|
|
}
|
|
}
|