feat: add editable playback queue
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user