Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a7d6b9674 | |||
| 640e23b004 | |||
| 8cd5db5375 | |||
| dd02383c0b | |||
| 3e782aff31 | |||
| 07243f0ed7 | |||
| afb0c30e09 | |||
| 2690a22d39 | |||
| fc857870de | |||
| 1d33f5d572 | |||
| ce903e2367 | |||
| d4704af442 | |||
| 2b96a165d2 | |||
| f49eb6d084 | |||
| 3368228537 |
@@ -285,6 +285,33 @@ def check_cli_instances(root, app, fixtures):
|
||||
and state["position"] > 0 and state["hasVideo"] for state in states.values()),
|
||||
"cold default CLI opens one actual player window")
|
||||
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():
|
||||
raise RuntimeError(f"missing config must use defaults without creating a file: {first}")
|
||||
invoke([])
|
||||
|
||||
@@ -98,6 +98,54 @@ private enum CLIInstanceCheck {
|
||||
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) {
|
||||
let settings = try! JSONSerialization.jsonObject(with: Data(contentsOf: configuration)) as! [String: String]
|
||||
let directory = URL(fileURLWithPath: settings["directory"]!, isDirectory: true)
|
||||
@@ -147,6 +195,9 @@ private enum CLIInstanceCheck {
|
||||
switch action {
|
||||
case "youtube":
|
||||
model.openYouTube(command["url"] as! String)
|
||||
case "queue":
|
||||
model.showsQueue = command["visible"] as! Bool
|
||||
model.revealControls()
|
||||
case "caption":
|
||||
guard let view = window.contentView, let selector = captionSelector(in: view),
|
||||
let cell = selector.cell as? NSPopUpButtonCell, let menu = selector.menu else {
|
||||
@@ -252,6 +303,8 @@ private enum CLIInstanceCheck {
|
||||
"mainWindow": windows.first?.isMainWindow ?? false,
|
||||
"editorText": editorText,
|
||||
"compact": model.isCompact,
|
||||
"showsQueue": model.showsQueue,
|
||||
"window": windowSnapshot(windows.first),
|
||||
"onTop": model.isOnTop,
|
||||
]
|
||||
do {
|
||||
|
||||
@@ -17,6 +17,36 @@ struct CastPlaybackCheck {
|
||||
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,
|
||||
scenario: String, gates: URL, defaults: UserDefaults) async 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 }
|
||||
}
|
||||
}
|
||||
requirePlaybackPower(system: !active || scenario == "disconnect-active", display: scenario == "cancel-connect",
|
||||
"handoff keeps the relay awake even after the local decoder pauses")
|
||||
if scenario == "reject-load" {
|
||||
try signal("observed")
|
||||
try await until(model, "LOAD rejection restores original local playback", permitError: true) {
|
||||
@@ -107,6 +139,7 @@ struct CastPlaybackCheck {
|
||||
if scenario.hasPrefix("shutdown-") {
|
||||
require(model.castDevice == nil && !model.castConnecting && !model.canOpen && !model.canClose,
|
||||
"shutdown releases Cast ownership and permanently disables the local player")
|
||||
requirePlaybackPower(system: false, display: false, "interrupted Cast shutdown releases sleep prevention")
|
||||
require(!model.setPlayback(playing: true) && !model.seekRelative(5) && model.volume == 0,
|
||||
"late receiver traffic cannot resume a destroyed player or replace local volume")
|
||||
print("PASS: " + scenario)
|
||||
@@ -180,6 +213,10 @@ struct CastPlaybackCheck {
|
||||
let model = PlayerModel(defaults: defaults)
|
||||
defer { model.shutdown() }
|
||||
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],
|
||||
gates: URL(fileURLWithPath: CommandLine.arguments[4]), defaults: defaults)
|
||||
return
|
||||
@@ -187,29 +224,36 @@ struct CastPlaybackCheck {
|
||||
model.setVolume(0)
|
||||
model.open(source)
|
||||
try await until(model, "local source plays") { model.state.status == PLAYER_PLAYING && model.canSeek }
|
||||
requirePlaybackPower(system: true, display: true, "local video prevents system and display sleep")
|
||||
require(model.setPlayback(playing: false), "local pause accepted")
|
||||
try await until(model, "local pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
|
||||
requirePlaybackPower(system: false, display: false, "local pause permits sleep")
|
||||
require(model.seekRelative(9.25 - model.state.position), "local restoration position accepted")
|
||||
require(!model.canCast, "Cast cannot transfer an unacknowledged local seek")
|
||||
try await until(model, "local position acknowledged") { !model.state.seek_pending && abs(model.state.position - 9.25) < 0.15 }
|
||||
model.cast(to: CastDevice(id: "loopback", name: "Loopback TV", host: "127.0.0.1", port: port))
|
||||
requirePlaybackPower(system: true, display: false, "pending Cast handoff protects the relay")
|
||||
try await until(model, "receiver load acknowledges paused handoff") {
|
||||
model.castDevice != nil && !model.castConnecting && model.canSeek && model.state.status == PLAYER_PAUSED
|
||||
}
|
||||
requirePlaybackPower(system: false, display: false, "paused Cast permits sleep")
|
||||
require(abs(model.state.position - 9.25) < 0.15, "Cast starts at acknowledged local position")
|
||||
require(!model.canStepFrame && model.bufferedRange == nil, "local decoder-only controls are not exposed while casting")
|
||||
require(model.setPlayback(playing: true), "remote play accepted")
|
||||
try await until(model, "receiver play acknowledged") { model.state.status == PLAYER_PLAYING && model.playbackIntent == nil }
|
||||
requirePlaybackPower(system: true, display: false, "playing Cast protects the relay but permits display sleep")
|
||||
for _ in 0..<64 { require(model.seekRelative(0.125), "rapid remote skips accumulate beyond queue capacity") }
|
||||
require(model.seekRelative(7), "final remote skip retains accumulated target")
|
||||
try await until(model, "receiver seek acknowledged") { !model.state.seek_pending && abs(model.state.position - 24.25) < 0.15 }
|
||||
require(model.setPlayback(playing: false), "remote pause accepted")
|
||||
try await until(model, "receiver pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
|
||||
requirePlaybackPower(system: false, display: false, "receiver pause permits sleep")
|
||||
model.stopCasting()
|
||||
try await until(model, "local decoder restores receiver position paused") {
|
||||
model.castDevice == nil && model.state.status == PLAYER_PAUSED && !model.state.seek_pending &&
|
||||
abs(model.state.position - 24.25) < 0.15 && !model.isBusy
|
||||
}
|
||||
requirePlaybackPower(system: false, display: false, "paused local restoration permits sleep")
|
||||
require(abs((PlaybackProgress(defaults: defaults).position(for: source) ?? 0) - 24.25) < 0.15,
|
||||
"remote history is not overwritten by the abandoned local position")
|
||||
require(model.volume == 0, "local volume survives receiver volume changes")
|
||||
@@ -220,8 +264,10 @@ struct CastPlaybackCheck {
|
||||
model.castDevice == nil && model.commandError != nil && model.state.status == PLAYER_PLAYING
|
||||
}
|
||||
require(model.canSeek && model.state.position >= 24, "connection failure preserves a usable local timeline")
|
||||
requirePlaybackPower(system: true, display: true, "failed Cast restores local display protection")
|
||||
model.closeFile()
|
||||
try await until(model, "closed media releases Cast and decoder") { model.state.status == PLAYER_EMPTY }
|
||||
requirePlaybackPower(system: false, display: false, "closing Cast media releases sleep prevention")
|
||||
print("PASS: real local decode → TLS Cast LOAD/play/seek/pause → local restoration/history; failed connection recovery")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -791,6 +791,36 @@ struct CastSessionCheck {
|
||||
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 {
|
||||
for scenario in ["cancel-connect", "cancel-load", "reject-load", "replace-load", "close-load",
|
||||
"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")
|
||||
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")
|
||||
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 {
|
||||
try await behavior(identity: identity)
|
||||
try await seekCoalescing(identity: identity)
|
||||
|
||||
@@ -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 {
|
||||
try geometry()
|
||||
try windowBehavior()
|
||||
try reducedMotion()
|
||||
try rememberedSize()
|
||||
try continuousQueueResize()
|
||||
print("Compact window checks passed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,20 @@ import AppKit
|
||||
import MediaPlayer
|
||||
import TerebiConfiguration
|
||||
|
||||
// Native focus eligibility normally depends on the user's Full Keyboard Access setting.
|
||||
// Force eligibility only; keep the controls' real AppKit responder/key behavior.
|
||||
private final class KeyboardCheckButton: NSButton {
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
}
|
||||
|
||||
private final class KeyboardCheckSlider: NSSlider {
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
}
|
||||
|
||||
private final class KeyboardCheckLabel: NSTextField {
|
||||
override var acceptsFirstResponder: Bool { true }
|
||||
}
|
||||
|
||||
@main
|
||||
@MainActor
|
||||
struct SystemPlaybackChecks {
|
||||
@@ -17,6 +31,50 @@ struct SystemPlaybackChecks {
|
||||
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() {
|
||||
let times: [(Double, Double, String)] = [
|
||||
(12.9, 45.9, "12 / 45 s"),
|
||||
@@ -33,9 +91,11 @@ struct SystemPlaybackChecks {
|
||||
require(compactProgressText(position: position, duration: duration, format: .time) == expected,
|
||||
"compact time uses total-duration units, truncation and bounded progress: \(expected)")
|
||||
}
|
||||
for (position, expected) in [(59.9, "59"), (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,
|
||||
"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] {
|
||||
require(compactProgressText(position: 10, duration: duration, format: .time) == nil &&
|
||||
@@ -49,6 +109,151 @@ struct SystemPlaybackChecks {
|
||||
"disabled compact progress leaves only the title")
|
||||
}
|
||||
|
||||
static func checkQueue(directory: URL, defaults: UserDefaults) {
|
||||
let model = PlayerModel(defaults: defaults)
|
||||
defer { model.shutdown() }
|
||||
model.setVolume(0)
|
||||
let video = directory.appendingPathComponent("av.mp4")
|
||||
let audio = directory.appendingPathComponent("audio.wav")
|
||||
model.openFiles([video, audio])
|
||||
until("queue opens its naturally sorted first file") {
|
||||
model.queue.currentURL == audio && model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
|
||||
}
|
||||
let current = model.queue.currentID!
|
||||
require(model.setPlayback(playing: false), "pause queue before editing")
|
||||
until("queue pause acknowledged") { model.state.status == PLAYER_PAUSED && model.playbackIntent == nil }
|
||||
let revision = model.state.revision
|
||||
model.addToQueue([video])
|
||||
model.moveQueueItems(fromOffsets: IndexSet(integer: 0), toOffset: 2)
|
||||
require(model.queue.currentID == current && model.state.revision == revision &&
|
||||
model.state.status == PLAYER_PAUSED, "append and reorder do not reopen current playback")
|
||||
model.removeQueueItem(model.queue.items.last!.id)
|
||||
require(model.queue.currentID == current && model.state.revision == revision,
|
||||
"removing another queue entry leaves native playback untouched")
|
||||
model.removeQueueItem(current)
|
||||
until("removing final current entry stops despite earlier remaining entries") {
|
||||
model.queue.currentID == nil && model.state.status == PLAYER_EMPTY
|
||||
}
|
||||
require(model.queue.items.count == 1, "stopping at queue end preserves earlier entries")
|
||||
model.selectQueueItem(model.queue.items[0].id)
|
||||
until("explicit queue selection opens remaining entry") {
|
||||
model.queue.currentURL == video && model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
|
||||
}
|
||||
model.openFiles([video, audio])
|
||||
until("replacement queue starts with audio") {
|
||||
model.queue.currentURL == audio && model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
|
||||
}
|
||||
require(model.seekRelative(model.duration - model.state.position - 0.2), "queue seeks near first EOF")
|
||||
until("natural EOF advances to next queue file") {
|
||||
model.queue.currentURL == video && model.state.status == PLAYER_PLAYING && model.canSeek && !model.isBusy
|
||||
}
|
||||
require(model.seekRelative(model.duration - model.state.position - 0.2), "queue seeks near final EOF")
|
||||
until("final queue EOF stops without wrapping") { model.state.status == PLAYER_ENDED }
|
||||
require(model.queue.currentURL == video && !model.queue.canNext, "final EOF retains last selection")
|
||||
model.openFiles([directory.appendingPathComponent("hdr.mkv"), video])
|
||||
model.selectQueueItem(model.queue.items.first { $0.url.lastPathComponent == "hdr.mkv" }!.id)
|
||||
until("queued playback error is acknowledged") { model.state.status == PLAYER_ERROR && !model.isBusy }
|
||||
require(model.queue.currentURL?.lastPathComponent == "hdr.mkv",
|
||||
"playback failure stops on failed queue entry rather than skipping")
|
||||
}
|
||||
|
||||
static func checkKeyboardRouting(defaults: UserDefaults) {
|
||||
let bindings = try! KeyBindings(overrides: [
|
||||
"play": [], "pause": [], "seek_backward_5s": [],
|
||||
"show_buffer_info": ["space", "return", "left", "shift+left", "tab", "shift+tab",
|
||||
"cmd+b", "f12", "ctrl+alt+b"]
|
||||
])
|
||||
let model = PlayerModel(defaults: defaults, configuration: PlayerConfiguration(keys: bindings))
|
||||
defer { model.shutdown() }
|
||||
let window = NSWindow(contentRect: NSRect(x: -10_000, y: -10_000, width: 640, height: 360),
|
||||
styleMask: [.titled, .resizable], backing: .buffered, defer: false)
|
||||
window.isReleasedWhenClosed = false
|
||||
defer { window.close() }
|
||||
func event(_ code: UInt16, _ text: String, _ flags: NSEvent.ModifierFlags = []) -> NSEvent {
|
||||
NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
|
||||
windowNumber: window.windowNumber, context: nil, characters: text,
|
||||
charactersIgnoringModifiers: text, isARepeat: false, keyCode: code)!
|
||||
}
|
||||
let shortcuts = [event(49, " "), event(36, "\r"), event(123, "\u{F702}"),
|
||||
event(123, "\u{F702}", .shift), event(11, "b", .command),
|
||||
event(111, "\u{F70F}", .function)]
|
||||
func focus(_ responder: NSView) {
|
||||
window.makeFirstResponder(nil)
|
||||
window.contentView?.subviews.forEach { $0.removeFromSuperview() }
|
||||
responder.frame = NSRect(x: 0, y: 0, width: 300, height: 200)
|
||||
window.contentView?.addSubview(responder)
|
||||
require(window.makeFirstResponder(responder), "keyboard regression fixture accepts actual window focus")
|
||||
let focusedResponder = (responder as? NSTextField)?.currentEditor() ?? responder
|
||||
require(window.firstResponder === focusedResponder,
|
||||
"keyboard regression fixture owns actual control or field-editor focus")
|
||||
}
|
||||
let lists = [NSTableView(), NSOutlineView()]
|
||||
for list in lists {
|
||||
list.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier("title")))
|
||||
focus(list)
|
||||
for shortcut in shortcuts {
|
||||
model.setShowsBufferInfo(false)
|
||||
require(model.handleKeyEvent(shortcut, in: window) && model.showsBufferInfo,
|
||||
"focused queue list dispatches configured \(KeyChord.from(shortcut)!) binding")
|
||||
require(window.firstResponder === list, "playback shortcuts do not steal queue focus")
|
||||
}
|
||||
let traversalModifiers: [NSEvent.ModifierFlags] = [[], .shift]
|
||||
for flags in traversalModifiers {
|
||||
model.setShowsBufferInfo(false)
|
||||
require(!model.handleKeyEvent(event(48, "\t", flags), in: window) && !model.showsBufferInfo,
|
||||
"Tab and Shift-Tab remain native traversal even when configured")
|
||||
}
|
||||
}
|
||||
let label = KeyboardCheckLabel(labelWithString: "Queue item")
|
||||
focus(label)
|
||||
model.setShowsBufferInfo(false)
|
||||
require(model.handleKeyEvent(shortcuts[0], in: window) && model.showsBufferInfo,
|
||||
"noneditable nonselectable queue label does not block playback")
|
||||
let controls: [NSControl] = [KeyboardCheckButton(), KeyboardCheckSlider()]
|
||||
for control in controls {
|
||||
focus(control)
|
||||
for shortcut in shortcuts.prefix(4) {
|
||||
model.setShowsBufferInfo(false)
|
||||
require(!model.handleKeyEvent(shortcut, in: window) && !model.showsBufferInfo,
|
||||
"deliberately focused native button/slider retains activation and navigation")
|
||||
require(window.firstResponder === control, "native control keyboard focus is preserved")
|
||||
}
|
||||
require(model.handleKeyEvent(shortcuts[4], in: window) && model.showsBufferInfo,
|
||||
"modified configured bindings still work with native control focus")
|
||||
}
|
||||
let editor = NSTextView()
|
||||
focus(editor)
|
||||
for shortcut in shortcuts {
|
||||
model.setShowsBufferInfo(false)
|
||||
require(!model.handleKeyEvent(shortcut, in: window) && !model.showsBufferInfo,
|
||||
"text editing retains configured keys instead of dispatching playback")
|
||||
}
|
||||
editor.keyDown(with: shortcuts[0])
|
||||
require(editor.string == " ", "unconsumed Space reaches actual text entry")
|
||||
label.isSelectable = true
|
||||
focus(label)
|
||||
require(!model.handleKeyEvent(shortcuts[0], in: window),
|
||||
"selectable text retains native text interaction")
|
||||
label.isSelectable = false
|
||||
label.isEditable = true
|
||||
require(!model.handleKeyEvent(shortcuts[0], in: window),
|
||||
"editable text field retains native text entry")
|
||||
focus(lists[0])
|
||||
let voiceOverChord = event(11, "b", [.control, .option])
|
||||
require(model.handleKeyEvent(voiceOverChord, in: window) != NSWorkspace.shared.isVoiceOverEnabled,
|
||||
"VoiceOver retains its Control-Option chords when enabled")
|
||||
let sheet = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 200, height: 100),
|
||||
styleMask: .titled, backing: .buffered, defer: false)
|
||||
sheet.isReleasedWhenClosed = false
|
||||
window.beginSheet(sheet)
|
||||
require(!model.handleKeyEvent(shortcuts[0], in: window), "attached sheet blocks player shortcuts")
|
||||
window.endSheet(sheet)
|
||||
sheet.close()
|
||||
let modal = NSApp.beginModalSession(for: window)
|
||||
require(!model.handleKeyEvent(shortcuts[0], in: window), "modal session blocks player shortcuts")
|
||||
NSApp.endModalSession(modal)
|
||||
}
|
||||
|
||||
static func main() {
|
||||
checkCompactProgress()
|
||||
_ = NSApplication.shared
|
||||
@@ -56,6 +261,10 @@ struct SystemPlaybackChecks {
|
||||
let suite = "terebi.system-playback-check.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defer { defaults.removePersistentDomain(forName: suite) }
|
||||
checkKeyboardRouting(defaults: defaults)
|
||||
checkQueue(directory: URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true), defaults: defaults)
|
||||
checkWindowSurface(defaults: defaults)
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let initialModel = PlayerModel(defaults: defaults)
|
||||
require(!initialModel.showsBufferInfo, "buffer readout defaults off before playback")
|
||||
initialModel.setShowsBufferInfo(true)
|
||||
@@ -77,6 +286,7 @@ struct SystemPlaybackChecks {
|
||||
defer { model.shutdown() }
|
||||
require(!model.showsBufferInfo && !model.automaticYouTubeQuality && model.compactWindow.snappingEnabled,
|
||||
"session overrides never replace built-in settings")
|
||||
requirePlaybackPower(system: false, display: false, "idle player permits sleep")
|
||||
model.setVolume(0)
|
||||
let directory = URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true)
|
||||
let center = MPNowPlayingInfoCenter.default()
|
||||
@@ -87,6 +297,15 @@ struct SystemPlaybackChecks {
|
||||
window.isReleasedWhenClosed = false
|
||||
defer { window.close() }
|
||||
model.attachWindow(window)
|
||||
let videoFrame = window.frame
|
||||
model.showsQueue = true
|
||||
require(abs(window.frame.width - videoFrame.width - 260) < 0.01 &&
|
||||
abs(window.frame.height - videoFrame.height) < 0.01,
|
||||
"opening queue grows window without shrinking video")
|
||||
model.showsQueue = false
|
||||
require(abs(window.frame.width - videoFrame.width) < 0.01 &&
|
||||
abs(window.frame.height - videoFrame.height) < 0.01,
|
||||
"closing queue restores video-only window size")
|
||||
model.open(directory.appendingPathComponent("anamorphic.mp4"))
|
||||
require(model.isBusy && model.perform(.enterCompact) && model.isCompact,
|
||||
"compact entry is available before native media opening acknowledges")
|
||||
@@ -107,9 +326,32 @@ struct SystemPlaybackChecks {
|
||||
model.setPointerInsideWindow(true)
|
||||
}
|
||||
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")
|
||||
requirePlaybackPower(system: true, display: true, "local video keeps the Mac and display awake")
|
||||
var bufferingState = model.state
|
||||
bufferingState.buffering = true
|
||||
model.applyState(bufferingState)
|
||||
requirePlaybackPower(system: true, display: true, "buffering does not drop video sleep prevention")
|
||||
require(model.setPlayback(playing: false), "explicit pause accepted")
|
||||
until("pause acknowledged") { model.state.status == PLAYER_PAUSED }
|
||||
requirePlaybackPower(system: false, display: false, "pause releases sleep prevention")
|
||||
let pausedPosition = model.state.position
|
||||
if !NSWorkspace.shared.isVoiceOverEnabled {
|
||||
model.setControlsHovered(true)
|
||||
@@ -213,6 +455,7 @@ struct SystemPlaybackChecks {
|
||||
require(model.bufferedRange == nil, "accepted close immediately hides a stale retained-media snapshot")
|
||||
require(center.nowPlayingInfo == nil && center.playbackState == .stopped,
|
||||
"accepted close releases system playback immediately")
|
||||
requirePlaybackPower(system: false, display: false, "close releases sleep prevention immediately")
|
||||
require(!commands.togglePlayPauseCommand.isEnabled && !commands.nextTrackCommand.isEnabled,
|
||||
"closed source cannot receive system playback or seek commands")
|
||||
|
||||
@@ -277,9 +520,21 @@ struct SystemPlaybackChecks {
|
||||
until("decode error acknowledged") { model.state.status == PLAYER_ERROR && !model.isBusy }
|
||||
require(center.nowPlayingInfo == nil && !commands.playCommand.isEnabled,
|
||||
"failed source does not claim playback keys")
|
||||
requirePlaybackPower(system: false, display: false, "playback failure permits sleep")
|
||||
model.open(localURL)
|
||||
until("playback resumes before end-of-media check") {
|
||||
model.state.status == PLAYER_PLAYING && model.canSeek && !model.state.seek_pending
|
||||
}
|
||||
require(model.seekRelative(model.duration - model.state.position - 0.2), "seek near end accepted")
|
||||
until("video reaches its natural end") { model.state.status == PLAYER_ENDED }
|
||||
requirePlaybackPower(system: false, display: false, "end of media permits sleep")
|
||||
model.open(directory.appendingPathComponent("audio.wav"))
|
||||
until("audio-only playback starts") { model.state.status == PLAYER_PLAYING && !model.state.has_video }
|
||||
requirePlaybackPower(system: true, display: false, "audio keeps the Mac awake but permits display sleep")
|
||||
model.shutdown()
|
||||
require(center.nowPlayingInfo == nil && !commands.pauseCommand.isEnabled,
|
||||
"shutdown releases system playback")
|
||||
requirePlaybackPower(system: false, display: false, "shutdown releases active sleep prevention")
|
||||
print("System playback checks passed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
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
|
||||
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
|
||||
CLI_SOURCES := Sources/CLI/TerebiCLI.swift Sources/CLI/PlayerCommand.swift Sources/App/YouTubeResolver.swift Sources/App/ResolvedSource.swift $(RUNTIME_SOURCE)
|
||||
CONFIG_BUILD := .build/configuration
|
||||
@@ -28,7 +28,7 @@ BUNDLE_STAMP := $(BUILD_DIR)/bundle.stamp
|
||||
SIGNING_IDENTITY ?= -
|
||||
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
|
||||
|
||||
$(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
|
||||
$(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
|
||||
|
||||
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
|
||||
$(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
|
||||
$(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)
|
||||
$(BUILD_DIR)/ffmpeg-runtime-check "$(APP)"
|
||||
|
||||
$(BUILD_DIR)/system-playback-check: Checks/system-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/system-playback.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
|
||||
$(BUILD_DIR)/system-playback-check: Checks/system-playback.swift Checks/playback-power.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h Checks/system-playback.swift Checks/playback-power.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
|
||||
|
||||
check-media: $(BUILD_DIR)/system-playback-check check-fixtures
|
||||
$(BUILD_DIR)/system-playback-check $(BUILD_DIR)/fixtures
|
||||
@@ -278,8 +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
|
||||
$(CXX) $(CXXFLAGS) -ISources/Core -DCAST_MEDIA_CHECK $< $(BUILD_DIR)/cast_media-check.o $(FF_LIBS) -o $@
|
||||
|
||||
$(BUILD_DIR)/cast-playback-check: Checks/cast-playback.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $< $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
|
||||
$(BUILD_DIR)/cast-playback-check: Checks/cast-playback.swift Checks/playback-power.swift $(SWIFT_SOURCES) $(OBJECTS) $(CONFIG_LIBRARY) Makefile | check-dependencies
|
||||
$(SWIFT) -target $(ARCH)-apple-macosx15.0 -swift-version 6 -parse-as-library -DPLAYER_CHECK -O $(DEBUG_FLAGS) -import-objc-header Sources/Core/player_macos.h $< Checks/playback-power.swift $(SWIFT_SOURCES) $(CONFIG_FLAGS) $(OBJECTS) $(FF_LIBS) $(FRAMEWORKS) -lc++ -o $@
|
||||
|
||||
check-cast: $(BUILD_DIR)/cast-session-check $(BUILD_DIR)/cast-media-check $(BUILD_DIR)/cast-playback-check check-fixtures
|
||||
$(BUILD_DIR)/cast-session-check
|
||||
|
||||
@@ -31,7 +31,7 @@ Builds are ad-hoc signed for personal use, not notarized. Bundled licenses and s
|
||||
|
||||
## 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 |
|
||||
| --- | --- |
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
@@ -59,9 +75,9 @@ Only individual videos are supported, not playlists or channels. Private, age-re
|
||||
|
||||
### Chromecast
|
||||
|
||||
Open media, select the **Cast** icon or **Playback → Cast to Device → Find Cast Devices…**, and choose a receiver on the same LAN. Allow macOS Local Network access and incoming firewall connections. Keep the Mac awake and connected: it relays the media to the receiver.
|
||||
Open media, select the **Cast** icon or **Playback → Cast to Device → Find Cast Devices…**, and choose a receiver on the same LAN. Allow macOS Local Network access and incoming firewall connections. Terebi keeps the Mac awake during the handoff and active Cast playback while allowing the screensaver and display sleep once local playback stops. Keep the Mac connected: it relays media to the receiver. Closing the lid, explicitly sleeping the Mac or critical-battery sleep can still interrupt playback.
|
||||
|
||||
Existing controls operate remote playback. **Play on This Mac** returns to local playback at the receiver's position. Opening different media ends casting. Frame stepping and YouTube quality changes are local-only; live Cast playback is not seekable. Cast output is limited to 1080p/30 fps. The screen-and-triangle icon does not provide AirPlay support.
|
||||
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.
|
||||
|
||||
@@ -112,7 +128,7 @@ automatic_quality = true
|
||||
[ui]
|
||||
buffer_info = 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]
|
||||
play = "space"
|
||||
@@ -127,7 +143,7 @@ Settings load once at startup. Menu changes are session-only and never write the
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
@@ -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 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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import QuartzCore
|
||||
@MainActor
|
||||
final class CompactWindowController: NSObject {
|
||||
var onChange: (() -> Void)?
|
||||
var normalVideoLayout: (() -> (aspect: Double, sidebarWidth: CGFloat)?)?
|
||||
private(set) var isCompact = false
|
||||
private(set) var isTransitioning = false
|
||||
static let transitionDuration: TimeInterval = 0.25
|
||||
@@ -20,6 +21,7 @@ final class CompactWindowController: NSObject {
|
||||
let minSize: NSSize
|
||||
let contentMinSize: NSSize
|
||||
let aspectRatio: NSSize
|
||||
let resizeIncrements: NSSize
|
||||
let styleMask: NSWindow.StyleMask
|
||||
let titleVisibility: NSWindow.TitleVisibility
|
||||
let titlebarAppearsTransparent: Bool
|
||||
@@ -34,6 +36,7 @@ final class CompactWindowController: NSObject {
|
||||
minSize = window.minSize
|
||||
contentMinSize = window.contentMinSize
|
||||
aspectRatio = window.aspectRatio
|
||||
resizeIncrements = window.resizeIncrements
|
||||
styleMask = window.styleMask
|
||||
titleVisibility = window.titleVisibility
|
||||
titlebarAppearsTransparent = window.titlebarAppearsTransparent
|
||||
@@ -194,6 +197,8 @@ final class CompactWindowController: NSObject {
|
||||
// NSObject forwarding returns untyped Any; accesses remain on AppKit's main thread.
|
||||
nonisolated(unsafe) weak var original: (any NSWindowDelegate)?
|
||||
private weak var owner: CompactWindowController?
|
||||
private var resizeStartSize: NSSize?
|
||||
private var resizeWidthDriven: Bool?
|
||||
|
||||
init(owner: CompactWindowController, original: (any NSWindowDelegate)?) {
|
||||
self.owner = owner
|
||||
@@ -210,6 +215,44 @@ final class CompactWindowController: NSObject {
|
||||
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) {
|
||||
owner?.fullScreenFailed()
|
||||
original?.windowDidFailToEnterFullScreen?(window)
|
||||
@@ -310,7 +353,11 @@ final class CompactWindowController: NSObject {
|
||||
window.minSize = snapshot.minSize
|
||||
window.contentMinSize = snapshot.contentMinSize
|
||||
window.setFrame(snapshot.frame, display: true)
|
||||
window.aspectRatio = snapshot.aspectRatio
|
||||
if snapshot.aspectRatio == .zero {
|
||||
window.resizeIncrements = snapshot.resizeIncrements
|
||||
} else {
|
||||
window.aspectRatio = snapshot.aspectRatio
|
||||
}
|
||||
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.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
|
||||
guard let self, let window else { return }
|
||||
if Self.canFitAspect(aspect, available: usable.size) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -79,11 +79,14 @@ extension PlayerModel {
|
||||
}
|
||||
|
||||
private func handleKeyEvent(_ event: NSEvent, in window: NSWindow, chord: KeyChord) -> Bool {
|
||||
guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil,
|
||||
!(window.firstResponder is NSTextView), !(window.firstResponder is NSTextField) else { return false }
|
||||
// Preserve focus traversal, control activation/navigation and VoiceOver's own chords.
|
||||
guard event.window === window, NSApp.modalWindow == nil, window.attachedSheet == nil else { return false }
|
||||
let responder = window.firstResponder
|
||||
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 window.firstResponder is NSControl,
|
||||
if responder is NSControl, !(responder is NSTableView), !(responder is NSTextField),
|
||||
chord.modifiers.subtracting(.shift).isEmpty,
|
||||
Self.nativeNavigationKeys.contains(chord.key) {
|
||||
return false
|
||||
@@ -178,6 +181,8 @@ struct PlayerCommands: Commands {
|
||||
Button("Open File…") { model.performMenuAction(.openFile) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: .openFile).first?.keyboardShortcut)
|
||||
.disabled(!model.canPerform(.openFile))
|
||||
Button("Add to Queue…", action: model.addQueuePanel)
|
||||
.disabled(!model.canOpen)
|
||||
Button("Open YouTube URL…") { model.performMenuAction(.openYoutube) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: .openYoutube).first?.keyboardShortcut)
|
||||
.disabled(!model.canPerform(.openYoutube))
|
||||
@@ -195,6 +200,10 @@ struct PlayerCommands: Commands {
|
||||
Button(model.effectivePlaying ? "Pause" : "Play") { model.performMenuAction(playback) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: playback).first?.keyboardShortcut)
|
||||
.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) }
|
||||
.keyboardShortcut(model.keyBindings.shortcuts(for: .seekBackward5s).first?.keyboardShortcut)
|
||||
.disabled(!hasFocusedScene || !model.canPerform(.seekBackward5s))
|
||||
@@ -256,6 +265,8 @@ struct PlayerCommands: Commands {
|
||||
.disabled(!hasFocusedScene || !model.canPerform(bufferInfo))
|
||||
}
|
||||
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") {
|
||||
model.performMenuAction(fullscreen)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ final class SystemPlaybackControls {
|
||||
private let infoCenter = MPNowPlayingInfoCenter.default()
|
||||
private var targets: [(MPRemoteCommand, Any)] = []
|
||||
private var snapshot: Snapshot?
|
||||
private var activity: NSObjectProtocol?
|
||||
private var activityOptions: ProcessInfo.ActivityOptions = []
|
||||
|
||||
init(model: PlayerModel) {
|
||||
self.model = model
|
||||
@@ -75,6 +77,23 @@ final class SystemPlaybackControls {
|
||||
}
|
||||
|
||||
func update() {
|
||||
var options: ProcessInfo.ActivityOptions = []
|
||||
if let model, model.fileName != nil,
|
||||
model.state.status == PLAYER_PLAYING || model.castConnecting {
|
||||
options = .userInitiated
|
||||
if model.state.status == PLAYER_PLAYING, model.state.has_video,
|
||||
model.castDevice == nil || model.castConnecting {
|
||||
options.insert(.idleDisplaySleepDisabled)
|
||||
}
|
||||
}
|
||||
if options != activityOptions {
|
||||
// Acquire before releasing so a local/Cast handoff cannot briefly allow sleep.
|
||||
let next = options.isEmpty ? nil :
|
||||
ProcessInfo.processInfo.beginActivity(options: options, reason: "Playing media")
|
||||
if let activity { ProcessInfo.processInfo.endActivity(activity) }
|
||||
activity = next
|
||||
activityOptions = options
|
||||
}
|
||||
let ready = model?.fileName != nil && model?.canTogglePlayback == true
|
||||
let seekable = ready && model?.canSeek == true
|
||||
for (command, _) in targets {
|
||||
@@ -117,6 +136,9 @@ final class SystemPlaybackControls {
|
||||
}
|
||||
|
||||
func shutdown() {
|
||||
if let activity { ProcessInfo.processInfo.endActivity(activity) }
|
||||
activity = nil
|
||||
activityOptions = []
|
||||
for (command, target) in targets {
|
||||
command.removeTarget(target)
|
||||
command.isEnabled = false
|
||||
|
||||
+339
-76
@@ -5,7 +5,11 @@ import TerebiConfiguration
|
||||
func compactProgressText(position: Double, duration: Double, format: CompactTitleProgress) -> String? {
|
||||
guard format != .none, duration.isFinite, duration > 0, position.isFinite else { return nil }
|
||||
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 current = Int(min(position, Double(Int.max / 2)))
|
||||
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 fileIcon: NSImage?
|
||||
@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()
|
||||
let castDiscovery = CastDiscovery()
|
||||
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 }
|
||||
cancelYouTubeResolution()
|
||||
commandError = nil
|
||||
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)
|
||||
revealControls()
|
||||
}
|
||||
|
||||
func stopCasting(resume: Bool = true) {
|
||||
queueCastDevice = nil
|
||||
casting.stop(resume: resume)
|
||||
}
|
||||
|
||||
@@ -220,6 +248,7 @@ final class PlayerModel: ObservableObject {
|
||||
if !isSeeking && !state.seek_pending { seekPosition = state.position }
|
||||
if let handle { _ = player_report_remote_state(handle, &state) }
|
||||
saveProgress()
|
||||
completeQueuePlaybackIfNeeded()
|
||||
case .stopped(let result):
|
||||
if let checkpoint = result.checkpoint {
|
||||
saveCastProgress(checkpoint, source: result.source)
|
||||
@@ -249,17 +278,21 @@ final class PlayerModel: ObservableObject {
|
||||
return accepted
|
||||
}
|
||||
|
||||
func openPanel() {
|
||||
func openPanel() { filePanel(append: false) }
|
||||
|
||||
func addQueuePanel() { filePanel(append: true) }
|
||||
|
||||
private func filePanel(append: Bool) {
|
||||
guard canOpen else { return }
|
||||
defer { window?.makeFirstResponder(nil) }
|
||||
revealControls()
|
||||
let panel = NSOpenPanel()
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowsMultipleSelection = false
|
||||
panel.prompt = "Open"
|
||||
guard panel.runModal() == .OK, let url = panel.url else { return }
|
||||
open(url)
|
||||
panel.allowsMultipleSelection = true
|
||||
panel.prompt = append ? "Add to Queue" : "Open"
|
||||
guard panel.runModal() == .OK else { return }
|
||||
if append { addToQueue(panel.urls) } else { openFiles(panel.urls) }
|
||||
}
|
||||
|
||||
func openYouTubePanel() {
|
||||
@@ -290,12 +323,14 @@ final class PlayerModel: ObservableObject {
|
||||
revealControls()
|
||||
return
|
||||
}
|
||||
clearQueue()
|
||||
resolveYouTube(url, height: nil)
|
||||
}
|
||||
|
||||
func openResolvedSource(_ file: URL) {
|
||||
do {
|
||||
let source = try ResolvedSource.load(from: file)
|
||||
clearQueue()
|
||||
cancelYouTubeResolution()
|
||||
openMedia(.resolved(source))
|
||||
} catch {
|
||||
@@ -483,6 +518,8 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
transition.acceptOpen(revision: revision, source: source, change: change, savedPosition: savedPosition)
|
||||
queuePlaybackRevision = queue.currentID == nil ? nil : player_get_state(handle).revision
|
||||
queueCompletionHandled = false
|
||||
currentMedia = source
|
||||
if change.automatic { automaticQuality.resetHealth() } else { automaticQuality = AutomaticQuality() }
|
||||
switch source {
|
||||
@@ -524,13 +561,79 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func open(_ url: URL) {
|
||||
if url.isFileURL { openFiles([url]) } else { openYouTube(url.absoluteString) }
|
||||
}
|
||||
|
||||
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 }
|
||||
guard url.isFileURL else {
|
||||
openYouTube(url.absoluteString)
|
||||
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()
|
||||
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() {
|
||||
@@ -578,7 +681,7 @@ final class PlayerModel: ObservableObject {
|
||||
revealControls()
|
||||
}
|
||||
|
||||
func closeFile() {
|
||||
func closeFile(clearQueue: Bool = true) {
|
||||
guard let handle, canClose else { return }
|
||||
saveProgress()
|
||||
if castDevice != nil { stopCasting(resume: false) }
|
||||
@@ -589,6 +692,10 @@ final class PlayerModel: ObservableObject {
|
||||
return
|
||||
}
|
||||
transition.closeMedia()
|
||||
if clearQueue { self.clearQueue() }
|
||||
queuePlaybackRevision = nil
|
||||
queueCompletionHandled = true
|
||||
queueCastDevice = nil
|
||||
currentMedia = nil
|
||||
selectSubtitle(nil)
|
||||
subtitleTracks = []
|
||||
@@ -600,7 +707,7 @@ final class PlayerModel: ObservableObject {
|
||||
youtubeHeight = nil
|
||||
youtubeQuality = nil
|
||||
needsVideoFit = false
|
||||
window?.aspectRatio = .zero
|
||||
window?.resizeIncrements = NSSize(width: 1, height: 1)
|
||||
commandError = nil
|
||||
isSeeking = false
|
||||
window?.representedURL = nil
|
||||
@@ -657,6 +764,10 @@ final class PlayerModel: ObservableObject {
|
||||
self.window = window
|
||||
window.title = fileName ?? "terebi"
|
||||
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)
|
||||
updateWindowState()
|
||||
fitVideoWindow()
|
||||
@@ -873,18 +984,23 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
needsVideoFit = false
|
||||
guard state.has_video, aspect.isFinite, aspect > 0 else {
|
||||
window.aspectRatio = .zero
|
||||
window.resizeIncrements = NSSize(width: 1, height: 1)
|
||||
return
|
||||
}
|
||||
let screen = (window.screen ?? NSScreen.main)?.visibleFrame ?? window.frame
|
||||
let width = min(window.frame.width, screen.width, screen.height * aspect)
|
||||
let size = NSSize(width: width, height: width / aspect)
|
||||
let sidebarWidth: CGFloat = showsQueue ? 260 : 0
|
||||
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.
|
||||
guard size.width >= window.minSize.width, size.height >= window.minSize.height else {
|
||||
window.aspectRatio = .zero
|
||||
window.resizeIncrements = NSSize(width: 1, height: 1)
|
||||
return
|
||||
}
|
||||
window.aspectRatio = NSSize(width: aspect, height: 1)
|
||||
if showsQueue {
|
||||
window.resizeIncrements = NSSize(width: 1, height: 1)
|
||||
} else {
|
||||
window.aspectRatio = NSSize(width: aspect, height: 1)
|
||||
}
|
||||
let frame = NSRect(
|
||||
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),
|
||||
@@ -918,6 +1034,8 @@ final class PlayerModel: ObservableObject {
|
||||
}
|
||||
|
||||
func applyState(_ next: PlayerState) {
|
||||
guard !casting.active else { return }
|
||||
if let revision = queuePlaybackRevision, next.revision < revision { return }
|
||||
let statusChanged = state.status != next.status
|
||||
let observation = transition.advance(next,
|
||||
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 }
|
||||
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() {
|
||||
@@ -1242,6 +1369,7 @@ final class PlayerModel: ObservableObject {
|
||||
|
||||
private enum MediaChange {
|
||||
case open
|
||||
case queue(playing: Bool)
|
||||
case quality(position: Double?, playing: Bool, automatic: Bool)
|
||||
case restore(position: Double, playing: Bool)
|
||||
|
||||
@@ -1252,6 +1380,7 @@ final class PlayerModel: ObservableObject {
|
||||
var playing: Bool {
|
||||
if case .quality(_, let playing, _) = self { return playing }
|
||||
if case .restore(_, let playing) = self { return playing }
|
||||
if case .queue(let playing) = self { return playing }
|
||||
return true
|
||||
}
|
||||
var position: Double? {
|
||||
@@ -1689,14 +1818,6 @@ private struct PlaybackSlider: NSViewRepresentable {
|
||||
func castVolumeSliderFeedbackCheck() -> Bool { PlaybackSlider.Slider.feedbackCheck() }
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
private struct VideoSurface: NSViewRepresentable {
|
||||
let view: NSView
|
||||
|
||||
func makeNSView(context: Context) -> NSView { view }
|
||||
func updateNSView(_ nsView: NSView, context: Context) {}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct CaptionSelector: NSViewRepresentable {
|
||||
let model: PlayerModel
|
||||
@@ -1768,22 +1889,46 @@ private struct CaptionSelector: NSViewRepresentable {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct CompactDragSurface: NSViewRepresentable {
|
||||
let model: PlayerModel
|
||||
struct PlaybackSurface: NSViewRepresentable {
|
||||
@ObservedObject var model: PlayerModel
|
||||
|
||||
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 {
|
||||
private let model: PlayerModel
|
||||
private var hoverTracking: NSTrackingArea?
|
||||
private var mouseDownEvent: NSEvent?
|
||||
|
||||
init(model: PlayerModel) {
|
||||
self.model = model
|
||||
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 }
|
||||
|
||||
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() {
|
||||
super.updateTrackingAreas()
|
||||
if let hoverTracking { removeTrackingArea(hoverTracking) }
|
||||
@@ -1796,18 +1941,33 @@ private struct CompactDragSurface: NSViewRepresentable {
|
||||
|
||||
override func mouseEntered(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) {
|
||||
mouseDownEvent = nil
|
||||
if event.clickCount == 2 {
|
||||
model.toggleCompact()
|
||||
return
|
||||
if model.isCompact { model.toggleCompact() } else { model.toggleFullScreen() }
|
||||
} else {
|
||||
mouseDownEvent = event
|
||||
}
|
||||
}
|
||||
|
||||
override func mouseDragged(with event: NSEvent) {
|
||||
guard let down = mouseDownEvent, let window else { return }
|
||||
mouseDownEvent = nil
|
||||
model.setControlsDragging(true)
|
||||
window?.performDrag(with: event)
|
||||
model.compactWindow.snapAfterDrag()
|
||||
window.performDrag(with: down)
|
||||
if model.isCompact { model.compactWindow.snapAfterDrag() }
|
||||
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)
|
||||
.clipped()
|
||||
if let progress {
|
||||
Text(" · \(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)")
|
||||
}
|
||||
}
|
||||
.monospacedDigit()
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(model.compactTitleProgress == .percentage ? "\(progress) percent" : progress)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
@@ -1914,7 +2083,9 @@ private struct PlayerContent: View {
|
||||
.frame(height: 24)
|
||||
.background(PlayerGlass(cornerRadius: 8).clipShape(RoundedRectangle(cornerRadius: 8)).allowsHitTesting(false))
|
||||
Spacer(minLength: 12)
|
||||
titleControls
|
||||
if !model.showsQueue {
|
||||
titleControls
|
||||
}
|
||||
}
|
||||
.padding(.leading, model.isFullScreen ? 16 : 86)
|
||||
.padding(.trailing, 12)
|
||||
@@ -1928,6 +2099,9 @@ private struct PlayerContent: View {
|
||||
|
||||
private var titleControls: some View {
|
||||
HStack(spacing: 4) {
|
||||
iconButton("list.bullet", model.showsQueue ? "Hide Queue" : "Show Queue") {
|
||||
model.showsQueue.toggle()
|
||||
}
|
||||
iconButton(model.isOnTop ? "pin.fill" : "pin",
|
||||
model.isOnTop ? "Stop Keeping Window on Top" : "Keep Window on Top", action: model.toggleOnTop)
|
||||
.imageScale(.small)
|
||||
@@ -1938,8 +2112,12 @@ private struct PlayerContent: View {
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.frame(height: 24)
|
||||
.background(PlayerGlass(cornerRadius: 8)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8)).allowsHitTesting(false))
|
||||
.background {
|
||||
if !model.showsQueue {
|
||||
PlayerGlass(cornerRadius: 8)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8)).allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var captionsMenu: some View {
|
||||
@@ -1950,6 +2128,10 @@ private struct PlayerContent: View {
|
||||
private var actionsMenu: some View {
|
||||
Menu {
|
||||
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: .closeFile, title: "Close File")
|
||||
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 {
|
||||
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
|
||||
ZStack(alignment: .top) {
|
||||
if model.fileName == nil || model.castDevice != nil {
|
||||
@@ -2113,18 +2394,8 @@ private struct PlayerContent: View {
|
||||
} else {
|
||||
Color.black
|
||||
}
|
||||
if let view = model.videoView {
|
||||
VideoSurface(view: view)
|
||||
.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)
|
||||
}
|
||||
PlaybackSurface(model: model)
|
||||
.accessibilityLabel("Video")
|
||||
if !model.subtitleText.isEmpty {
|
||||
Text(model.subtitleText)
|
||||
.font(.system(size: max(16, min(28, geometry.size.width / 32)), weight: .semibold))
|
||||
@@ -2316,12 +2587,9 @@ private struct PlayerContent: View {
|
||||
|
||||
titlebar
|
||||
.frame(width: geometry.size.width)
|
||||
.animation(compactChromeAnimation) { content in
|
||||
content.opacity(model.isCompact ? 0 : 1)
|
||||
}
|
||||
.allowsHitTesting(!model.isCompact && model.controlsVisible)
|
||||
.accessibilityHidden(model.isCompact || !model.controlsVisible)
|
||||
|
||||
.opacity(model.isCompact || model.compactWindow.isTransitioning ? 0 : 1)
|
||||
.allowsHitTesting(!model.isCompact && !model.compactWindow.isTransitioning && model.controlsVisible)
|
||||
.accessibilityHidden(model.isCompact || model.compactWindow.isTransitioning || !model.controlsVisible)
|
||||
if model.canTogglePlayback {
|
||||
playbackControls
|
||||
.frame(width: 440, height: controlHeight)
|
||||
@@ -2349,9 +2617,14 @@ private struct PlayerContent: View {
|
||||
}
|
||||
}
|
||||
.dropDestination(for: URL.self) { urls, _ in
|
||||
guard model.canOpen, let url = urls.first else { return false }
|
||||
if !url.isFileURL && (try? YouTubeResolver.validateURL(url.absoluteString)) == nil { return false }
|
||||
model.open(url)
|
||||
guard model.canOpen, !urls.isEmpty else { 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)
|
||||
}
|
||||
return true
|
||||
} isTargeted: {
|
||||
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) {
|
||||
model.setControlsDragging(true)
|
||||
window?.performDrag(with: event)
|
||||
model.compactWindow.snapAfterDrag()
|
||||
model.setControlsDragging(false)
|
||||
}
|
||||
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
||||
|
||||
func detach() {
|
||||
if let monitor { NSEvent.removeMonitor(monitor) }
|
||||
@@ -2504,8 +2765,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
}
|
||||
|
||||
@objc private func openDocuments(_ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor) {
|
||||
guard let url = event.paramDescriptor(forKeyword: keyDirectObject)?.atIndex(1)?.fileURLValue else { return }
|
||||
PlayerModel.shared.open(url)
|
||||
guard let files = event.paramDescriptor(forKeyword: keyDirectObject), files.numberOfItems > 0 else { return }
|
||||
let urls = (1...files.numberOfItems).compactMap { files.atIndex($0)?.fileURLValue }
|
||||
PlayerModel.shared.openFiles(urls)
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
@@ -2525,7 +2787,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
// Let SwiftUI create the initial window for a Finder/document launch.
|
||||
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 }
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
- [x] improve chrome cast subtitle rendering
|
||||
- native WebVTT style: white sans-serif, black outline, transparent background; physical-TV font-scale tuning remains unverified
|
||||
- [ ] add playlist support
|
||||
- maybe by allowing multiple file selection? 🤔
|
||||
- [x] currently, computer falls asleep even if video is playing. this should not be the case.
|
||||
- native playback activity prevents idle system sleep; local video keeps the display awake, casting allows screensaver/display sleep; native assertion checks and loopback Cast transitions passed, physical idle-TV verification remains unverified
|
||||
- [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
|
||||
- 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)
|
||||
Reference in New Issue
Block a user