cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix/website-download-wiring
goq / portal / decoder-recovery.mjs
decoder-recovery.mjs 3.8K
1export const DECODER_RECOVERY_REASONS = Object.freeze({2 TRANSPORT_GAP: 'transport-gap',3 DISCONTINUITY: 'discontinuity',4 DELIVERY_TIMEOUT: 'delivery-timeout',5 DECODER_RESET: 'decoder-reset',6 DECODER_ERROR: 'decoder-error',7 FRONTEND_BACKPRESSURE: 'frontend-backpressure',8});910const VALID_REASONS = new Set(Object.values(DECODER_RECOVERY_REASONS));1112function validReason(reason) {13 if (!VALID_REASONS.has(reason)) {14 throw new TypeError(`unsupported decoder recovery reason: ${reason}`);15 }16 return reason;17}1819/**20 * Owns the small state machine between partial media delivery and WebCodecs.21 *22 * Entering recovery issues at most one keyframe request for that episode.23 * Receiving a keyframe does not itself end the episode: the caller must first24 * configure the decoder and enqueue the keyframe successfully. This keeps a25 * synchronous decode failure from admitting dependent delta frames.26 */27export class DecoderRecoveryState {28 #recovering;29 #requestIssued = false;30 #reason = null;31 #onKeyframeRequest;3233 constructor({ initiallyRecovering = true, onKeyframeRequest = () => {} } = {}) {34 if (typeof initiallyRecovering !== 'boolean') {35 throw new TypeError('initiallyRecovering must be a boolean');36 }37 if (typeof onKeyframeRequest !== 'function') {38 throw new TypeError('onKeyframeRequest must be a function');39 }40 this.#recovering = initiallyRecovering;41 this.#onKeyframeRequest = onKeyframeRequest;42 }4344 get recovering() {45 return this.#recovering;46 }4748 get reason() {49 return this.#reason;50 }5152 get requestIssued() {53 return this.#requestIssued;54 }5556 /** Enter recovery, coalescing with an already active recovery episode. */57 enter(reason, { requestKeyframe = true } = {}) {58 validReason(reason);59 if (typeof requestKeyframe !== 'boolean') {60 throw new TypeError('requestKeyframe must be a boolean');61 }62 const entered = !this.#recovering;63 if (entered) {64 this.#recovering = true;65 this.#requestIssued = false;66 this.#reason = reason;67 } else if (this.#reason === null) {68 this.#reason = reason;69 }7071 let requested = false;72 if (requestKeyframe && !this.#requestIssued) {73 // Mark the request before invoking user code. Even if that code throws,74 // the same recovery episode must not create an unbounded retry loop.75 this.#requestIssued = true;76 requested = true;77 this.#onKeyframeRequest(reason);78 }79 return { entered, requested };80 }8182 /**83 * Start an explicitly new recovery episode, even if the previous episode84 * has not recovered. This is the deliberate retry boundary after an85 * external reset; ordinary repeated loss signals should use enter().86 */87 restart(reason) {88 validReason(reason);89 this.#recovering = true;90 this.#requestIssued = false;91 this.#reason = reason;92 const result = this.enter(reason);93 return { ...result, entered: true };94 }9596 /** Reset session-local state without requesting from a not-yet-live host. */97 reset({ initiallyRecovering = true } = {}) {98 if (typeof initiallyRecovering !== 'boolean') {99 throw new TypeError('initiallyRecovering must be a boolean');100 }101 this.#recovering = initiallyRecovering;102 this.#requestIssued = false;103 this.#reason = null;104 }105106 shouldDropFrame({ keyframe }) {107 if (typeof keyframe !== 'boolean') throw new TypeError('keyframe must be a boolean');108 return this.#recovering && !keyframe;109 }110111 /**112 * Confirm the result of configuring/enqueuing a candidate recovery113 * keyframe. Failure intentionally stays in the same coalesced episode.114 */115 confirmKeyframeEnqueued(succeeded) {116 if (typeof succeeded !== 'boolean') throw new TypeError('succeeded must be a boolean');117 if (!this.#recovering || !succeeded) return false;118 this.#recovering = false;119 this.#requestIssued = false;120 this.#reason = null;121 return true;122 }123}