mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-11-10 22:43:42 +08:00
555 lines
35 KiB
JavaScript
555 lines
35 KiB
JavaScript
import { requestJson } from "./startup.js"
|
||
import { GxNotice } from "./notice.js"
|
||
import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
|
||
import { GxState } from "./state.js"
|
||
import { GxIconButton } from "./icon-button.js"
|
||
import { markRaw } from "../vendor/vue/vue.esm-browser.js"
|
||
import { ReplayHistory, drawReplayTimeline } from "./replay-timeline.js"
|
||
|
||
const ID = /^[a-f0-9]{32}$/
|
||
const STATES = new Set(["idle", "preparing", "buffering", "playing", "paused", "seeking", "stopping", "ended", "failed"])
|
||
const TERMINAL = new Set(["idle", "failed"])
|
||
const number = value => typeof value === "number" && Number.isFinite(value)
|
||
const integer = value => Number.isSafeInteger(value) && value >= 0
|
||
export const replayToken = () => Array.from(crypto.getRandomValues(new Uint8Array(16)), value => value.toString(16).padStart(2, "0")).join("")
|
||
const mediaSources = () => [globalThis.MediaSource, globalThis.ManagedMediaSource].filter(Source => typeof Source === "function")
|
||
const STREAMING_UNAVAILABLE = "Replay video streaming is unavailable in this browser. Update your browser or try another browser."
|
||
const PLAY_BUFFER = .5, MAX_BUFFER_AHEAD = 1, MEDIA_INTERVAL = 50
|
||
export const replayableSegment = segment => !!((segment.files.rlog || segment.files.qlog) && (segment.files.fcamera || segment.files.qcamera))
|
||
export function validReplayStatus(value) {
|
||
const ranges = value?.seekRanges, segments = value?.segmentRanges
|
||
return value?.schemaVersion === 1 && ID.test(value.ownerId) && STATES.has(value.state) &&
|
||
(value.sessionId === null || ID.test(value.sessionId)) && integer(value.revision) && integer(value.epoch) &&
|
||
[value.position, value.start, value.end].every(number) && value.start <= value.end &&
|
||
[.5, 1, 2].includes(value.rate) && typeof value.paused === "boolean" && typeof value.offroad === "boolean" && typeof value.owned === "boolean" &&
|
||
(value.codec == null || typeof value.codec === "string" && value.codec.length <= 128) &&
|
||
(ranges === undefined || Array.isArray(ranges) && ranges.length <= 512 && ranges.every((range, i) =>
|
||
Array.isArray(range) && range.length === 2 && range.every(number) && value.start <= range[0] && range[0] <= range[1] &&
|
||
range[1] <= value.end && (i === 0 || ranges[i-1][1] <= range[0]))) &&
|
||
(segments === undefined || Array.isArray(segments) && segments.length <= 512 &&
|
||
(!segments.length || segments.length === ranges?.length) && new Set(segments.map(row => row?.[0])).size === segments.length &&
|
||
segments.every((row, i) => Array.isArray(row) && row.length === 3 && integer(row[0]) && row[0] <= 999999 &&
|
||
row[1] === ranges[i][0] && row[2] === ranges[i][1]))
|
||
}
|
||
export function replaySeekPosition(status, position) {
|
||
const bounded = Math.min(status.end, Math.max(status.start, position)), ranges = status.seekRanges || []
|
||
if (!ranges.length || ranges.some(([start, end]) => start <= bounded && bounded <= end)) return bounded
|
||
let nearest = ranges[0][0]
|
||
for (const range of ranges) for (const boundary of range) if (Math.abs(boundary - bounded) < Math.abs(nearest - bounded)) nearest = boundary
|
||
return nearest
|
||
}
|
||
export function replaySeekGaps(status) {
|
||
const gaps = [], ranges = status?.seekRanges || []
|
||
let after = status?.start
|
||
for (const [start, end] of ranges) { if (start > after) gaps.push([after, start]); after = end }
|
||
if (ranges.length && after < status.end) gaps.push([after, status.end])
|
||
return gaps
|
||
}
|
||
export function replayBufferTime(buffered, paused = false) {
|
||
const last = buffered.length - 1, start = buffered.start(last), end = buffered.end(last)
|
||
return Math.max(start, end - (paused ? .001 : Math.min(.2, end - start)))
|
||
}
|
||
export const replayTime = seconds => `${Math.floor(Math.max(0, seconds) / 60)}:${String(Math.floor(Math.max(0, seconds) % 60)).padStart(2, "0")}`
|
||
|
||
// The browser owns viewing; every route action still goes through the parked replay owner.
|
||
export class ReplayClient {
|
||
constructor({ publish, unauthorized, video, fetcher = (...args) => fetch(...args),
|
||
mediaSource = Source => new Source(), createURL = value => URL.createObjectURL(value), revokeURL = value => URL.revokeObjectURL(value),
|
||
later = (fn, ms) => setTimeout(fn, ms), cancelTimer = id => clearTimeout(id), clientId = replayToken() }) {
|
||
Object.assign(this, { publish, unauthorized, video, fetcher, mediaSource, createURL, revokeURL, later, cancelTimer, clientId })
|
||
this.active = false
|
||
this.generation = this.mediaGeneration = 0
|
||
this.requests = new Set()
|
||
this.status = null
|
||
this.busy = false
|
||
this.pendingAction = null
|
||
this.error = ""
|
||
this.detail = null
|
||
this.ownedSession = null
|
||
this.timeline = null
|
||
this.historyError = ""
|
||
this.historyGeneration = 0
|
||
}
|
||
emit() { this.publish({ replay: this.status, replayBusy: this.busy, replayError: this.error, replayDetail: this.detail,
|
||
replayTimeline: this.timeline?.rows ? this.timeline : null, replayHistoryNotice: this.historyError,
|
||
replayVideoReady: !!this.buffer?.buffered.length, replayBuffering: this.needsBuffer() }) }
|
||
async json(path, body = null) {
|
||
const request = new AbortController(), generation = this.generation
|
||
this.requests.add(request)
|
||
if (path === "start") this.startRequest = request
|
||
try {
|
||
const data = await requestJson(`./api/replay/${path}`, { fetcher: this.fetcher, timeout: 6000,
|
||
later: this.later, cancel: this.cancelTimer, request: { signal: request.signal,
|
||
...(body === null ? {} : { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }) } })
|
||
return path === "start" || this.active && generation === this.generation ? data : null
|
||
} catch (error) {
|
||
if (!this.active || generation !== this.generation) return null
|
||
if (error.status === 401 || ["setup_required", "access_unavailable"].includes(error.code)) { this.close(false); this.unauthorized(); return null }
|
||
throw error
|
||
} finally { this.requests.delete(request); if (this.startRequest === request) this.startRequest = null }
|
||
}
|
||
async open(routeId, startSegment = null) {
|
||
this.close(false)
|
||
this.active = true; this.stopping = false
|
||
this.status = this.detail = null; this.error = ""
|
||
this.routeId = routeId
|
||
this.startSegment = startSegment
|
||
this.emit()
|
||
const generation = this.generation
|
||
try {
|
||
const [detail] = await Promise.all([this.json(`detail?routeId=${encodeURIComponent(routeId)}`), this.refresh()])
|
||
if (!this.active || generation !== this.generation) return
|
||
this.detail = detail; this.emit()
|
||
if (!this.stopping && !this.error && detail?.playable && this.status?.offroad && TERMINAL.has(this.status.state)) await this.start()
|
||
} catch (error) { if (this.active) { this.error = error.message; this.emit() } }
|
||
}
|
||
async refresh() {
|
||
if (!this.active || this.reading) return
|
||
const generation = this.generation
|
||
this.reading = true
|
||
try {
|
||
const status = await this.json(`status?clientId=${this.clientId}`)
|
||
if (!status) return
|
||
if (!validReplayStatus(status)) throw new Error("Replay returned an unsupported status.")
|
||
this.receive(status)
|
||
} catch (error) {
|
||
if (this.active) { this.retireMedia(); this.status = null; this.error = error.message || "Replay disconnected. Refresh before continuing."; this.emit() }
|
||
} finally {
|
||
if (generation === this.generation) {
|
||
this.reading = false
|
||
if (this.active) { this.cancelTimer(this.timer); this.timer = this.later(() => this.refresh(), 500) }
|
||
}
|
||
}
|
||
}
|
||
receive(status) {
|
||
const prior = this.status
|
||
if (prior?.ownerId === status.ownerId && prior.sessionId === status.sessionId && status.revision < prior.revision) return
|
||
if (!this.ownedSession && status.purpose !== "export" && status.owned && ID.test(status.sessionId) && status.routeId === this.routeId) { this.ownedSession = status.sessionId; this.error = "" }
|
||
this.status = status
|
||
if (prior?.ownerId !== status.ownerId || prior?.sessionId !== status.sessionId || !status.owned || !status.offroad || ["idle", "failed", "stopping"].includes(status.state)) this.resetTimeline()
|
||
if (status.timelineState === "unavailable") { this.resetTimeline(); this.historyError = "History unavailable" }
|
||
else if (status.timelineState === "ready" && status.owned && status.offroad && !["idle", "failed", "stopping"].includes(status.state) && !this.timeline && !this.historyError) {
|
||
this.timeline = new ReplayHistory(status.ownerId, status.sessionId)
|
||
}
|
||
this.loadTimeline()
|
||
if (status.lastRequestId === this.pendingAction || TERMINAL.has(status.state) || !status.offroad) { this.pendingAction = null; this.busy = false }
|
||
if (this.ownedSession && (status.purpose === "export" || status.sessionId !== this.ownedSession || !status.owned)) { this.ownedSession = null; this.retireMedia() }
|
||
if (!status.offroad || TERMINAL.has(status.state) || ["preparing", "seeking", "stopping"].includes(status.state) || status.mediaReady === false) this.retireMedia()
|
||
else if (status.sessionId === this.ownedSession && status.codec &&
|
||
((!this.source && !this.videoFailed) || prior?.epoch !== status.epoch)) this.beginMedia(status)
|
||
const video = this.video()
|
||
if (video && this.source) {
|
||
if (status.paused && status.state !== "ended") {
|
||
video.pause()
|
||
if (this.buffer?.buffered.length) {
|
||
const position = replayBufferTime(this.buffer.buffered, true)
|
||
if (Math.abs(video.currentTime - position) > .001) video.currentTime = position
|
||
}
|
||
}
|
||
else this.playVideo()
|
||
}
|
||
if (this.source && this.buffer && (prior?.paused && !status.paused || status.state === "ended" && this.sequence <= status.media?.lastSequence)) {
|
||
this.mediaDemand = true; this.cancelTimer(this.mediaTimer); this.loadMedia(this.mediaGeneration, status.sessionId, status.epoch)
|
||
}
|
||
this.emit()
|
||
}
|
||
resetTimeline() {
|
||
this.historyGeneration++
|
||
this.timeline = null; this.historyError = ""
|
||
this.cancelTimer(this.historyTimer)
|
||
}
|
||
async loadTimeline() {
|
||
const history = this.timeline, generation = this.historyGeneration
|
||
if (!this.active || !history || history.rows || this.historyReading || this.busy) return
|
||
this.historyReading = true
|
||
try {
|
||
const page = await this.json(`timeline?sessionId=${history.sessionId}&clientId=${this.clientId}&cursor=${history.cursor}`)
|
||
if (!page || generation !== this.historyGeneration || this.timeline !== history) return
|
||
history.append(page, this.status)
|
||
if (history.rows) this.timeline = markRaw(history)
|
||
this.emit()
|
||
} catch {
|
||
if (generation === this.historyGeneration) { this.timeline = null; this.historyError = "History unavailable"; this.emit() }
|
||
} finally {
|
||
this.historyReading = false
|
||
if (this.active && generation === this.historyGeneration && history === this.timeline && !history.rows) {
|
||
this.cancelTimer(this.historyTimer); this.historyTimer = this.later(() => this.loadTimeline(), 50)
|
||
}
|
||
}
|
||
}
|
||
playVideo() {
|
||
const generation = this.mediaGeneration, video = this.video()
|
||
const ended = this.status?.state === "ended"
|
||
if (ended && this.sequence > this.status.media?.lastSequence && this.source?.readyState === "open" && !this.buffer?.updating) {
|
||
this.cancelTimer(this.mediaTimer)
|
||
this.source.endOfStream()
|
||
}
|
||
if (!video?.paused || this.status?.paused && !ended || this.needsBuffer() || this.bufferAhead() <= 0) return
|
||
video.play()?.catch(error => {
|
||
if (!this.active || generation !== this.mediaGeneration || error.name === "AbortError") return
|
||
this.error = error.name === "NotAllowedError" ? "Select Play to allow video playback in this browser." : "Replay video could not play. Retry the video or stop replay."
|
||
this.emit()
|
||
})
|
||
}
|
||
bufferAhead() {
|
||
const video = this.video(), ranges = this.buffer?.buffered
|
||
for (let i = 0; video && i < (ranges?.length || 0); i++) {
|
||
if (ranges.start(i) <= video.currentTime && video.currentTime < ranges.end(i)) return ranges.end(i) - video.currentTime
|
||
}
|
||
return 0
|
||
}
|
||
needsBuffer() {
|
||
return !!this.source && !this.status?.paused && this.status?.state !== "ended" && !!this.video()?.paused && this.bufferAhead() < PLAY_BUFFER
|
||
}
|
||
videoWaiting() {
|
||
if (!this.active || !this.source || this.status?.paused || this.status?.state === "ended") return
|
||
this.video().pause()
|
||
this.mediaDemand = true
|
||
this.cancelTimer(this.mediaTimer)
|
||
this.loadMedia(this.mediaGeneration, this.status.sessionId, this.status.epoch)
|
||
this.emit()
|
||
}
|
||
async action(operation, extra = {}) {
|
||
if (!this.active || operation !== "stop" && (this.busy || !this.status)) return
|
||
const generation = this.generation, requestId = replayToken()
|
||
this.pendingAction = operation === "control" ? requestId : null
|
||
this.busy = true; this.error = ""; this.emit()
|
||
try {
|
||
const status = await this.json(operation, { clientId: this.clientId, requestId, ...extra })
|
||
if (!status) return
|
||
if (operation === "start" && ID.test(status.sessionId)) {
|
||
if (!this.active || generation !== this.generation) { this.sendStop(status.sessionId); return }
|
||
this.ownedSession = status.sessionId
|
||
}
|
||
if (!validReplayStatus(status)) throw new Error("Replay returned an unsupported status.")
|
||
this.receive(status)
|
||
} catch (error) {
|
||
this.pendingAction = null
|
||
if (this.active) { this.error = error.message || "Replay action could not complete."; await this.refresh() }
|
||
} finally { this.busy = this.pendingAction !== null; if (this.active) this.emit() }
|
||
}
|
||
start() {
|
||
if (!this.active || this.busy || !this.status) return
|
||
if (!mediaSources().length) { this.error = STREAMING_UNAVAILABLE; this.emit(); return }
|
||
this.startAttempted = true; this.videoFailed = false; this.autoplay = true
|
||
return this.starting = this.action("start", { routeId: this.routeId, target: "web", expectedOwnerId: this.status.ownerId,
|
||
...(this.startSegment == null ? {} : { segment: this.startSegment }) }).finally(() => { this.starting = null })
|
||
}
|
||
control(action, value) {
|
||
this.autoplay = false
|
||
if (!this.ownedSession || this.status?.sessionId !== this.ownedSession) return
|
||
if (action === "seek") { if (!number(value)) return; value = replaySeekPosition(this.status, value) }
|
||
return this.action("control", { sessionId: this.ownedSession, expectedRevision: this.status.revision, action,
|
||
...(action === "pause" ? { paused: value } : action === "seek" ? { position: value } : { rate: value }) })
|
||
}
|
||
stop() { return this.ownedSession && this.action("stop", { sessionId: this.ownedSession }) }
|
||
async stopAndWait() {
|
||
this.stopping = true
|
||
this.retireMedia()
|
||
if (this.starting) await this.starting
|
||
const session = this.ownedSession
|
||
if (session) {
|
||
await this.stop()
|
||
const deadline = performance.now() + 6000
|
||
while (this.active && this.status?.sessionId === session) {
|
||
if (performance.now() >= deadline) throw new Error("Replay is still stopping. Wait a moment and try again.")
|
||
await new Promise(resolve => this.later(resolve, 150))
|
||
const status = await this.json(`status?clientId=${this.clientId}`)
|
||
if (!status || !validReplayStatus(status)) throw new Error("Could not confirm that replay stopped. Try again.")
|
||
this.receive(status)
|
||
}
|
||
if (!this.active) throw new Error("Replay disconnected before its shutdown was confirmed.")
|
||
}
|
||
this.close(false)
|
||
}
|
||
retireMedia() {
|
||
this.mediaGeneration++
|
||
this.mediaRequest?.abort(); this.mediaRequest = null
|
||
this.cancelTimer(this.mediaTimer)
|
||
this.rejectAppend?.(new Error("Replay video retired")); this.rejectAppend = null
|
||
this.source = this.buffer = null
|
||
const video = this.video()
|
||
if (video) {
|
||
if (this.mediaSeeking) video.removeEventListener("seeking", this.mediaSeeking)
|
||
video.pause(); video.removeAttribute("src"); video.load()
|
||
}
|
||
this.mediaSeeking = null
|
||
if (this.url) this.revokeURL(this.url)
|
||
this.url = null
|
||
}
|
||
beginMedia(status) {
|
||
this.retireMedia()
|
||
this.videoFailed = false; this.error = ""
|
||
if (!status.offroad || !status.codec || status.mediaReady === false) return
|
||
const mime = `video/mp4; codecs="${status.codec}"`
|
||
const sources = mediaSources(), Source = sources.find(Source => Source.isTypeSupported(mime))
|
||
if (!Source) {
|
||
this.videoFailed = true
|
||
this.error = sources.length ? "This browser does not support this replay's H.264 video. Update your browser or try another browser." : STREAMING_UNAVAILABLE
|
||
if (this.ownedSession === status.sessionId) this.sendStop(status.sessionId)
|
||
this.emit(); return
|
||
}
|
||
const generation = this.mediaGeneration
|
||
try {
|
||
const video = this.video(), source = this.source = this.mediaSource(Source)
|
||
const load = () => this.loadMedia(generation, status.sessionId, status.epoch)
|
||
this.mediaDemand = true
|
||
if (Source === globalThis.ManagedMediaSource) {
|
||
video.disableRemotePlayback = true
|
||
const resume = () => {
|
||
if (!this.active || generation !== this.mediaGeneration) return
|
||
this.mediaDemand = true
|
||
if (this.buffer) { this.cancelTimer(this.mediaTimer); load() }
|
||
}
|
||
source.addEventListener("startstreaming", resume)
|
||
source.addEventListener("endstreaming", () => {
|
||
if (!this.active || generation !== this.mediaGeneration || video.seeking) return
|
||
this.mediaDemand = false
|
||
if (this.buffer && !this.needsBuffer()) this.cancelTimer(this.mediaTimer)
|
||
})
|
||
// Safari can omit startstreaming after a seek; resume the existing loader explicitly.
|
||
video.addEventListener("seeking", this.mediaSeeking = resume)
|
||
}
|
||
source.addEventListener("sourceopen", () => {
|
||
if (!this.active || generation !== this.mediaGeneration) return
|
||
this.cancelTimer(this.mediaTimer)
|
||
try {
|
||
const buffer = this.buffer = source.addSourceBuffer(mime)
|
||
buffer.addEventListener("bufferedchange", event => {
|
||
if (!this.active || generation !== this.mediaGeneration || !event.removedRanges.length) return
|
||
for (let i = 0; i < event.removedRanges.length; i++) {
|
||
if (!buffer.buffered.length || event.removedRanges.start(i) <= video.currentTime && video.currentTime < event.removedRanges.end(i)) {
|
||
this.beginMedia(this.status); return
|
||
}
|
||
}
|
||
})
|
||
this.sequence = 0
|
||
this.nextFragment = status.media?.firstSequence || 1
|
||
load()
|
||
} catch (error) { this.videoFailed = true; this.mediaFailed(error, generation) }
|
||
}, { once: true })
|
||
this.url = this.createURL(source)
|
||
video.src = this.url
|
||
this.mediaTimer = this.later(() => {
|
||
if (!this.active || generation !== this.mediaGeneration) return
|
||
this.videoFailed = true
|
||
this.mediaFailed(new Error("Replay video could not open. Retry video or stop replay."), generation)
|
||
}, 4000)
|
||
} catch (error) { this.videoFailed = true; this.mediaFailed(error, generation) }
|
||
}
|
||
append(bytes, generation) { return this.updateBuffer(buffer => buffer.appendBuffer(bytes), generation) }
|
||
updateBuffer(change, generation) {
|
||
return new Promise((resolve, reject) => {
|
||
if (!this.buffer || generation !== this.mediaGeneration) { reject(new Error("Replay video retired")); return }
|
||
const buffer = this.buffer
|
||
const cleanup = () => { buffer.removeEventListener("updateend", done); buffer.removeEventListener("error", failed); if (this.rejectAppend === failed) this.rejectAppend = null }
|
||
const done = () => { cleanup(); resolve() }
|
||
const failed = () => { cleanup(); reject(new Error("Replay video could not decode.")) }
|
||
this.rejectAppend = failed
|
||
buffer.addEventListener("updateend", done, { once: true })
|
||
buffer.addEventListener("error", failed, { once: true })
|
||
try { change(buffer) } catch (error) { cleanup(); reject(error) }
|
||
})
|
||
}
|
||
async loadMedia(generation, sessionId, epoch) {
|
||
if (!this.active || generation !== this.mediaGeneration || !this.buffer || this.mediaRequest ||
|
||
this.source?.readyState === "ended" || this.mediaDemand === false && this.buffer.buffered.length && !this.needsBuffer()) return
|
||
const load = () => this.loadMedia(generation, sessionId, epoch)
|
||
this.cancelTimer(this.mediaTimer)
|
||
if (this.bufferAhead() >= MAX_BUFFER_AHEAD) { this.mediaTimer = this.later(load, MEDIA_INTERVAL); return }
|
||
const request = this.mediaRequest = new AbortController()
|
||
const timeout = this.later(() => request.abort(), 4000)
|
||
try {
|
||
const response = await this.fetcher(`./api/replay/media?sessionId=${sessionId}&clientId=${this.clientId}&epoch=${epoch}&sequence=${this.sequence}`, { credentials: "same-origin", cache: "no-store", signal: request.signal })
|
||
if (!this.active || generation !== this.mediaGeneration || this.source?.readyState === "ended") return
|
||
if (response.status === 401) { this.close(false); this.unauthorized(); return }
|
||
if (response.status === 202) { this.mediaTimer = this.later(load, this.status?.paused || this.status?.state === "ended" ? 500 : MEDIA_INTERVAL); return }
|
||
if (!response.ok) {
|
||
const error = await response.json().catch(() => ({}))
|
||
if (error.code === "media_evicted") { await this.refresh(); if (generation === this.mediaGeneration && this.status) this.beginMedia(this.status); return }
|
||
throw new Error(error.error || "Replay video is unavailable.")
|
||
}
|
||
if (response.headers.get("Content-Type")?.split(";")[0] !== "video/mp4" || Number(response.headers.get("X-Replay-Epoch")) !== epoch) throw new Error("Replay video epoch changed.")
|
||
const length = Number(response.headers.get("Content-Length"))
|
||
if (!integer(length) || length < 1 || length > 1024 * 1024) throw new Error("Replay video exceeded its media budget.")
|
||
const bytes = await response.arrayBuffer()
|
||
if (!this.active || generation !== this.mediaGeneration) return
|
||
if (bytes.byteLength !== length) throw new Error("Replay video was incomplete.")
|
||
await this.append(bytes, generation)
|
||
if (!this.active || generation !== this.mediaGeneration) return
|
||
const next = Number(response.headers.get("X-Replay-Next-Sequence"))
|
||
if (!integer(next) || next <= this.sequence) throw new Error("Replay video sequence is invalid.")
|
||
this.sequence = this.sequence === 0 ? this.nextFragment : next
|
||
const video = this.video(), buffer = this.buffer
|
||
if (buffer.buffered.length) {
|
||
// The owner starts paused so its first composed frame can reach the viewer.
|
||
if (this.autoplay) {
|
||
this.autoplay = false
|
||
if (this.status.paused && this.status.state !== "ended") await this.control("pause", false)
|
||
if (!this.active || generation !== this.mediaGeneration) return
|
||
}
|
||
const start = buffer.buffered.start(0), end = buffer.buffered.end(buffer.buffered.length - 1)
|
||
if (this.status.paused && this.status.state !== "ended" || this.bufferAhead() === 0 && video.currentTime < end) {
|
||
const position = replayBufferTime(buffer.buffered, this.status.paused && this.status.state !== "ended")
|
||
if (Math.abs(video.currentTime - position) > .001) video.currentTime = position
|
||
}
|
||
if (video.currentTime - start > 3) {
|
||
await this.updateBuffer(buffer => buffer.remove(start, video.currentTime - 2), generation)
|
||
}
|
||
}
|
||
this.playVideo()
|
||
this.emit()
|
||
if (this.source?.readyState !== "ended") this.mediaTimer = this.later(load, !this.buffer.buffered.length || this.needsBuffer() ? 0 : MEDIA_INTERVAL)
|
||
} catch (error) { this.mediaFailed(error, generation) }
|
||
finally { this.cancelTimer(timeout); if (this.mediaRequest === request) this.mediaRequest = null }
|
||
}
|
||
videoError() { this.videoFailed = true; this.mediaFailed(new Error("Replay video could not decode. Retry the video or stop replay."), this.mediaGeneration) }
|
||
mediaFailed(error, generation) {
|
||
if (!this.active || generation !== this.mediaGeneration) return
|
||
this.retireMedia(); this.error = error.message || "Replay video disconnected. Stop or retry the video."; this.emit()
|
||
}
|
||
sendStop(sessionId) {
|
||
this.fetcher("./api/replay/stop", { method: "POST", credentials: "same-origin", keepalive: true,
|
||
headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sessionId, clientId: this.clientId, requestId: replayToken() }) }).catch(() => {})
|
||
}
|
||
close(stop = true) {
|
||
this.resetTimeline()
|
||
if (stop && this.active && this.ownedSession) this.sendStop(this.ownedSession)
|
||
this.active = this.reading = this.autoplay = this.startAttempted = false; this.generation++
|
||
this.cancelTimer(this.timer)
|
||
for (const request of this.requests) if (request !== this.startRequest) request.abort()
|
||
this.requests.clear(); this.retireMedia(); this.ownedSession = null
|
||
}
|
||
}
|
||
|
||
export const ReplayViewer = {
|
||
components: { GxNotice, BluetoothNotice, GxState, GxIconButton },
|
||
props: { clientId: { type: String, required: true }, routeId: { type: String, required: true }, startSegment: { type: Number, default: null }, unauthorized: { type: Function, required: true } },
|
||
emits: ["segment", "busy"],
|
||
data: () => ({ bluetooth: bluetoothConnection(), client: null, replay: null, replayBusy: false, replayError: "", replayDetail: null, replayVideoReady: false, replayBuffering: false, seekPosition: null, seekGesture: null, replayTimeline: null, replayHistoryNotice: "" }),
|
||
computed: {
|
||
playing() { return this.client?.ownedSession === this.replay?.sessionId && !!this.replay?.sessionId && !TERMINAL.has(this.replay.state) },
|
||
controlsBusy() { return this.replayBusy || !this.replay?.offroad || ["preparing", "seeking", "stopping"].includes(this.replay.state) },
|
||
seekGaps() { return replaySeekGaps(this.replay) },
|
||
canStart() { return this.replay?.offroad && TERMINAL.has(this.replay.state) && !this.replayBusy && this.replayDetail?.playable === true },
|
||
historyEntry() { return this.replayTimeline?.entry(this.seekPosition ?? this.replay?.position) },
|
||
historyLabel() {
|
||
const position = this.seekPosition ?? this.replay?.position, ranges = this.replay?.seekRanges
|
||
if (ranges?.length && !ranges.some(([start, end]) => start <= position && position <= end)) return "Unavailable gap"
|
||
return this.historyEntry?.label || (this.replayTimeline?.rows ? "Neutral" : this.replayHistoryNotice || "Loading history")
|
||
},
|
||
seekProgress() {
|
||
const { start, end, position } = this.replay || {}
|
||
return end > start ? Math.max(0, Math.min(1, ((this.seekPosition ?? position) - start) / (end - start))) * 100 : 0
|
||
},
|
||
},
|
||
mounted() {
|
||
if (this.bluetooth) return
|
||
this.client = markRaw(new ReplayClient({ clientId: this.clientId, video: () => this.$refs.video, unauthorized: this.unauthorized, publish: update => {
|
||
Object.assign(this.$data, update)
|
||
this.$emit("busy", this.controlsBusy)
|
||
const current = update.replay?.segmentRanges?.find(([, start, end]) => start <= update.replay.position && update.replay.position < end)
|
||
if (this.playing && current && current[0] !== this.startSegment && !this.controlsBusy) this.$emit("segment", current[0])
|
||
} }))
|
||
this.visibility = () => { if (document.hidden) { this.client.close() } }
|
||
document.addEventListener("visibilitychange", this.visibility)
|
||
this.resizeRail = () => this.drawRail(true)
|
||
this.railObserver = new ResizeObserver(this.resizeRail)
|
||
this.railObserver.observe(this.$refs.player)
|
||
window.addEventListener("resize", this.resizeRail)
|
||
this.client.open(this.routeId, this.startSegment)
|
||
},
|
||
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); window.removeEventListener("resize", this.resizeRail); this.railObserver?.disconnect(); this.client?.close() },
|
||
methods: {
|
||
replayTime,
|
||
drawRail(force = false) {
|
||
const canvas = this.$refs.timeline
|
||
if (!canvas || !this.replay) return
|
||
const key = JSON.stringify([this.replay.start, this.replay.end, this.replay.seekRanges])
|
||
if (!force && this.railHistory === this.replayTimeline && this.railRange === key) return
|
||
this.railHistory = this.replayTimeline; this.railRange = key
|
||
drawReplayTimeline(canvas, this.replay, this.replayTimeline)
|
||
},
|
||
stopAndWait() { return this.client.stopAndWait() },
|
||
close() { return this.client?.stop() },
|
||
seek(value) { this.seekPosition = null; this.client.control("seek", Number(value)) },
|
||
beginSeek(event) {
|
||
if (!event.isPrimary || event.button !== 0) return
|
||
this.seekGesture = { pointer: event.pointerId, session: this.replay.sessionId, epoch: this.replay.epoch, cancelled: false }
|
||
this.seekPosition = Number(event.currentTarget.value)
|
||
event.currentTarget.setPointerCapture(event.pointerId)
|
||
},
|
||
previewSeek(event) { if (!this.seekGesture?.cancelled) this.seekPosition = Number(event.currentTarget.value) },
|
||
commitSeek(event) {
|
||
const gesture = this.seekGesture
|
||
if (gesture && (gesture.session !== this.replay.sessionId || gesture.epoch !== this.replay.epoch)) this.cancelSeek()
|
||
if (this.seekPosition !== null && !this.controlsBusy) this.seek(event.currentTarget.value)
|
||
},
|
||
endSeek(event) {
|
||
if (this.seekGesture?.pointer !== event.pointerId) return
|
||
if (event.type === "pointerup") this.commitSeek(event)
|
||
this.cancelSeek(); this.seekGesture = null
|
||
},
|
||
cancelSeek() { this.seekPosition = null; if (this.seekGesture) this.seekGesture.cancelled = true },
|
||
cycleRate() {
|
||
const rates = [.5, 1, 2]
|
||
this.client.control("rate", rates[(rates.indexOf(this.replay.rate) + 1) % rates.length])
|
||
},
|
||
fullscreen() { return document.fullscreenElement === this.$refs.player ? document.exitFullscreen() : this.$refs.player.requestFullscreen?.() },
|
||
},
|
||
watch: {
|
||
replayTimeline() { this.$nextTick(() => this.drawRail(true)) },
|
||
playing() { this.cancelSeek(); this.$nextTick(() => this.drawRail(true)) },
|
||
controlsBusy(value) { if (value) this.cancelSeek() },
|
||
'replay.epoch'() { this.cancelSeek() },
|
||
'replay.sessionId'() { this.cancelSeek() },
|
||
'replay.seekRanges'() { this.$nextTick(() => this.drawRail()) },
|
||
startSegment(value) {
|
||
if (!this.client) return
|
||
this.client.startSegment = value
|
||
const range = this.replay?.segmentRanges?.find(row => row[0] === value)
|
||
if (this.playing && range && !(range[1] <= this.replay.position && this.replay.position < range[2])) this.seek(range[1])
|
||
},
|
||
},
|
||
template: `<div class="gx-replay">
|
||
<BluetoothNotice v-if="bluetooth" feature="Route replay and video" />
|
||
<template v-else>
|
||
<div ref="player" class="gx-replay__player">
|
||
<div class="gx-replay__screen">
|
||
<video ref="video" class="gx-replay__video" playsinline muted aria-label="Recorded route with driving interface" @waiting="client?.videoWaiting()" @error="client?.videoError()"></video>
|
||
<GxState class="gx-replay__loading" v-if="!replayError && (!replay || replayBuffering || !replayVideoReady && (playing || replayBusy || !replayDetail) || ['preparing', 'seeking'].includes(replay.state))" loading>{{ replay?.state === 'seeking' ? 'Seeking recorded route…' : replayBuffering ? 'Buffering replay…' : 'Loading replay…' }}</GxState>
|
||
<GxIconButton v-if="playing" class="gx-replay__fullscreen" label="Fullscreen video" icon="bi-fullscreen" @click="fullscreen" />
|
||
</div>
|
||
<div v-if="playing" class="gx-replay__media-controls">
|
||
<label class="gx-replay__timeline">
|
||
<span>{{ replayTime((seekPosition ?? replay.position)-replay.start) }} / {{ replayTime(replay.end-replay.start) }} · {{ historyLabel }}</span>
|
||
<span class="gx-replay__track">
|
||
<span class="gx-replay__rail" :style="{'--replay-position': seekProgress + '%'}" aria-hidden="true">
|
||
<canvas ref="timeline"></canvas><span class="gx-replay__playhead"></span>
|
||
<template v-if="seekPosition !== null"><span class="gx-replay__preview-line"></span><span class="gx-replay__preview"><i :style="historyEntry ? {backgroundColor: 'rgb(' + historyEntry.rgb.join(' ') + ')'} : {}"></i><span>{{ replayTime(seekPosition-replay.start) }} · {{ historyLabel }}</span></span></template>
|
||
</span>
|
||
<input type="range" aria-label="Replay route position" :aria-valuetext="replayTime((seekPosition ?? replay.position)-replay.start) + ', ' + historyLabel" :min="replay.start" :max="replay.end" step="0.05" :value="seekPosition ?? replay.position" :disabled="controlsBusy" @pointerdown="beginSeek" @pointerup="endSeek" @pointercancel="endSeek" @lostpointercapture="endSeek" @blur="cancelSeek" @keydown.esc.prevent="cancelSeek" @input="previewSeek" @change="commitSeek" />
|
||
</span>
|
||
</label>
|
||
<div class="gx-replay__controls">
|
||
<button type="button" class="gx-btn" aria-label="Back ten seconds" :disabled="controlsBusy" @click="seek(replay.position-10)">-10 s</button>
|
||
<button v-if="replay.state === 'ended'" type="button" class="gx-btn" aria-label="Return to start" :disabled="controlsBusy" @click="seek(replay.start)">Restart</button>
|
||
<button v-else type="button" class="gx-btn" :disabled="controlsBusy" @click="client.control('pause', !replay.paused)">{{ replay.paused ? 'Play' : 'Pause' }}</button>
|
||
<button type="button" class="gx-btn" aria-label="Forward ten seconds" :disabled="controlsBusy" @click="seek(replay.position+10)">+10 s</button>
|
||
<button type="button" class="gx-btn" aria-label="Replay speed" :disabled="controlsBusy" @click="cycleRate">{{ replay.rate }}x</button>
|
||
<button type="button" class="gx-btn" :disabled="replayBusy" @click="close">Stop</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<GxNotice v-if="replayError" tone="danger">{{ replayError }}<template #actions><button v-if="!playing" class="gx-btn gx-btn--tonal" :disabled="replayBusy" @click="client.open(routeId, startSegment)">Retry replay</button><button v-else class="gx-btn gx-btn--tonal" :disabled="controlsBusy || !replay.codec || replay.mediaReady === false" @click="client.beginMedia(replay)">Retry video</button></template></GxNotice>
|
||
<GxNotice v-if="replay && !replay.offroad" tone="warn">Turn off the vehicle before replaying a drive.</GxNotice>
|
||
<GxNotice v-if="replay?.errorCode" tone="warn">Replay stopped: {{ replay.errorCode.replaceAll('_', ' ') }}</GxNotice>
|
||
<GxNotice v-if="replay?.sessionId && !playing && !canStart" tone="info">Another replay is active. Close it in its viewer before starting this route.</GxNotice>
|
||
<p v-if="replayDetail?.reason" class="gx-note">{{ replayDetail.reason }}</p>
|
||
<details v-if="replayTimeline" class="gx-route-workspace__help"><summary>Timeline colors</summary><p class="gx-replay__legend"><span v-for="(item, kind) in replayTimeline.legend" :key="kind"><i :style="{backgroundColor: 'rgb(' + item.rgb.join(' ') + ')'}" aria-hidden="true"></i>{{ item.label }}</span></p><p v-if="replayTimeline.source !== 'rlog'" class="gx-note">Sampled history; brief events may be missing.</p></details>
|
||
<details v-if="playing && seekGaps.length" class="gx-route-workspace__help gx-replay__gaps"><summary>{{ seekGaps.length }} missing recording {{ seekGaps.length === 1 ? 'range' : 'ranges' }}</summary><p class="gx-note">Missing recordings: <span v-for="(gap, i) in seekGaps" :key="i">{{ i ? ", " : "" }}{{ replayTime(gap[0]-replay.start) }}–{{ replayTime(gap[1]-replay.start) }}</span>. Seeks move to the nearest recorded position.</p></details>
|
||
<button v-if="canStart && !replayError" class="gx-btn gx-btn--tonal" @click="client.open(routeId, startSegment)">Play replay</button>
|
||
</template>
|
||
</div>`,
|
||
}
|