Compare commits

...

15 Commits

16 changed files with 1070 additions and 99 deletions
+27
View File
@@ -285,6 +285,33 @@ def check_cli_instances(root, app, fixtures):
and state["position"] > 0 and state["hasVideo"] for state in states.values()), and state["position"] > 0 and state["hasVideo"] for state in states.values()),
"cold default CLI opens one actual player window") "cold default CLI opens one actual player window")
first_pid = next(iter(first)) first_pid = next(iter(first))
renderer = first[first_pid]["window"]
if (not renderer.get("rendererAttached") or renderer.get("rendererHidden")
or abs(renderer["rendererWidth"] - renderer["videoWidth"]) > 1
or abs(renderer["rendererHeight"] - renderer["videoHeight"]) > 1):
raise RuntimeError(f"playing renderer must fill its visible native surface: {renderer}")
opened = input_request(first_pid, 1, lambda state: state["showsQueue"]
and state["window"].get("videoOwnsMouse") is True
and state["window"].get("rendererAttached") is True
and state["window"].get("rendererHidden") is False
and abs(state["window"]["videoWidth"] - first[first_pid]["window"]["videoWidth"]) < 1,
"queue preserves video size and mouse target", action="queue", visible=True)
window = opened[first_pid]["window"]
if (not window["resizable"] or window["aspectWidth"] != 0 or window["aspectHeight"] != 0
or window["resizeIncrementWidth"] <= 0 or window["resizeIncrementHeight"] <= 0
or window["minWidth"] > 660 or window["minHeight"] > 300
or window["maxWidth"] <= window["width"] + 80
or window["maxHeight"] <= window["height"] + 80
or not window["controlsOwnMouse"] or not window["sidebarOwnsMouse"]
or not window["edgesAvoidDragSurface"] or window["surfaceMovesOnMouseDown"]):
raise RuntimeError(f"queue must preserve native resize limits and independent mouse targets: {window}")
input_request(first_pid, 1, lambda state: not state["showsQueue"]
and abs(state["window"]["videoWidth"] - first[first_pid]["window"]["videoWidth"]) < 1,
"queue closes without changing video size", action="queue", visible=False)
print(f"PASS native queue window: independent video/controls/sidebar/edge targets, "
f"resizable min {window['minWidth']}x{window['minHeight']}, unconstrained maxima and cleared aspect")
if os.environ.get("TEREBI_CHECK_WINDOW_ONLY") == "1":
return
if settings(first[first_pid]) != (False, False, True) or config.parent.exists(): if settings(first[first_pid]) != (False, False, True) or config.parent.exists():
raise RuntimeError(f"missing config must use defaults without creating a file: {first}") raise RuntimeError(f"missing config must use defaults without creating a file: {first}")
invoke([]) invoke([])
+53
View File
@@ -98,6 +98,54 @@ private enum CLIInstanceCheck {
return nil return nil
} }
static func windowSnapshot(_ window: NSWindow?) -> [String: Any] {
guard let window, let content = window.contentView else { return [:] }
func surface(in view: NSView) -> NSView? {
if view.identifier?.rawValue == "PlaybackSurface" { return view }
return view.subviews.lazy.compactMap { surface(in: $0) }.first
}
var result: [String: Any] = [
"width": window.frame.width, "height": window.frame.height,
"minWidth": window.minSize.width, "minHeight": window.minSize.height,
"maxWidth": window.maxSize.width, "maxHeight": window.maxSize.height,
"contentMinWidth": window.contentMinSize.width, "contentMinHeight": window.contentMinSize.height,
"contentMaxWidth": window.contentMaxSize.width, "contentMaxHeight": window.contentMaxSize.height,
"aspectWidth": window.aspectRatio.width, "aspectHeight": window.aspectRatio.height,
"resizeIncrementWidth": window.resizeIncrements.width,
"resizeIncrementHeight": window.resizeIncrements.height,
"resizable": window.styleMask.contains(.resizable),
]
if let surface = surface(in: content) {
func hit(_ point: NSPoint) -> NSView? {
content.hitTest(surface.convert(point, to: content.superview))
}
result["videoOwnsMouse"] = hit(NSPoint(x: surface.bounds.midX, y: surface.bounds.midY)) === surface
result["controlsOwnMouse"] = hit(NSPoint(x: surface.bounds.midX, y: 46)) !== surface
result["sidebarOwnsMouse"] = hit(NSPoint(x: surface.bounds.maxX + 130, y: surface.bounds.midY)) !== surface
result["videoWidth"] = surface.bounds.width
result["videoHeight"] = surface.bounds.height
if let video = PlayerModel.shared.videoView {
result["rendererWidth"] = video.bounds.width
result["rendererHeight"] = video.bounds.height
result["rendererAttached"] = video.superview === surface
result["rendererHidden"] = video.isHidden
}
result["surfaceMovesOnMouseDown"] = surface.mouseDownCanMoveWindow
let edges = [NSPoint(x: 1, y: content.bounds.midY),
NSPoint(x: content.bounds.maxX - 1, y: content.bounds.midY),
NSPoint(x: content.bounds.midX, y: 1),
NSPoint(x: content.bounds.midX, y: content.bounds.maxY - 1)]
let targets = edges.map { point in
content.hitTest(content.convert(point, to: content.superview))
}
result["edgeTargets"] = targets.map { $0.map { String(describing: type(of: $0)) } ?? "native window" }
result["edgesAvoidDragSurface"] = targets.allSatisfy { target in
target !== surface && !(target.map { String(describing: type(of: $0)).contains("ChromeView") } ?? false)
}
}
return result
}
static func run(_ configuration: URL) { static func run(_ configuration: URL) {
let settings = try! JSONSerialization.jsonObject(with: Data(contentsOf: configuration)) as! [String: String] let settings = try! JSONSerialization.jsonObject(with: Data(contentsOf: configuration)) as! [String: String]
let directory = URL(fileURLWithPath: settings["directory"]!, isDirectory: true) let directory = URL(fileURLWithPath: settings["directory"]!, isDirectory: true)
@@ -147,6 +195,9 @@ private enum CLIInstanceCheck {
switch action { switch action {
case "youtube": case "youtube":
model.openYouTube(command["url"] as! String) model.openYouTube(command["url"] as! String)
case "queue":
model.showsQueue = command["visible"] as! Bool
model.revealControls()
case "caption": case "caption":
guard let view = window.contentView, let selector = captionSelector(in: view), guard let view = window.contentView, let selector = captionSelector(in: view),
let cell = selector.cell as? NSPopUpButtonCell, let menu = selector.menu else { let cell = selector.cell as? NSPopUpButtonCell, let menu = selector.menu else {
@@ -252,6 +303,8 @@ private enum CLIInstanceCheck {
"mainWindow": windows.first?.isMainWindow ?? false, "mainWindow": windows.first?.isMainWindow ?? false,
"editorText": editorText, "editorText": editorText,
"compact": model.isCompact, "compact": model.isCompact,
"showsQueue": model.showsQueue,
"window": windowSnapshot(windows.first),
"onTop": model.isOnTop, "onTop": model.isOnTop,
] ]
do { do {
+46
View File
@@ -17,6 +17,36 @@ struct CastPlaybackCheck {
require(predicate(), message) require(predicate(), message)
} }
static func queued(_ model: PlayerModel, source: URL, port: UInt16, gates: URL) async throws {
model.openFiles([source, source, source])
try await until(model, "queue local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port))
try await until(model, "queue receiver starts") {
model.castDevice?.id == "loopback" && !model.castConnecting && model.state.status == PLAYER_PLAYING
}
let initial = model.queue.currentID
model.advanceQueue(1)
try await until(model, "manual queue next retains receiver") {
model.queue.currentID != initial && model.castDevice?.id == "loopback" &&
!model.castConnecting && model.state.status == PLAYER_PLAYING
}
let middle = model.queue.currentID
try Data().write(to: gates.appendingPathComponent("next"), options: .atomic)
try await until(model, "receiver EOF automatically advances queue without disconnecting") {
model.queue.currentID != middle && model.castDevice?.id == "loopback" &&
!model.castConnecting && model.state.status == PLAYER_PLAYING
}
require(!model.queue.canNext, "receiver automatic advance selects final queue entry")
try Data().write(to: gates.appendingPathComponent("advanced"), options: .atomic)
try await until(model, "final receiver EOF stops without wrapping") { model.state.status == PLAYER_ENDED }
require(model.castDevice?.id == "loopback" && !model.queue.canNext,
"final receiver EOF preserves receiver and last selection")
model.stopCasting()
try await until(model, "Cast restoration keeps local queue") { model.castDevice == nil && !model.isBusy }
require(model.queue.items.count == 3 && !model.queue.canNext,
"Cast restoration does not replace queue")
}
static func interrupted(_ model: PlayerModel, source: URL, port: UInt16, static func interrupted(_ model: PlayerModel, source: URL, port: UInt16,
scenario: String, gates: URL, defaults: UserDefaults) async throws { scenario: String, gates: URL, defaults: UserDefaults) async throws {
func signal(_ name: String) throws { func signal(_ name: String) throws {
@@ -75,6 +105,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 +139,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)
@@ -180,6 +213,10 @@ struct CastPlaybackCheck {
let model = PlayerModel(defaults: defaults) let model = PlayerModel(defaults: defaults)
defer { model.shutdown() } defer { model.shutdown() }
if CommandLine.arguments.count == 5 { if CommandLine.arguments.count == 5 {
if CommandLine.arguments[3] == "queue" {
try await queued(model, source: source, port: port, gates: URL(fileURLWithPath: CommandLine.arguments[4]))
return
}
try await interrupted(model, source: source, port: port, scenario: CommandLine.arguments[3], try await interrupted(model, source: source, port: port, scenario: CommandLine.arguments[3],
gates: URL(fileURLWithPath: CommandLine.arguments[4]), defaults: defaults) gates: URL(fileURLWithPath: CommandLine.arguments[4]), defaults: defaults)
return return
@@ -187,29 +224,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 +264,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")
} }
} }
+32
View File
@@ -791,6 +791,36 @@ struct CastSessionCheck {
require(peer.relayVerified, "Receiver fetches real relay HLS and media bytes") require(peer.relayVerified, "Receiver fetches real relay HLS and media bytes")
} }
static func queuedPlayback(identity: SecIdentity, executable: String, file: String) async throws {
let peer = try Receiver(identity: identity)
peer.verifyRelay = true
try await peer.start()
defer { peer.stop() }
let gates = FileManager.default.temporaryDirectory.appendingPathComponent("terebi-cast-queue-" + UUID().uuidString)
try FileManager.default.createDirectory(at: gates, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: gates) }
let process = Process()
process.executableURL = URL(fileURLWithPath: executable)
process.arguments = [file, String(peer.port), "queue", gates.path]
try process.run()
defer { if process.isRunning { process.terminate() } }
for stage in ["next", "advanced"] {
try await until("queue consumer reaches \(stage)", attempts: 18000) {
FileManager.default.fileExists(atPath: gates.appendingPathComponent(stage).path) ||
!process.isRunning || peer.failure != nil
}
require(peer.failure == nil && process.isRunning, peer.failure ?? "queue consumer remains active")
require(FileManager.default.fileExists(atPath: gates.appendingPathComponent(stage).path),
"queue consumer acknowledges \(stage)")
try peer.status(state: "IDLE", idle: "FINISHED")
}
try await until("queue consumer completes", attempts: 18000) { !process.isRunning || peer.failure != nil }
require(peer.failure == nil && !process.isRunning && process.terminationStatus == 0,
peer.failure ?? "queue navigation and EOF retain receiver")
require(peer.commands.filter { $0.0 == "LOAD" }.count == 3 && peer.relayVerified,
"initial, manual-next and automatic-next LOADs fetch real relay media")
}
static func interruptedPlayback(identity: SecIdentity, executable: String, file: String) async throws { static func interruptedPlayback(identity: SecIdentity, executable: String, file: String) async throws {
for scenario in ["cancel-connect", "cancel-load", "reject-load", "replace-load", "close-load", for scenario in ["cancel-connect", "cancel-load", "reject-load", "replace-load", "close-load",
"replace-active", "close-active", "shutdown-load", "shutdown-active", "disconnect-active"] { "replace-active", "close-active", "shutdown-load", "shutdown-active", "disconnect-active"] {
@@ -913,6 +943,8 @@ struct CastSessionCheck {
print("PASS: native TLS receiver fetches relay and acknowledges PlayerModel handoff/control/disconnect") print("PASS: native TLS receiver fetches relay and acknowledges PlayerModel handoff/control/disconnect")
try await interruptedPlayback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3]) try await interruptedPlayback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
print("PASS: interrupted Cast connect/LOAD, rejection, replacement/close, latest intent and acknowledged-position recovery") print("PASS: interrupted Cast connect/LOAD, rejection, replacement/close, latest intent and acknowledged-position recovery")
try await queuedPlayback(identity: identity, executable: CommandLine.arguments[2], file: CommandLine.arguments[3])
print("PASS: Cast queue next, automatic advance, final EOF and local restoration")
} else { } else {
try await behavior(identity: identity) try await behavior(identity: identity)
try await seekCoalescing(identity: identity) try await seekCoalescing(identity: identity)
+35
View File
@@ -472,11 +472,46 @@ struct CompactWindowChecks {
} }
} }
static func continuousQueueResize() throws {
let aspect = 16.0 / 9.0
let sidebar: CGFloat = 260
let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 1_160, height: 900 / aspect),
styleMask: [.titled, .resizable, .fullSizeContentView],
backing: .buffered, defer: false)
window.isReleasedWhenClosed = false
defer { window.close() }
let controller = CompactWindowController()
controller.normalVideoLayout = { (aspect, sidebar) }
controller.attach(window)
for horizontal in [true, false] {
let start = window.frame
let chrome = start.height - window.contentRect(forFrameRect: start).height
window.delegate?.windowWillStartLiveResize?(Notification(name: NSWindow.willStartLiveResizeNotification, object: window))
// AppKit proposes sizes from the pointer's drag origin. The delegate's
// correction of the other dimension must not feed back into axis selection.
for offset in [CGFloat(20), 21, 22, 23, 24, 23, 22, 21, 20, 0, -1, -2] {
let proposal = NSSize(width: start.width + (horizontal ? offset : 0),
height: start.height + (horizontal ? 0 : offset))
let size = window.delegate!.windowWillResize!(window, to: proposal)
try require(close(horizontal ? size.width : size.height,
horizontal ? proposal.width : proposal.height),
"continuous queue resize follows the dragged edge without snapping back")
try require(close((size.width - sidebar) / (size.height - chrome), aspect),
"continuous queue resize keeps the video aspect")
var frame = start
frame.size = size
window.setFrame(frame, display: false)
}
window.delegate?.windowDidEndLiveResize?(Notification(name: NSWindow.didEndLiveResizeNotification, object: window))
}
}
static func main() throws { static func main() throws {
try geometry() try geometry()
try windowBehavior() try windowBehavior()
try reducedMotion() try reducedMotion()
try rememberedSize() try rememberedSize()
try continuousQueueResize()
print("Compact window checks passed") print("Compact window checks passed")
} }
} }
+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)
}
}
+71
View File
@@ -0,0 +1,71 @@
import Foundation
@main
struct PlaybackQueueChecks {
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() { fatalError("FAIL: " + message) }
}
static func file(_ name: String) -> URL {
URL(fileURLWithPath: "/queue-check/" + name + ".mp4")
}
static func main() {
let first = file("Episode1"), second = file("Episode2"), tenth = file("Episode10")
var queue = PlaybackQueue()
require(queue.items.isEmpty && queue.currentURL == nil && !queue.canPrevious && !queue.canNext,
"empty queue has no selection or navigation")
require(queue.advance(1) == nil && queue.advance(-1) == nil, "empty navigation is harmless")
queue.replace([tenth, second, first])
require(queue.items.map(\.url) == [first, second, tenth], "replace uses natural Episode2/Episode10 filename order")
require(queue.currentURL == first && !queue.canPrevious && queue.canNext, "replace selects first item")
let firstID = queue.currentID
require(queue.advance(-1) == nil && queue.currentID == firstID, "previous at start does not wrap or deselect")
require(queue.advance(1) == second && queue.canPrevious && queue.canNext, "next selects middle item")
require(queue.advance(1) == tenth && queue.canPrevious && !queue.canNext, "next selects final item")
let tenthID = queue.currentID
require(queue.advance(1) == nil && queue.currentID == tenthID, "next at end stops without wrapping")
require(queue.advance(-1) == second, "previous moves back one item")
let selected = queue.currentID!
queue.append([file("Episode20"), file("Episode3")])
require(queue.items.map(\.url) == [first, second, tenth, file("Episode3"), file("Episode20")],
"append naturally sorts the new batch without reordering existing items")
require(queue.currentID == selected && queue.currentURL == second, "append does not interrupt selection")
queue.move(fromOffsets: IndexSet(integer: 1), toOffset: queue.items.count)
require(queue.items.last?.id == selected && queue.currentID == selected && queue.currentURL == second,
"reordering preserves current item identity")
require(queue.canPrevious && !queue.canNext, "navigation follows reordered positions")
queue.move(fromOffsets: IndexSet([0, 1]), toOffset: queue.items.count)
require(queue.currentID == selected && queue.currentURL == second, "moving other items preserves current")
queue.replace([tenth, second, first])
queue.select(queue.items[1].id)
require(queue.currentURL == second, "explicit selection jumps to an item")
let current = queue.currentID
let unrelated = queue.items[0].id
require(queue.remove(unrelated) == nil && queue.currentID == current && queue.currentURL == second,
"removing another item does not request playback or change selection")
require(queue.remove(current!) == tenth && queue.currentURL == tenth,
"removing current selects the next remaining item")
require(queue.remove(queue.currentID!) == nil && queue.currentURL == nil && queue.items.isEmpty,
"removing final item stops and empties queue")
queue.replace([first, second, tenth])
queue.select(queue.items.last!.id)
require(queue.remove(queue.currentID!) == nil && queue.currentID == nil && queue.items.count == 2,
"removing current at end stops rather than jumping backward")
require(!queue.canPrevious && !queue.canNext, "stopped queue has no implicit selection")
queue.select(queue.items[0].id)
require(queue.currentURL == first, "remaining items can be explicitly selected after stopping")
queue.append([first])
require(Set(queue.items.map(\.id)).count == queue.items.count, "duplicate file entries have independent identities")
queue.replace([tenth])
require(queue.items.map(\.url) == [tenth] && queue.currentURL == tenth && !queue.canPrevious && !queue.canNext,
"replace discards previous queue and starts new selection")
queue.replace([])
require(queue.items.isEmpty && queue.currentID == nil, "empty replacement clears queue")
print("playback queue checks passed")
}
}
+257 -2
View File
@@ -2,6 +2,20 @@ import AppKit
import MediaPlayer import MediaPlayer
import TerebiConfiguration 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 @main
@MainActor @MainActor
struct SystemPlaybackChecks { struct SystemPlaybackChecks {
@@ -17,6 +31,50 @@ struct SystemPlaybackChecks {
require(condition(), message) 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)
surface.frame = window.contentView!.bounds
window.contentView!.addSubview(surface)
model.attachWindow(window)
@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() { static func checkCompactProgress() {
let times: [(Double, Double, String)] = [ let times: [(Double, Double, String)] = [
(12.9, 45.9, "12 / 45 s"), (12.9, 45.9, "12 / 45 s"),
@@ -33,9 +91,11 @@ struct SystemPlaybackChecks {
require(compactProgressText(position: position, duration: duration, format: .time) == expected, require(compactProgressText(position: position, duration: duration, format: .time) == expected,
"compact time uses total-duration units, truncation and bounded progress: \(expected)") "compact time uses total-duration units, truncation and bounded progress: \(expected)")
} }
for (position, expected) in [(59.9, "59"), (100.0, "100"), (105.0, "100"), (-1.0, "0")] { 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, require(compactProgressText(position: position, duration: 100, format: .percentage) == expected,
"compact percentage is a bounded integer: \(expected)") "compact percentage has one decimal and stays bounded: \(expected)")
} }
for duration in [0.0, -1.0, .nan, .infinity] { for duration in [0.0, -1.0, .nan, .infinity] {
require(compactProgressText(position: 10, duration: duration, format: .time) == nil && require(compactProgressText(position: 10, duration: duration, format: .time) == nil &&
@@ -49,6 +109,151 @@ struct SystemPlaybackChecks {
"disabled compact progress leaves only the title") "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() { static func main() {
checkCompactProgress() checkCompactProgress()
_ = NSApplication.shared _ = NSApplication.shared
@@ -56,6 +261,10 @@ struct SystemPlaybackChecks {
let suite = "terebi.system-playback-check.\(UUID().uuidString)" let suite = "terebi.system-playback-check.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)! let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: 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) let initialModel = PlayerModel(defaults: defaults)
require(!initialModel.showsBufferInfo, "buffer readout defaults off before playback") require(!initialModel.showsBufferInfo, "buffer readout defaults off before playback")
initialModel.setShowsBufferInfo(true) initialModel.setShowsBufferInfo(true)
@@ -77,6 +286,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()
@@ -87,6 +297,15 @@ struct SystemPlaybackChecks {
window.isReleasedWhenClosed = false window.isReleasedWhenClosed = false
defer { window.close() } defer { window.close() }
model.attachWindow(window) 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")) model.open(directory.appendingPathComponent("anamorphic.mp4"))
require(model.isBusy && model.perform(.enterCompact) && model.isCompact, require(model.isBusy && model.perform(.enterCompact) && model.isCompact,
"compact entry is available before native media opening acknowledges") "compact entry is available before native media opening acknowledges")
@@ -107,9 +326,32 @@ struct SystemPlaybackChecks {
model.setPointerInsideWindow(true) model.setPointerInsideWindow(true)
} }
until("open starts authoritative playback") { model.canTogglePlayback && model.state.status == PLAYER_PLAYING } until("open starts authoritative playback") { model.canTogglePlayback && model.state.status == PLAYER_PLAYING }
model.showsQueue = true
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
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)
@@ -213,6 +455,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")
@@ -277,9 +520,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")
} }
} }
+13 -7
View File
@@ -13,7 +13,7 @@ PREFIX ?= $(HOME)/.local
CXXFLAGS = -std=c++20 -mmacosx-version-min=15.0 -O2 $(DEBUG_FLAGS) -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS) CXXFLAGS = -std=c++20 -mmacosx-version-min=15.0 -O2 $(DEBUG_FLAGS) -Wall -Wextra -Wno-unused-parameter $(FF_CFLAGS)
FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore -framework MediaPlayer FRAMEWORKS := -framework AppKit -framework AudioToolbox -framework CoreAudio -framework Metal -framework MetalKit -framework QuartzCore -framework MediaPlayer
OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/Output.o $(BUILD_DIR)/cast_media.o OBJECTS := $(BUILD_DIR)/player.o $(BUILD_DIR)/progress_reporter.o $(BUILD_DIR)/Output.o $(BUILD_DIR)/cast_media.o
SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/PlayerActions.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift Sources/App/SystemPlaybackControls.swift Sources/App/AutomaticQuality.swift Sources/App/ResolvedSource.swift Sources/App/ExternalSubtitles.swift Sources/App/CastSession.swift Sources/App/CastMediaServer.swift Sources/App/CastControls.swift SWIFT_SOURCES := Sources/App/TerebiApp.swift Sources/App/PlayerActions.swift Sources/App/CompactWindow.swift Sources/App/YouTubeResolver.swift Sources/App/PlaybackProgress.swift Sources/App/PlaybackQueue.swift Sources/App/SystemPlaybackControls.swift Sources/App/AutomaticQuality.swift Sources/App/ResolvedSource.swift Sources/App/ExternalSubtitles.swift Sources/App/CastSession.swift Sources/App/CastMediaServer.swift Sources/App/CastControls.swift
RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift RUNTIME_SOURCE := Sources/Launcher/FFmpegRuntime.swift
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift $(RUNTIME_SOURCE) CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift $(RUNTIME_SOURCE)
CONFIG_BUILD := .build/configuration CONFIG_BUILD := .build/configuration
@@ -28,7 +28,7 @@ BUNDLE_STAMP := $(BUILD_DIR)/bundle.stamp
SIGNING_IDENTITY ?= - SIGNING_IDENTITY ?= -
DECODE_FIXTURES := $(addprefix $(BUILD_DIR)/fixtures/decode-,h264.mp4 hevc.mp4 vp9.mp4 av1.mp4 h264-10.mp4 mpeg4.mp4) DECODE_FIXTURES := $(addprefix $(BUILD_DIR)/fixtures/decode-,h264.mp4 hevc.mp4 vp9.mp4 av1.mp4 h264-10.mp4 mpeg4.mp4)
.PHONY: all release install check-dependencies run check check-seek-queue check-video-decoding check-config check-fixtures check-audio check-window check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast .PHONY: all release install check-dependencies run check check-seek-queue check-video-decoding check-config check-fixtures check-audio check-window check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast check-queue
all: $(BUILD_DIR)/terebi all: $(BUILD_DIR)/terebi
$(NATIVE_STAMP): Scripts/dependencies.py Dependencies.lock.json $(NATIVE_STAMP): Scripts/dependencies.py Dependencies.lock.json
@@ -140,7 +140,7 @@ $(BUILD_DIR)/seek-queue-check: Checks/seek-queue.cpp Sources/Core/player.cpp Sou
check-seek-queue: $(BUILD_DIR)/seek-queue-check check-seek-queue: $(BUILD_DIR)/seek-queue-check
$(BUILD_DIR)/seek-queue-check $(BUILD_DIR)/seek-queue-check
check: check-dependencies $(BUILD_DIR)/playback-check check-seek-queue check-video-decoding check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast check: check-dependencies $(BUILD_DIR)/playback-check check-seek-queue check-video-decoding check-fixtures check-audio check-window check-config check-youtube check-quality check-adaptive check-network check-progress check-reporting check-cli check-runtime check-media check-startup check-bundle check-subtitles check-resolved check-cast check-queue
$(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures $(BUILD_DIR)/playback-check $(BUILD_DIR)/fixtures
check-fixtures: | check-dependencies check-fixtures: | check-dependencies
@@ -200,6 +200,12 @@ $(BUILD_DIR)/automatic-quality-check: Checks/automatic-quality.swift Sources/App
check-quality: $(BUILD_DIR)/automatic-quality-check check-quality: $(BUILD_DIR)/automatic-quality-check
$(BUILD_DIR)/automatic-quality-check $(BUILD_DIR)/automatic-quality-check
$(BUILD_DIR)/playback-queue-check: Checks/playback-queue.swift Sources/App/PlaybackQueue.swift Makefile | $(BUILD_DIR)
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -O $(DEBUG_FLAGS) Checks/playback-queue.swift Sources/App/PlaybackQueue.swift -framework Foundation -o $@
check-queue: $(BUILD_DIR)/playback-queue-check
$(BUILD_DIR)/playback-queue-check
$(BUILD_DIR)/automatic-quality-playback-check: Checks/automatic-quality-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies $(BUILD_DIR)/automatic-quality-playback-check: Checks/automatic-quality-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 $@ $(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 $@
@@ -263,8 +269,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 +284,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
+26 -6
View File
@@ -31,7 +31,7 @@ Builds are ad-hoc signed for personal use, not notarized. Bundled licenses and s
## Usage ## Usage
Open a file with **⌘O**, drag it onto the window, or use Finder's **Open With**. Open YouTube with **⇧⌘O** or drop a video link. Media starts automatically and resumes its saved position; finished videos restart from the beginning. Live and audio-only media do not save progress. Open one or more local files with **⌘O**, drag them onto the video, or use Finder's **Open With**. A selection replaces the session's queue and plays in natural filename order (for example, episode 2 before episode 10). Open YouTube with **⇧⌘O** or drop a video link. Media starts automatically and resumes its saved position; finished videos restart from the beginning. Live and audio-only media do not save progress.
| Action | Default shortcut | | Action | Default shortcut |
| --- | --- | | --- | --- |
@@ -49,7 +49,23 @@ Open a file with **⌘O**, drag it onto the window, or use Finder's **Open With*
Compact mode keeps playback in the same floating window; drag to move it and double-click to restore. The titlebar pin keeps the normal window on top. macOS media keys also control playback; previous/next seek five seconds rather than changing tracks. Compact mode keeps playback in the same floating window; drag to move it and double-click to restore. The titlebar pin keeps the normal window on top. macOS media keys also control playback; previous/next seek five seconds rather than changing tracks.
Playback controls stay anchored at the bottom center of the video and cannot be dragged. Drag the video or unused playback background to move the window, including with the queue open. A single video click hides the controls; double-click toggles fullscreen. Playback controls stay anchored at the bottom center of the video and cannot be dragged. Native window edges remain available for resizing; the open queue allows free resizing with letterboxed video.
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.
### Local-file queue
Use the titlebar's **Queue** icon or **View → Show Queue** to show the editable sidebar. **Add to Queue…** and dropping files onto the sidebar append without replacing the queue or interrupting the current item. Drag rows to reorder, remove them with the minus button, and double-click a row to play it. The current item is marked; context menus also offer Play, Remove and Move Up/Down.
In a normal window, showing the queue expands the window outward while preserving the video's size; hiding it removes that extra width. The sidebar meets the video directly, without a separate divider gap. The window shifts left when necessary near the screen's right edge. Fullscreen keeps its fixed bounds.
Resizing with the queue open preserves the video's aspect ratio while keeping the sidebar at a fixed width; closing the queue retains ordinary aspect-locked video resizing.
While the sidebar is open, its persistent top header holds the queue count, queue toggle, pin, Cast and settings controls without a surrounding glass container; these do not fade with the video controls. Closing the sidebar returns those controls to the auto-hiding video titlebar with its glass container. Space and configured playback shortcuts continue working with queue-list focus; Tab navigation, deliberately focused buttons/sliders and text entry keep their native keyboard behavior.
Playback advances automatically and stops at the end, without wrapping. **Previous Queue Item** and **Next Queue Item** are available in the sidebar and Playback menu and are disabled at their respective ends. Reordering or removing another item does not interrupt playback; removing the current item plays its next neighbor, or stops if there is none. A playback error stays on that item rather than skipping it.
The queue contains local files only and lasts until it is replaced, closed or the app quits; it has no persistence, shuffle, repeat or playlist-file import. Opening a YouTube video or resolved stream clears it. The sidebar is hidden in compact mode; Playback-menu queue navigation remains available. Sidebar appearance, drag/drop interaction and physical-receiver queue transitions still need manual verification.
### YouTube ### YouTube
@@ -59,9 +75,9 @@ 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 without changing the local-file queue. Queue navigation and automatic advance keep the selected receiver; opening a replacement selection or different stream 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.
External Cast subtitles use white sans-serif text with a black outline, transparent background and no enclosing window. Font scale currently uses the receiver default (1.0), pending physical-TV tuning. Language selection and Off remain independent of video delivery; no subtitle burn-in is used. External Cast subtitles use white sans-serif text with a black outline, transparent background and no enclosing window. Font scale currently uses the receiver default (1.0), pending physical-TV tuning. Language selection and Off remain independent of video delivery; no subtitle burn-in is used.
@@ -112,7 +128,7 @@ automatic_quality = true
[ui] [ui]
buffer_info = true buffer_info = true
compact_snap_to_edges = true compact_snap_to_edges = true
compact_title_progress = "percentage" # "time", "percentage", or "none" compact_title_progress = "percentage" # "time", "percentage" (one decimal; 99.9 rounds up to 100.0), or "none"
[keys] [keys]
play = "space" play = "space"
@@ -127,7 +143,7 @@ Settings load once at startup. Menu changes are session-only and never write the
## Limits ## Limits
- SDR output only; PQ/HLG HDR is rejected. No playlists, embedded/YouTube captions, manual audio-track selection, rotation, or deinterlacing. External resolved-source WebVTT subtitles are supported. - SDR output only; PQ/HLG HDR is rejected. No stream playlists, playlist-file import, embedded/YouTube captions, manual audio-track selection, rotation, or deinterlacing. The session-only local-file queue and external resolved-source WebVTT subtitles are supported.
- H.264, HEVC, and VP9 prefer VideoToolbox decoding with software fallback; other codecs, including AV1, use software. Backward frame stepping can be slow on long-GOP or unindexed media. - H.264, HEVC, and VP9 prefer VideoToolbox decoding with software fallback; other codecs, including AV1, use software. Backward frame stepping can be slow on long-GOP or unindexed media.
- Network buffering is bounded and memory-only; live/non-seekable sources disable seeking and frame stepping. Expired stream URLs are not automatically refreshed. - Network buffering is bounded and memory-only; live/non-seekable sources disable seeking and frame stepping. Expired stream URLs are not automatically refreshed.
- Audio-device changes during playback are not handled. - Audio-device changes during playback are not handled.
@@ -141,6 +157,10 @@ make check
The full suite requires a graphical macOS session and a working audio device; it launches isolated app instances and plays short test audio. Targeted `check-*` targets are listed in the [Makefile](Makefile). Visual appearance, physical media-key routing, and receiver behavior still need manual checks. The full suite requires a graphical macOS session and a working audio device; it launches isolated app instances and plays short test audio. Targeted `check-*` targets are listed in the [Makefile](Makefile). Visual appearance, physical media-key routing, and receiver behavior still need manual checks.
The startup checks verify that playing video remains attached, unhidden and sized to its native drag surface, including with the queue open; playback-state checks alone do not establish renderer visibility.
Queue-open resize checks require positive native resize increments and exercise continuous horizontal/vertical resize-delegate sequences, including reversals, to preserve video aspect without snap-back. The driving axis is fixed for each live resize gesture so aspect corrections do not feed back into axis selection. Physical edge-drag resizing still needs manual verification.
`make benchmark-playback` measures cold source startup, cached/uncached seeks, and sustained native/Cast playback with repeatable loopback fixtures. It requires the same macOS session/audio device and fixture-generation FFmpeg; results exclude live extraction, WAN/TLS, and physical-receiver latency. `make benchmark-playback` measures cold source startup, cached/uncached seeks, and sustained native/Cast playback with repeatable loopback fixtures. It requires the same macOS session/audio device and fixture-generation FFmpeg; results exclude live extraction, WAN/TLS, and physical-receiver latency.
The [benchmark runner](Checks/playback-benchmark.py) accepts `--compare-native <baseline-binary>` for alternating native-only comparisons; add `--seek-work` to isolate cache-miss seeks when both binaries support that mode. Preserve the baseline executable before rebuilding. The [benchmark runner](Checks/playback-benchmark.py) accepts `--compare-native <baseline-binary>` for alternating native-only comparisons; add `--seek-work` to isolate cache-miss seeks when both binaries support that mode. Preserve the baseline executable before rebuilding.
+48 -1
View File
@@ -4,6 +4,7 @@ import QuartzCore
@MainActor @MainActor
final class CompactWindowController: NSObject { final class CompactWindowController: NSObject {
var onChange: (() -> Void)? var onChange: (() -> Void)?
var normalVideoLayout: (() -> (aspect: Double, sidebarWidth: CGFloat)?)?
private(set) var isCompact = false private(set) var isCompact = false
private(set) var isTransitioning = false private(set) var isTransitioning = false
static let transitionDuration: TimeInterval = 0.25 static let transitionDuration: TimeInterval = 0.25
@@ -20,6 +21,7 @@ final class CompactWindowController: NSObject {
let minSize: NSSize let minSize: NSSize
let contentMinSize: NSSize let contentMinSize: NSSize
let aspectRatio: NSSize let aspectRatio: NSSize
let resizeIncrements: NSSize
let styleMask: NSWindow.StyleMask let styleMask: NSWindow.StyleMask
let titleVisibility: NSWindow.TitleVisibility let titleVisibility: NSWindow.TitleVisibility
let titlebarAppearsTransparent: Bool let titlebarAppearsTransparent: Bool
@@ -34,6 +36,7 @@ final class CompactWindowController: NSObject {
minSize = window.minSize minSize = window.minSize
contentMinSize = window.contentMinSize contentMinSize = window.contentMinSize
aspectRatio = window.aspectRatio aspectRatio = window.aspectRatio
resizeIncrements = window.resizeIncrements
styleMask = window.styleMask styleMask = window.styleMask
titleVisibility = window.titleVisibility titleVisibility = window.titleVisibility
titlebarAppearsTransparent = window.titlebarAppearsTransparent titlebarAppearsTransparent = window.titlebarAppearsTransparent
@@ -194,6 +197,8 @@ final class CompactWindowController: NSObject {
// NSObject forwarding returns untyped Any; accesses remain on AppKit's main thread. // NSObject forwarding returns untyped Any; accesses remain on AppKit's main thread.
nonisolated(unsafe) weak var original: (any NSWindowDelegate)? nonisolated(unsafe) weak var original: (any NSWindowDelegate)?
private weak var owner: CompactWindowController? private weak var owner: CompactWindowController?
private var resizeStartSize: NSSize?
private var resizeWidthDriven: Bool?
init(owner: CompactWindowController, original: (any NSWindowDelegate)?) { init(owner: CompactWindowController, original: (any NSWindowDelegate)?) {
self.owner = owner self.owner = owner
@@ -210,6 +215,44 @@ final class CompactWindowController: NSObject {
return responds ? original : nil return responds ? original : nil
} }
func windowWillStartLiveResize(_ notification: Notification) {
resizeStartSize = (notification.object as? NSWindow)?.frame.size
resizeWidthDriven = nil
original?.windowWillStartLiveResize?(notification)
}
func windowDidEndLiveResize(_ notification: Notification) {
resizeStartSize = nil
resizeWidthDriven = nil
original?.windowDidEndLiveResize?(notification)
}
func windowWillResize(_ sender: NSWindow, to proposedSize: NSSize) -> NSSize {
let proposed = original?.windowWillResize?(sender, to: proposedSize) ?? proposedSize
guard let owner, !owner.isCompact, !owner.isTransitioning,
!sender.styleMask.contains(.fullScreen),
let layout = owner.normalVideoLayout?(),
layout.aspect.isFinite, layout.aspect > 0 else { return proposed }
// The sidebar stays fixed-width; only the video area is aspect-locked.
let chrome = sender.frame.height - sender.contentRect(forFrameRect: sender.frame).height
let start = resizeStartSize ?? sender.frame.size
let widthChange = abs(proposed.width - start.width)
let heightChange = abs(proposed.height - start.height) * layout.aspect
// Latch the user's axis once. Comparing against each corrected frame
// makes our own aspect adjustment look like a new drag on the other axis.
if resizeStartSize != nil, resizeWidthDriven == nil,
widthChange > 0 || heightChange > 0 {
resizeWidthDriven = widthChange >= heightChange
}
let widthDriven = resizeWidthDriven ?? (widthChange >= heightChange)
let minimumHeight = max(sender.minSize.height - chrome,
(sender.minSize.width - layout.sidebarWidth) / layout.aspect)
let height = max(minimumHeight, widthDriven
? (proposed.width - layout.sidebarWidth) / layout.aspect
: proposed.height - chrome)
return NSSize(width: height * layout.aspect + layout.sidebarWidth, height: height + chrome)
}
func windowDidFailToEnterFullScreen(_ window: NSWindow) { func windowDidFailToEnterFullScreen(_ window: NSWindow) {
owner?.fullScreenFailed() owner?.fullScreenFailed()
original?.windowDidFailToEnterFullScreen?(window) original?.windowDidFailToEnterFullScreen?(window)
@@ -310,7 +353,11 @@ final class CompactWindowController: NSObject {
window.minSize = snapshot.minSize window.minSize = snapshot.minSize
window.contentMinSize = snapshot.contentMinSize window.contentMinSize = snapshot.contentMinSize
window.setFrame(snapshot.frame, display: true) window.setFrame(snapshot.frame, display: true)
if snapshot.aspectRatio == .zero {
window.resizeIncrements = snapshot.resizeIncrements
} else {
window.aspectRatio = snapshot.aspectRatio window.aspectRatio = snapshot.aspectRatio
}
self.snapshot = nil self.snapshot = nil
} }
@@ -380,7 +427,7 @@ final class CompactWindowController: NSObject {
frame.origin.x = min(max(window.frame.minX, usable.minX), usable.maxX - frame.width) frame.origin.x = min(max(window.frame.minX, usable.minX), usable.maxX - frame.width)
frame.origin.y = min(max(window.frame.minY, usable.minY), usable.maxY - frame.height) frame.origin.y = min(max(window.frame.minY, usable.minY), usable.maxY - frame.height)
} }
window.aspectRatio = .zero window.resizeIncrements = NSSize(width: 1, height: 1)
animateFrame(frame, of: window, animated: animated) { [weak self, weak window] in animateFrame(frame, of: window, animated: animated) { [weak self, weak window] in
guard let self, let window else { return } guard let self, let window else { return }
if Self.canFitAspect(aspect, available: usable.size) { if Self.canFitAspect(aspect, available: usable.size) {
+62
View File
@@ -0,0 +1,62 @@
import Foundation
struct PlaybackQueue {
struct Item: Identifiable {
let id = UUID()
let url: URL
}
private(set) var items: [Item] = []
private(set) var currentID: UUID?
var currentURL: URL? { items.first { $0.id == currentID }?.url }
private var currentIndex: Int? { items.firstIndex { $0.id == currentID } }
var canPrevious: Bool { currentIndex.map { $0 > 0 } ?? false }
var canNext: Bool { currentIndex.map { $0 + 1 < items.count } ?? false }
mutating func replace(_ urls: [URL]) {
items = sortedFiles(urls).map { Item(url: $0) }
currentID = items.first?.id
}
mutating func append(_ urls: [URL]) {
items.append(contentsOf: sortedFiles(urls).map { Item(url: $0) })
}
mutating func select(_ id: UUID) {
guard items.contains(where: { $0.id == id }) else { return }
currentID = id
}
mutating func advance(_ offset: Int) -> URL? {
guard let index = currentIndex else { return nil }
let (target, overflow) = index.addingReportingOverflow(offset)
guard !overflow, items.indices.contains(target) else { return nil }
currentID = items[target].id
return items[target].url
}
mutating func remove(_ id: UUID) -> URL? {
guard let index = items.firstIndex(where: { $0.id == id }) else { return nil }
let wasCurrent = currentID == id
items.remove(at: index)
guard wasCurrent else { return nil }
currentID = items.indices.contains(index) ? items[index].id : nil
return currentURL
}
mutating func move(fromOffsets offsets: IndexSet, toOffset destination: Int) {
guard !offsets.isEmpty, offsets.allSatisfy(items.indices.contains),
(0...items.count).contains(destination) else { return }
let moving = offsets.map { items[$0] }
for index in offsets.reversed() { items.remove(at: index) }
let insertion = destination - offsets.filter { $0 < destination }.count
items.insert(contentsOf: moving, at: insertion)
}
private func sortedFiles(_ urls: [URL]) -> [URL] {
urls.filter(\.isFileURL).sorted {
let order = $0.lastPathComponent.localizedStandardCompare($1.lastPathComponent)
return order == .orderedSame ? $0.path < $1.path : order == .orderedAscending
}
}
}
+15 -4
View File
@@ -79,11 +79,14 @@ extension PlayerModel {
} }
private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool { private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool {
guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil, guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil else { return false }
!(window.firstResponder is NSTextView), !(window.firstResponder is NSTextField) else { return false } let responder = window.firstResponder
// Preserve focus traversal, control activation/navigation and VoiceOver's own chords. if let text = responder as? NSTextView, text.isEditable || text.isSelectable { return false }
if let text = responder as? NSTextField, text.isEditable || text.isSelectable { return false }
// Lists and inert labels are playback surfaces, not text entry or activation controls.
// Keep deliberate button/slider focus, traversal and VoiceOver's own chords native.
if chord.key == "tab" && chord.modifiers.subtracting(.shift).isEmpty { return false } if chord.key == "tab" && chord.modifiers.subtracting(.shift).isEmpty { return false }
if window.firstResponder is NSControl, if responder is NSControl, !(responder is NSTableView), !(responder is NSTextField),
chord.modifiers.subtracting(.shift).isEmpty, chord.modifiers.subtracting(.shift).isEmpty,
Self.nativeNavigationKeys.contains(chord.key) { Self.nativeNavigationKeys.contains(chord.key) {
return false return false
@@ -178,6 +181,8 @@ struct PlayerCommands: Commands {
Button("Open File…") { model.performMenuAction(.openFile) } Button("Open File…") { model.performMenuAction(.openFile) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .openFile).first?.keyboardShortcut) .keyboardShortcut(model.keyBindings.shortcuts(for: .openFile).first?.keyboardShortcut)
.disabled(!model.canPerform(.openFile)) .disabled(!model.canPerform(.openFile))
Button("Add to Queue…", action: model.addQueuePanel)
.disabled(!model.canOpen)
Button("Open YouTube URL…") { model.performMenuAction(.openYoutube) } Button("Open YouTube URL…") { model.performMenuAction(.openYoutube) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .openYoutube).first?.keyboardShortcut) .keyboardShortcut(model.keyBindings.shortcuts(for: .openYoutube).first?.keyboardShortcut)
.disabled(!model.canPerform(.openYoutube)) .disabled(!model.canPerform(.openYoutube))
@@ -195,6 +200,10 @@ struct PlayerCommands: Commands {
Button(model.effectivePlaying ? "Pause" : "Play") { model.performMenuAction(playback) } Button(model.effectivePlaying ? "Pause" : "Play") { model.performMenuAction(playback) }
.keyboardShortcut(model.keyBindings.shortcuts(for: playback).first?.keyboardShortcut) .keyboardShortcut(model.keyBindings.shortcuts(for: playback).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(playback)) .disabled(!hasFocusedScene || !model.canPerform(playback))
Button("Previous Queue Item") { model.advanceQueue(-1) }
.disabled(!hasFocusedScene || !model.canPreviousQueueItem)
Button("Next Queue Item") { model.advanceQueue(1) }
.disabled(!hasFocusedScene || !model.canNextQueueItem)
Button("Seek Back 5 Seconds") { model.performMenuAction(.seekBackward5s) } Button("Seek Back 5 Seconds") { model.performMenuAction(.seekBackward5s) }
.keyboardShortcut(model.keyBindings.shortcuts(for: .seekBackward5s).first?.keyboardShortcut) .keyboardShortcut(model.keyBindings.shortcuts(for: .seekBackward5s).first?.keyboardShortcut)
.disabled(!hasFocusedScene || !model.canPerform(.seekBackward5s)) .disabled(!hasFocusedScene || !model.canPerform(.seekBackward5s))
@@ -256,6 +265,8 @@ struct PlayerCommands: Commands {
.disabled(!hasFocusedScene || !model.canPerform(bufferInfo)) .disabled(!hasFocusedScene || !model.canPerform(bufferInfo))
} }
CommandGroup(replacing: .sidebar) { CommandGroup(replacing: .sidebar) {
Button(model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() }
.disabled(!hasFocusedScene || model.isCompact)
Button(fullscreen == .exitFullscreen ? "Exit Full Screen" : "Enter Full Screen") { Button(fullscreen == .exitFullscreen ? "Exit Full Screen" : "Enter Full Screen") {
model.performMenuAction(fullscreen) model.performMenuAction(fullscreen)
} }
+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
+335 -72
View File
@@ -5,7 +5,11 @@ import TerebiConfiguration
func compactProgressText(position: Double, duration: Double, format: CompactTitleProgress) -> String? { func compactProgressText(position: Double, duration: Double, format: CompactTitleProgress) -> String? {
guard format != .none, duration.isFinite, duration > 0, position.isFinite else { return nil } guard format != .none, duration.isFinite, duration > 0, position.isFinite else { return nil }
let position = min(max(0, position), duration) let position = min(max(0, position), duration)
if format == .percentage { return String(Int((position / duration * 100).rounded(.down))) } if format == .percentage {
let tenths = Int((position / duration * 1000).rounded(.down))
if tenths == 999 { return "100.0" }
return "\(tenths / 10).\(tenths % 10)"
}
let total = Int(min(duration, Double(Int.max / 2))) let total = Int(min(duration, Double(Int.max / 2)))
let current = Int(min(position, Double(Int.max / 2))) let current = Int(min(position, Double(Int.max / 2)))
if duration < 60 { return "\(current) / \(total) s" } if duration < 60 { return "\(current) / \(total) s" }
@@ -27,6 +31,29 @@ final class PlayerModel: ObservableObject {
@Published private(set) var fileName: String? @Published private(set) var fileName: String?
@Published private(set) var fileIcon: NSImage? @Published private(set) var fileIcon: NSImage?
@Published private(set) var commandError: String? @Published private(set) var commandError: String?
@Published private(set) var queue = PlaybackQueue()
@Published var showsQueue = false {
didSet {
guard showsQueue != oldValue, let window, !isFullScreen,
!isCompact, !compactWindow.isTransitioning else { return }
// Setting aspectRatio to zero leaves AppKit's resize increments at zero.
window.resizeIncrements = NSSize(width: 1, height: 1)
var frame = window.frame
frame.size.width += showsQueue ? 260 : -260
if let screen = window.screen?.visibleFrame {
frame.origin.x = max(screen.minX, min(frame.minX, screen.maxX - frame.width))
}
window.setFrame(frame, display: true)
if !showsQueue, state.has_video, state.video_aspect_ratio > 0 {
window.aspectRatio = NSSize(width: state.video_aspect_ratio, height: 1)
}
}
}
private var queuePlaybackRevision: UInt64?
private var queueCompletionHandled = false
private var queueCastDevice: CastDevice?
var canPreviousQueueItem: Bool { canOpen && queue.canPrevious }
var canNextQueueItem: Bool { canOpen && queue.canNext }
private var transition = PlaybackTransition() private var transition = PlaybackTransition()
let castDiscovery = CastDiscovery() let castDiscovery = CastDiscovery()
private lazy var casting = CastPlaybackTransition { [weak self] event in self?.receiveCast(event) } private lazy var casting = CastPlaybackTransition { [weak self] event in self?.receiveCast(event) }
@@ -189,17 +216,18 @@ final class PlayerModel: ObservableObject {
} }
} }
func cast(to device: CastDevice) { func cast(to device: CastDevice, playing: Bool? = nil) {
guard canCast, let source = currentMedia, let handle else { return } guard canCast, let source = currentMedia, let handle else { return }
cancelYouTubeResolution() cancelYouTubeResolution()
commandError = nil commandError = nil
casting.start(to: device, source: source, title: fileName ?? "terebi", subtitle: selectedSubtitle, casting.start(to: device, source: source, title: fileName ?? "terebi", subtitle: selectedSubtitle,
handle: handle, snapshot: state, playing: effectivePlaying, handle: handle, snapshot: state, playing: playing ?? effectivePlaying,
volume: volume, audibleVolume: lastAudibleVolume) volume: volume, audibleVolume: lastAudibleVolume)
revealControls() revealControls()
} }
func stopCasting(resume: Bool = true) { func stopCasting(resume: Bool = true) {
queueCastDevice = nil
casting.stop(resume: resume) casting.stop(resume: resume)
} }
@@ -220,6 +248,7 @@ final class PlayerModel: ObservableObject {
if !isSeeking && !state.seek_pending { seekPosition = state.position } if !isSeeking && !state.seek_pending { seekPosition = state.position }
if let handle { _ = player_report_remote_state(handle, &state) } if let handle { _ = player_report_remote_state(handle, &state) }
saveProgress() saveProgress()
completeQueuePlaybackIfNeeded()
case .stopped(let result): case .stopped(let result):
if let checkpoint = result.checkpoint { if let checkpoint = result.checkpoint {
saveCastProgress(checkpoint, source: result.source) saveCastProgress(checkpoint, source: result.source)
@@ -249,17 +278,21 @@ final class PlayerModel: ObservableObject {
return accepted return accepted
} }
func openPanel() { func openPanel() { filePanel(append: false) }
func addQueuePanel() { filePanel(append: true) }
private func filePanel(append: Bool) {
guard canOpen else { return } guard canOpen else { return }
defer { window?.makeFirstResponder(nil) } defer { window?.makeFirstResponder(nil) }
revealControls() revealControls()
let panel = NSOpenPanel() let panel = NSOpenPanel()
panel.canChooseFiles = true panel.canChooseFiles = true
panel.canChooseDirectories = false panel.canChooseDirectories = false
panel.allowsMultipleSelection = false panel.allowsMultipleSelection = true
panel.prompt = "Open" panel.prompt = append ? "Add to Queue" : "Open"
guard panel.runModal() == .OK, let url = panel.url else { return } guard panel.runModal() == .OK else { return }
open(url) if append { addToQueue(panel.urls) } else { openFiles(panel.urls) }
} }
func openYouTubePanel() { func openYouTubePanel() {
@@ -290,12 +323,14 @@ final class PlayerModel: ObservableObject {
revealControls() revealControls()
return return
} }
clearQueue()
resolveYouTube(url, height: nil) resolveYouTube(url, height: nil)
} }
func openResolvedSource(_ file: URL) { func openResolvedSource(_ file: URL) {
do { do {
let source = try ResolvedSource.load(from: file) let source = try ResolvedSource.load(from: file)
clearQueue()
cancelYouTubeResolution() cancelYouTubeResolution()
openMedia(.resolved(source)) openMedia(.resolved(source))
} catch { } catch {
@@ -483,6 +518,8 @@ final class PlayerModel: ObservableObject {
return return
} }
transition.acceptOpen(revision: revision, source: source, change: change, savedPosition: savedPosition) transition.acceptOpen(revision: revision, source: source, change: change, savedPosition: savedPosition)
queuePlaybackRevision = queue.currentID == nil ? nil : player_get_state(handle).revision
queueCompletionHandled = false
currentMedia = source currentMedia = source
if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() } if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
switch source { switch source {
@@ -524,13 +561,79 @@ final class PlayerModel: ObservableObject {
} }
func open(_ url: URL) { func open(_ url: URL) {
guard canOpen else { return } if url.isFileURL { openFiles([url]) } else { openYouTube(url.absoluteString) }
guard url.isFileURL else {
openYouTube(url.absoluteString)
return
} }
func openFiles(_ urls: [URL]) {
guard canOpen, urls.contains(where: \.isFileURL) else { return }
queueCastDevice = nil
queue.replace(urls)
showsQueue = queue.items.count > 1
openQueueSelection(keepingReceiver: false)
}
func addToQueue(_ urls: [URL]) {
guard canOpen else { return }
let wasEmpty = queue.items.isEmpty
queue.append(urls)
guard !queue.items.isEmpty else { return }
showsQueue = true
if wasEmpty, fileName == nil, let first = queue.items.first {
queue.select(first.id)
openQueueSelection()
}
}
func selectQueueItem(_ id: UUID) {
guard canOpen, queue.items.contains(where: { $0.id == id }) else { return }
queue.select(id)
openQueueSelection()
}
func advanceQueue(_ offset: Int) {
guard canOpen, queue.advance(offset) != nil else { return }
openQueueSelection()
}
func removeQueueItem(_ id: UUID) {
let wasCurrent = queue.currentID == id
let next = queue.remove(id)
guard wasCurrent else { return }
if next != nil { openQueueSelection() } else { closeFile(clearQueue: false) }
}
func moveQueueItems(fromOffsets offsets: IndexSet, toOffset destination: Int) {
queue.move(fromOffsets: offsets, toOffset: destination)
}
private func openQueueSelection(keepingReceiver: Bool = true) {
guard let url = queue.currentURL else { return }
let receiver = keepingReceiver ? (castDevice ?? queueCastDevice) : nil
queuePlaybackRevision = nil
queueCompletionHandled = true
cancelYouTubeResolution() cancelYouTubeResolution()
openMedia(.file(url)) openMedia(.file(url), change: .queue(playing: receiver == nil))
queueCastDevice = receiver
}
private func clearQueue() {
queue.replace([])
queuePlaybackRevision = nil
queueCompletionHandled = false
queueCastDevice = nil
}
private func completeQueuePlaybackIfNeeded() {
guard queue.currentID != nil, queuePlaybackRevision != nil, !isBusy else { return }
if state.status == PLAYER_PLAYING { queueCompletionHandled = false }
guard !queueCompletionHandled, !state.seek_pending, !transition.seekPending else { return }
if state.status == PLAYER_ERROR {
queueCompletionHandled = true
queueCastDevice = nil
} else if state.status == PLAYER_ENDED {
queueCompletionHandled = true
if queue.canNext { advanceQueue(1) }
}
} }
func togglePlayback() { func togglePlayback() {
@@ -578,7 +681,7 @@ final class PlayerModel: ObservableObject {
revealControls() revealControls()
} }
func closeFile() { func closeFile(clearQueue: Bool = true) {
guard let handle, canClose else { return } guard let handle, canClose else { return }
saveProgress() saveProgress()
if castDevice != nil { stopCasting(resume: false) } if castDevice != nil { stopCasting(resume: false) }
@@ -589,6 +692,10 @@ final class PlayerModel: ObservableObject {
return return
} }
transition.closeMedia() transition.closeMedia()
if clearQueue { self.clearQueue() }
queuePlaybackRevision = nil
queueCompletionHandled = true
queueCastDevice = nil
currentMedia = nil currentMedia = nil
selectSubtitle(nil) selectSubtitle(nil)
subtitleTracks = [] subtitleTracks = []
@@ -600,7 +707,7 @@ final class PlayerModel: ObservableObject {
youtubeHeight = nil youtubeHeight = nil
youtubeQuality = nil youtubeQuality = nil
needsVideoFit = false needsVideoFit = false
window?.aspectRatio = .zero window?.resizeIncrements = NSSize(width: 1, height: 1)
commandError = nil commandError = nil
isSeeking = false isSeeking = false
window?.representedURL = nil window?.representedURL = nil
@@ -657,6 +764,10 @@ final class PlayerModel: ObservableObject {
self.window = window self.window = window
window.title = fileName ?? "terebi" window.title = fileName ?? "terebi"
window.contentMinSize = NSSize(width: 400, height: 240) window.contentMinSize = NSSize(width: 400, height: 240)
compactWindow.normalVideoLayout = { [weak self] in
guard let self, self.showsQueue, self.state.has_video else { return nil }
return (self.state.video_aspect_ratio, 260)
}
compactWindow.attach(window) compactWindow.attach(window)
updateWindowState() updateWindowState()
fitVideoWindow() fitVideoWindow()
@@ -873,18 +984,23 @@ final class PlayerModel: ObservableObject {
} }
needsVideoFit = false needsVideoFit = false
guard state.has_video, aspect.isFinite, aspect > 0 else { guard state.has_video, aspect.isFinite, aspect > 0 else {
window.aspectRatio = .zero window.resizeIncrements = NSSize(width: 1, height: 1)
return return
} }
let screen = (window.screen ?? NSScreen.main)?.visibleFrame ?? window.frame let screen = (window.screen ?? NSScreen.main)?.visibleFrame ?? window.frame
let width = min(window.frame.width, screen.width, screen.height * aspect) let sidebarWidth: CGFloat = showsQueue ? 260 : 0
let size = NSSize(width: width, height: width / aspect) let width = min(window.frame.width - sidebarWidth, screen.width - sidebarWidth, screen.height * aspect)
let size = NSSize(width: width + sidebarWidth, height: width / aspect)
// ponytail: extreme ratios retain letterboxing; compact controls if exact fitting is needed. // ponytail: extreme ratios retain letterboxing; compact controls if exact fitting is needed.
guard size.width >= window.minSize.width, size.height >= window.minSize.height else { guard size.width >= window.minSize.width, size.height >= window.minSize.height else {
window.aspectRatio = .zero window.resizeIncrements = NSSize(width: 1, height: 1)
return return
} }
if showsQueue {
window.resizeIncrements = NSSize(width: 1, height: 1)
} else {
window.aspectRatio = NSSize(width: aspect, height: 1) window.aspectRatio = NSSize(width: aspect, height: 1)
}
let frame = NSRect( let frame = NSRect(
x: min(max(window.frame.midX - size.width / 2, screen.minX), screen.maxX - size.width), x: min(max(window.frame.midX - size.width / 2, screen.minX), screen.maxX - size.width),
y: min(max(window.frame.midY - size.height / 2, screen.minY), screen.maxY - size.height), y: min(max(window.frame.midY - size.height / 2, screen.minY), screen.maxY - size.height),
@@ -918,6 +1034,8 @@ final class PlayerModel: ObservableObject {
} }
func applyState(_ next: PlayerState) { func applyState(_ next: PlayerState) {
guard !casting.active else { return }
if let revision = queuePlaybackRevision, next.revision < revision { return }
let statusChanged = state.status != next.status let statusChanged = state.status != next.status
let observation = transition.advance(next, let observation = transition.advance(next,
forceCheckpoint: statusChanged && (next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED), forceCheckpoint: statusChanged && (next.status == PLAYER_PAUSED || next.status == PLAYER_ENDED),
@@ -941,6 +1059,15 @@ final class PlayerModel: ObservableObject {
if let position = restored.position { seekPosition = position } if let position = restored.position { seekPosition = position }
commandError = restored.error commandError = restored.error
} }
if let device = queueCastDevice, !isBusy, !transition.seekPending, !state.seek_pending {
if state.status == PLAYER_ERROR {
queueCastDevice = nil
} else if canCast {
queueCastDevice = nil
cast(to: device, playing: true)
}
}
completeQueuePlaybackIfNeeded()
} }
private func updateAutomaticQuality() { private func updateAutomaticQuality() {
@@ -1242,6 +1369,7 @@ final class PlayerModel: ObservableObject {
private enum MediaChange { private enum MediaChange {
case open case open
case queue(playing: Bool)
case quality(position: Double?, playing: Bool, automatic: Bool) case quality(position: Double?, playing: Bool, automatic: Bool)
case restore(position: Double, playing: Bool) case restore(position: Double, playing: Bool)
@@ -1252,6 +1380,7 @@ final class PlayerModel: ObservableObject {
var playing: Bool { var playing: Bool {
if case .quality(_, let playing, _) = self { return playing } if case .quality(_, let playing, _) = self { return playing }
if case .restore(_, let playing) = self { return playing } if case .restore(_, let playing) = self { return playing }
if case .queue(let playing) = self { return playing }
return true return true
} }
var position: Double? { var position: Double? {
@@ -1689,14 +1818,6 @@ private struct PlaybackSlider: NSViewRepresentable {
func castVolumeSliderFeedbackCheck() -> Bool { PlaybackSlider.Slider.feedbackCheck() } func castVolumeSliderFeedbackCheck() -> Bool { PlaybackSlider.Slider.feedbackCheck() }
#endif #endif
@MainActor
private struct VideoSurface: NSViewRepresentable {
let view: NSView
func makeNSView(context: Context) -> NSView { view }
func updateNSView(_ nsView: NSView, context: Context) {}
}
@MainActor @MainActor
private struct CaptionSelector: NSViewRepresentable { private struct CaptionSelector: NSViewRepresentable {
let model: PlayerModel let model: PlayerModel
@@ -1768,22 +1889,46 @@ private struct CaptionSelector: NSViewRepresentable {
} }
@MainActor @MainActor
private struct CompactDragSurface: NSViewRepresentable { struct PlaybackSurface: NSViewRepresentable {
let model: PlayerModel @ObservedObject var model: PlayerModel
func makeNSView(context: Context) -> DragView { DragView(model: model) } func makeNSView(context: Context) -> DragView { DragView(model: model) }
func updateNSView(_ view: DragView, context: Context) {} func updateNSView(_ view: DragView, context: Context) { view.updateVideo() }
final class DragView: NSView { final class DragView: NSView {
private let model: PlayerModel private let model: PlayerModel
private var hoverTracking: NSTrackingArea? private var hoverTracking: NSTrackingArea?
private var mouseDownEvent: NSEvent?
init(model: PlayerModel) { init(model: PlayerModel) {
self.model = model self.model = model
super.init(frame: .zero) super.init(frame: .zero)
identifier = NSUserInterfaceItemIdentifier("PlaybackSurface")
if let video = model.videoView {
video.frame = bounds
video.autoresizingMask = [.width, .height]
addSubview(video)
}
updateVideo()
} }
required init?(coder: NSCoder) { nil } required init?(coder: NSCoder) { nil }
func updateVideo() {
model.videoView?.isHidden = model.fileName == nil || model.castDevice != nil
}
override var mouseDownCanMoveWindow: Bool { false }
override func hitTest(_ point: NSPoint) -> NSView? {
guard super.hitTest(point) != nil else { return nil }
// Leave the outer native resize border alone, not the internal queue boundary.
if let content = window?.contentView,
!content.bounds.insetBy(dx: 5, dy: 5).contains(content.convert(point, from: superview)) {
return nil
}
return self
}
override func updateTrackingAreas() { override func updateTrackingAreas() {
super.updateTrackingAreas() super.updateTrackingAreas()
if let hoverTracking { removeTrackingArea(hoverTracking) } if let hoverTracking { removeTrackingArea(hoverTracking) }
@@ -1796,18 +1941,33 @@ private struct CompactDragSurface: NSViewRepresentable {
override func mouseEntered(with event: NSEvent) { model.revealControls() } override func mouseEntered(with event: NSEvent) { model.revealControls() }
override func mouseMoved(with event: NSEvent) { model.revealControls() } override func mouseMoved(with event: NSEvent) { model.revealControls() }
override func mouseExited(with event: NSEvent) { model.hideControls(hideCursor: false) } override func mouseExited(with event: NSEvent) {
if model.isCompact { model.hideControls(hideCursor: false) }
}
override func mouseDown(with event: NSEvent) { override func mouseDown(with event: NSEvent) {
mouseDownEvent = nil
if event.clickCount == 2 { if event.clickCount == 2 {
model.toggleCompact() if model.isCompact { model.toggleCompact() } else { model.toggleFullScreen() }
return } else {
mouseDownEvent = event
} }
}
override func mouseDragged(with event: NSEvent) {
guard let down = mouseDownEvent, let window else { return }
mouseDownEvent = nil
model.setControlsDragging(true) model.setControlsDragging(true)
window?.performDrag(with: event) window.performDrag(with: down)
model.compactWindow.snapAfterDrag() if model.isCompact { model.compactWindow.snapAfterDrag() }
model.setControlsDragging(false) model.setControlsDragging(false)
} }
override func mouseUp(with event: NSEvent) {
guard mouseDownEvent != nil else { return }
mouseDownEvent = nil
if !model.isCompact { model.hideControls() }
}
} }
} }
@@ -1830,8 +1990,17 @@ private struct PlayerContent: View {
.frame(minWidth: 0, maxWidth: constrained ? .infinity : nil, alignment: .leading) .frame(minWidth: 0, maxWidth: constrained ? .infinity : nil, alignment: .leading)
.clipped() .clipped()
if let progress { if let progress {
HStack(alignment: .top, spacing: 0) {
if model.compactTitleProgress == .percentage, let decimal = progress.firstIndex(of: ".") {
Text(" · \(progress[..<decimal])")
Text(String(progress[decimal...]))
.font(.system(size: 11 * 2 / 3, weight: .medium))
} else {
Text(" · \(progress)") Text(" · \(progress)")
}
}
.monospacedDigit() .monospacedDigit()
.accessibilityElement(children: .ignore)
.accessibilityLabel(model.compactTitleProgress == .percentage ? "\(progress) percent" : progress) .accessibilityLabel(model.compactTitleProgress == .percentage ? "\(progress) percent" : progress)
.fixedSize(horizontal: true, vertical: false) .fixedSize(horizontal: true, vertical: false)
} }
@@ -1914,8 +2083,10 @@ private struct PlayerContent: View {
.frame(height: 24) .frame(height: 24)
.background(PlayerGlass(cornerRadius: 8).clipShape(RoundedRectangle(cornerRadius: 8)).allowsHitTesting(false)) .background(PlayerGlass(cornerRadius: 8).clipShape(RoundedRectangle(cornerRadius: 8)).allowsHitTesting(false))
Spacer(minLength: 12) Spacer(minLength: 12)
if !model.showsQueue {
titleControls titleControls
} }
}
.padding(.leading, model.isFullScreen ? 16 : 86) .padding(.leading, model.isFullScreen ? 16 : 86)
.padding(.trailing, 12) .padding(.trailing, 12)
.frame(height: 32) .frame(height: 32)
@@ -1928,6 +2099,9 @@ private struct PlayerContent: View {
private var titleControls: some View { private var titleControls: some View {
HStack(spacing: 4) { HStack(spacing: 4) {
iconButton("list.bullet", model.showsQueue ? "Hide Queue" : "Show Queue") {
model.showsQueue.toggle()
}
iconButton(model.isOnTop ? "pin.fill" : "pin", iconButton(model.isOnTop ? "pin.fill" : "pin",
model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop) model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
.imageScale(.small) .imageScale(.small)
@@ -1938,8 +2112,12 @@ private struct PlayerContent: View {
} }
.padding(.horizontal, 8) .padding(.horizontal, 8)
.frame(height: 24) .frame(height: 24)
.background(PlayerGlass(cornerRadius: 8) .background {
.clipShape(RoundedRectangle(cornerRadius: 8)).allowsHitTesting(false)) if !model.showsQueue {
PlayerGlass(cornerRadius: 8)
.clipShape(RoundedRectangle(cornerRadius: 8)).allowsHitTesting(false)
}
}
} }
private var captionsMenu: some View { private var captionsMenu: some View {
@@ -1950,6 +2128,10 @@ private struct PlayerContent: View {
private var actionsMenu: some View { private var actionsMenu: some View {
Menu { Menu {
PlayerActionButton(model: model, action: .openFile, title: "Open File…") PlayerActionButton(model: model, action: .openFile, title: "Open File…")
Button("Add to Queue…", action: model.addQueuePanel).disabled(!model.canOpen)
Button(model.showsQueue ? "Hide Queue" : "Show Queue") { model.showsQueue.toggle() }
Button("Previous Queue Item") { model.advanceQueue(-1) }.disabled(!model.canPreviousQueueItem)
Button("Next Queue Item") { model.advanceQueue(1) }.disabled(!model.canNextQueueItem)
PlayerActionButton(model: model, action: .openYoutube, title: "Open YouTube URL…") PlayerActionButton(model: model, action: .openYoutube, title: "Open YouTube URL…")
PlayerActionButton(model: model, action: .closeFile, title: "Close File") PlayerActionButton(model: model, action: .closeFile, title: "Close File")
Divider() Divider()
@@ -2102,7 +2284,106 @@ private struct PlayerContent: View {
) )
} }
private var queueSidebar: some View {
VStack(spacing: 0) {
HStack(spacing: 4) {
HStack(spacing: 4) {
Text("Queue").fontWeight(.semibold)
Text("\(model.queue.items.count)").foregroundStyle(.secondary)
}
.font(.system(size: 13))
.accessibilityElement(children: .combine)
Spacer(minLength: 4)
titleControls
}
.padding(.horizontal, 12)
.frame(height: 32)
HStack {
iconButton("backward.end.fill", "Previous Queue Item") { model.advanceQueue(-1) }
.disabled(!model.canPreviousQueueItem)
iconButton("forward.end.fill", "Next Queue Item") { model.advanceQueue(1) }
.disabled(!model.canNextQueueItem)
Spacer()
Button("Add to Queue…", action: model.addQueuePanel).disabled(!model.canOpen)
}
.padding(.horizontal, 12)
.padding(.bottom, 8)
List {
ForEach(model.queue.items) { item in
HStack(spacing: 8) {
Image(systemName: item.id == model.queue.currentID ? "play.fill" : "film")
.foregroundStyle(item.id == model.queue.currentID ? Color.accentColor : .secondary)
.frame(width: 20, alignment: .center)
.accessibilityLabel(item.id == model.queue.currentID ? "Current item" : "Queued item")
Text(item.url.lastPathComponent)
.lineLimit(2)
.truncationMode(.middle)
.frame(maxWidth: .infinity, alignment: .leading)
Button { model.removeQueueItem(item.id) } label: {
Image(systemName: "minus.circle")
}
.buttonStyle(.plain)
.help("Remove from Queue")
.accessibilityLabel("Remove \(item.url.lastPathComponent) from Queue")
}
.contentShape(Rectangle())
.onTapGesture(count: 2) { model.selectQueueItem(item.id) }
.help(item.url.path + "\nDouble-click to play; drag to reorder.")
.contextMenu {
Button("Play") { model.selectQueueItem(item.id) }
Button("Remove from Queue") { model.removeQueueItem(item.id) }
if let index = model.queue.items.firstIndex(where: { $0.id == item.id }) {
Button("Move Up") {
model.moveQueueItems(fromOffsets: IndexSet(integer: index), toOffset: index - 1)
}.disabled(index == 0)
Button("Move Down") {
model.moveQueueItems(fromOffsets: IndexSet(integer: index), toOffset: index + 2)
}.disabled(index + 1 == model.queue.items.count)
}
}
.accessibilityAction(named: Text("Play")) { model.selectQueueItem(item.id) }
}
.onMove { model.moveQueueItems(fromOffsets: $0, toOffset: $1) }
}
.listStyle(.sidebar)
.overlay {
if model.queue.items.isEmpty {
Text("Drop local files here to add them to the queue.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding()
.allowsHitTesting(false)
}
}
}
.frame(width: 260)
.background(PlayerGlass(cornerRadius: 0))
.dropDestination(for: URL.self) { urls, _ in
guard model.canOpen, urls.contains(where: \.isFileURL) else { return false }
model.addToQueue(urls)
return true
}
.onHover(perform: model.setControlsHovered)
}
var body: some View { var body: some View {
HStack(spacing: 0) {
videoContent
if model.showsQueue && !model.isCompact && !model.compactWindow.isTransitioning {
queueSidebar
}
}
.frame(minWidth: model.isCompact || model.compactWindow.isTransitioning ? 160 : (model.showsQueue ? 660 : 400),
minHeight: model.isCompact || model.compactWindow.isTransitioning ? model.compactContentMinimumHeight : 240)
.ignoresSafeArea()
.preferredColorScheme(.dark)
.foregroundStyle(Color(SunsetPalette.text))
.tint(Color(SunsetPalette.accent))
.background(WindowChrome(model: model))
}
private var videoContent: some View {
GeometryReader { geometry in GeometryReader { geometry in
ZStack(alignment: .top) { ZStack(alignment: .top) {
if model.fileName == nil || model.castDevice != nil { if model.fileName == nil || model.castDevice != nil {
@@ -2113,18 +2394,8 @@ private struct PlayerContent: View {
} else { } else {
Color.black Color.black
} }
if let view = model.videoView { PlaybackSurface(model: model)
VideoSurface(view: view)
.accessibilityLabel("Video") .accessibilityLabel("Video")
.opacity(model.fileName == nil || model.castDevice != nil ? 0 : 1)
.gesture(
TapGesture(count: 2).onEnded { model.toggleFullScreen() }
.exclusively(before: TapGesture().onEnded { model.hideControls() })
)
}
if model.isCompact {
CompactDragSurface(model: model).accessibilityHidden(true)
}
if !model.subtitleText.isEmpty { if !model.subtitleText.isEmpty {
Text(model.subtitleText) Text(model.subtitleText)
.font(.system(size: max(16, min(28, geometry.size.width / 32)), weight: .semibold)) .font(.system(size: max(16, min(28, geometry.size.width / 32)), weight: .semibold))
@@ -2316,12 +2587,9 @@ private struct PlayerContent: View {
titlebar titlebar
.frame(width: geometry.size.width) .frame(width: geometry.size.width)
.animation(compactChromeAnimation) { content in .opacity(model.isCompact || model.compactWindow.isTransitioning ? 0 : 1)
content.opacity(model.isCompact ? 0 : 1) .allowsHitTesting(!model.isCompact && !model.compactWindow.isTransitioning && model.controlsVisible)
} .accessibilityHidden(model.isCompact || model.compactWindow.isTransitioning || !model.controlsVisible)
.allowsHitTesting(!model.isCompact && model.controlsVisible)
.accessibilityHidden(model.isCompact || !model.controlsVisible)
if model.canTogglePlayback { if model.canTogglePlayback {
playbackControls playbackControls
.frame(width: 440, height: controlHeight) .frame(width: 440, height: controlHeight)
@@ -2349,9 +2617,14 @@ private struct PlayerContent: View {
} }
} }
.dropDestination(for: URL.self) { urls, _ in .dropDestination(for: URL.self) { urls, _ in
guard model.canOpen, let url = urls.first else { return false } guard model.canOpen, !urls.isEmpty else { return false }
if !url.isFileURL && (try? YouTubeResolver.validateURL(url.absoluteString)) == nil { return false } if urls.allSatisfy(\.isFileURL) {
model.openFiles(urls)
} else {
guard urls.count == 1, let url = urls.first,
(try? YouTubeResolver.validateURL(url.absoluteString)) != nil else { return false }
model.open(url) model.open(url)
}
return true return true
} isTargeted: { } isTargeted: {
dropTargeted = $0 dropTargeted = $0
@@ -2365,13 +2638,6 @@ private struct PlayerContent: View {
} }
} }
} }
.frame(minWidth: model.isCompact || model.compactWindow.isTransitioning ? 160 : 400,
minHeight: model.isCompact || model.compactWindow.isTransitioning ? model.compactContentMinimumHeight : 240)
.ignoresSafeArea()
.preferredColorScheme(.dark)
.foregroundStyle(Color(SunsetPalette.text))
.tint(Color(SunsetPalette.accent))
.background(WindowChrome(model: model))
} }
} }
@@ -2471,12 +2737,7 @@ private struct WindowChrome: NSViewRepresentable {
} }
} }
override func mouseDown(with event: NSEvent) { override func hitTest(_ point: NSPoint) -> NSView? { nil }
model.setControlsDragging(true)
window?.performDrag(with: event)
model.compactWindow.snapAfterDrag()
model.setControlsDragging(false)
}
func detach() { func detach() {
if let monitor { NSEvent.removeMonitor(monitor) } if let monitor { NSEvent.removeMonitor(monitor) }
@@ -2504,8 +2765,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
} }
@objc private func openDocuments(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) { @objc private func openDocuments(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
guard let url = event.paramDescriptor(forKeyword: keyDirectObject)?.atIndex(1)?.fileURLValue else { return } guard let files = event.paramDescriptor(forKeyword: keyDirectObject), files.numberOfItems > 0 else { return }
PlayerModel.shared.open(url) let urls = (1...files.numberOfItems).compactMap { files.atIndex($0)?.fileURLValue }
PlayerModel.shared.openFiles(urls)
} }
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
@@ -2525,7 +2787,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Let SwiftUI create the initial window for a Finder/document launch. // Let SwiftUI create the initial window for a Finder/document launch.
func application(_ application: NSApplication, open urls: [URL]) { func application(_ application: NSApplication, open urls: [URL]) {
if let url = urls.first { PlayerModel.shared.open(url) } if urls.allSatisfy(\.isFileURL) { PlayerModel.shared.openFiles(urls) }
else if let url = urls.first { PlayerModel.shared.open(url) }
} }
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
+4 -2
View File
@@ -1,6 +1,8 @@
- [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
- [ ] add playlist support - [x] currently, computer falls asleep even if video is playing. this should not be the case.
- maybe by allowing multiple file selection? 🤔 - 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
- [x] add session-only local-file playlist support
- multi-file Open/Finder/drop, natural filename order, editable queue sidebar, explicit append, previous/next and automatic advance; Cast navigation retains the receiver; UI/drag-drop and physical-Cast verification remain unverified
- [ ] 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
- we can figure out the stream and how to handle it from the url (i.e. if youtube url, handle as youtube stream; if miruro url, handle accordingly) - we can figure out the stream and how to handle it from the url (i.e. if youtube url, handle as youtube stream; if miruro url, handle accordingly)