feat: add editable playback queue

This commit is contained in:
2026-10-18 00:15:05 +02:00
parent d4704af442
commit ce903e2367
10 changed files with 510 additions and 33 deletions
+34
View File
@@ -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 {
@@ -183,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
+32
View File
@@ -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)
+71
View File
@@ -0,0 +1,71 @@
import Foundation
@main
struct PlaybackQueueChecks {
static func require(_ condition: @autoclosure () -> Bool, _ message: String) {
if !condition() { fatalError("FAIL: " + message) }
}
static func file(_ name: String) -> URL {
URL(fileURLWithPath: "/queue-check/" + name + ".mp4")
}
static func main() {
let first = file("Episode1"), second = file("Episode2"), tenth = file("Episode10")
var queue = PlaybackQueue()
require(queue.items.isEmpty && queue.currentURL == nil && !queue.canPrevious && !queue.canNext,
"empty queue has no selection or navigation")
require(queue.advance(1) == nil && queue.advance(-1) == nil, "empty navigation is harmless")
queue.replace([tenth, second, first])
require(queue.items.map(\.url) == [first, second, tenth], "replace uses natural Episode2/Episode10 filename order")
require(queue.currentURL == first && !queue.canPrevious && queue.canNext, "replace selects first item")
let firstID = queue.currentID
require(queue.advance(-1) == nil && queue.currentID == firstID, "previous at start does not wrap or deselect")
require(queue.advance(1) == second && queue.canPrevious && queue.canNext, "next selects middle item")
require(queue.advance(1) == tenth && queue.canPrevious && !queue.canNext, "next selects final item")
let tenthID = queue.currentID
require(queue.advance(1) == nil && queue.currentID == tenthID, "next at end stops without wrapping")
require(queue.advance(-1) == second, "previous moves back one item")
let selected = queue.currentID!
queue.append([file("Episode20"), file("Episode3")])
require(queue.items.map(\.url) == [first, second, tenth, file("Episode3"), file("Episode20")],
"append naturally sorts the new batch without reordering existing items")
require(queue.currentID == selected && queue.currentURL == second, "append does not interrupt selection")
queue.move(fromOffsets: IndexSet(integer: 1), toOffset: queue.items.count)
require(queue.items.last?.id == selected && queue.currentID == selected && queue.currentURL == second,
"reordering preserves current item identity")
require(queue.canPrevious && !queue.canNext, "navigation follows reordered positions")
queue.move(fromOffsets: IndexSet([0, 1]), toOffset: queue.items.count)
require(queue.currentID == selected && queue.currentURL == second, "moving other items preserves current")
queue.replace([tenth, second, first])
queue.select(queue.items[1].id)
require(queue.currentURL == second, "explicit selection jumps to an item")
let current = queue.currentID
let unrelated = queue.items[0].id
require(queue.remove(unrelated) == nil && queue.currentID == current && queue.currentURL == second,
"removing another item does not request playback or change selection")
require(queue.remove(current!) == tenth && queue.currentURL == tenth,
"removing current selects the next remaining item")
require(queue.remove(queue.currentID!) == nil && queue.currentURL == nil && queue.items.isEmpty,
"removing final item stops and empties queue")
queue.replace([first, second, tenth])
queue.select(queue.items.last!.id)
require(queue.remove(queue.currentID!) == nil && queue.currentID == nil && queue.items.count == 2,
"removing current at end stops rather than jumping backward")
require(!queue.canPrevious && !queue.canNext, "stopped queue has no implicit selection")
queue.select(queue.items[0].id)
require(queue.currentURL == first, "remaining items can be explicitly selected after stopping")
queue.append([first])
require(Set(queue.items.map(\.id)).count == queue.items.count, "duplicate file entries have independent identities")
queue.replace([tenth])
require(queue.items.map(\.url) == [tenth] && queue.currentURL == tenth && !queue.canPrevious && !queue.canNext,
"replace discards previous queue and starts new selection")
queue.replace([])
require(queue.items.isEmpty && queue.currentID == nil, "empty replacement clears queue")
print("playback queue checks passed")
}
}
+50
View File
@@ -51,6 +51,54 @@ 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 main() {
checkCompactProgress()
_ = NSApplication.shared
@@ -58,6 +106,8 @@ struct SystemPlaybackChecks {
let suite = "terebi.system-playback-check.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
checkQueue(directory: URL(fileURLWithPath: CommandLine.arguments[1], isDirectory: true), defaults: defaults)
defaults.removePersistentDomain(forName: suite)
let initialModel = PlayerModel(defaults: defaults)
require(!initialModel.showsBufferInfo, "buffer readout defaults off before playback")
initialModel.setShowsBufferInfo(true)