Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 10 additions & 10 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion jsr.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@typetype/mse",
"version": "0.1.43",
"version": "0.1.44",
"exports": "./src/index.ts",
"publish": {
"include": ["LICENSE", "README.md", "src/**/*.ts"]
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@typetype/mse",
"version": "0.1.43",
"version": "0.1.44",
"description": "MSE playback engine for TypeType",
"license": "MIT",
"type": "module",
Expand Down Expand Up @@ -39,7 +39,7 @@
"publish:jsr": "deno publish"
},
"devDependencies": {
"@biomejs/biome": "^2.5.6",
"@biomejs/biome": "^2.5.7",
"typescript": "~7.0.2"
},
"publishConfig": {
Expand Down
58 changes: 50 additions & 8 deletions src/decode-preroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ const MAX_PREROLL_TIMEOUT_MS = 15_000;
const SNAP_TIMEOUT_MS = 2_000;
const SNAP_TOLERANCE_MS = 20;

class TargetSnapTimeoutError extends Error {
constructor() {
super("Seek target snap timed out");
this.name = "TargetSnapTimeoutError";
}
}

export function decodeStartMs(manifest: PlaybackManifest, targetMs: number): number {
if (!manifest.video) return targetMs;
const audio = manifest.audio.segments.find(
Expand All @@ -36,13 +43,33 @@ export async function runDecodePreroll(
const distanceMs = Math.abs(video.currentTime * 1000 - targetMs);
const exact = distanceMs <= SNAP_TOLERANCE_MS;
const resumeWithinTolerance = resumePlayback && distanceMs <= TARGET_TOLERANCE_MS;
if (!exact && !resumeWithinTolerance) await snapToTarget(video, targetMs, signal);
if (resumePlayback && video.paused) {
let resumeAttempted = false;
if (!exact && !resumeWithinTolerance) {
resumeAttempted = await snapToTarget(video, targetMs, signal, resumePlayback);
}
if (resumePlayback && !resumeAttempted && video.paused) {
await tryResumePlayback(video);
ensureNotAborted(signal);
}
return;
}
const decodeStartSeconds = video.currentTime;
if (!requiresDecodePreroll(video)) {
try {
const resumeAttempted = await snapToTarget(video, targetMs, signal, resumePlayback);
if (resumePlayback) {
if (!resumeAttempted && video.paused) await tryResumePlayback(video);
} else {
video.pause();
}
ensureNotAborted(signal);
return;
} catch (error) {
if (!(error instanceof TargetSnapTimeoutError)) throw error;
video.pause();
video.currentTime = decodeStartSeconds;
}
}
const restoreMediaState = transientState.begin();
let pausedForSnap = false;
try {
Expand All @@ -57,31 +84,46 @@ export async function runDecodePreroll(
restoreMediaState();
if (!resumePlayback) {
if (!pausedForSnap) video.pause();
} else if (!signal.aborted) {
} else if (!signal.aborted && video.paused) {
await tryResumePlayback(video);
ensureNotAborted(signal);
}
}
}

function snapToTarget(
function requiresDecodePreroll(video: HTMLVideoElement): boolean {
const webkitVideo = video as HTMLVideoElement & { webkitSupportsFullscreen?: boolean };
return typeof webkitVideo.webkitSupportsFullscreen === "boolean";
}

async function snapToTarget(
video: HTMLVideoElement,
targetMs: number,
signal: AbortSignal,
): Promise<void> {
resumePlayback = false,
): Promise<boolean> {
ensureNotAborted(signal);
const exact = Math.abs(video.currentTime * 1000 - targetMs) <= SNAP_TOLERANCE_MS;
if (exact && video.readyState >= HAVE_CURRENT_DATA) return Promise.resolve();
if (exact && !video.seeking && video.readyState >= HAVE_CURRENT_DATA) return false;
if (resumePlayback && !video.paused) video.pause();
video.currentTime = targetMs / 1000;
let resumeAttempted = false;
if (resumePlayback && video.paused) {
resumeAttempted = true;
await tryResumePlayback(video);
ensureNotAborted(signal);
}
return new Promise((resolve, reject) => {
const startedAt = performance.now();
const poll = () => {
if (signal.aborted) return reject(new DOMException("Operation aborted", "AbortError"));
if (video.error) return reject(new Error(video.error.message));
const exact = Math.abs(video.currentTime * 1000 - targetMs) <= SNAP_TOLERANCE_MS;
if (exact && video.readyState >= HAVE_CURRENT_DATA) return resolve();
if (exact && !video.seeking && video.readyState >= HAVE_CURRENT_DATA) {
return resolve(resumeAttempted);
}
if (performance.now() - startedAt >= SNAP_TIMEOUT_MS)
return reject(new Error("Seek target snap timed out"));
return reject(new TargetSnapTimeoutError());
setTimeout(poll, 10);
};
poll();
Expand Down
4 changes: 4 additions & 0 deletions src/transient-media-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ type MediaStateSnapshot = {
autoplay: boolean;
defaultPlaybackRate: number;
muted: boolean;
opacity: string | null;
playbackRate: number;
};

Expand All @@ -27,6 +28,7 @@ export class TransientMediaState {
autoplay: this.video.autoplay,
defaultPlaybackRate: this.video.defaultPlaybackRate,
muted: this.video.muted,
opacity: this.video.style?.opacity ?? null,
playbackRate: this.video.playbackRate,
};
this.video.defaultPlaybackRate = this.snapshot.playbackRate;
Expand All @@ -38,6 +40,7 @@ export class TransientMediaState {
begin(): () => void {
const restore = this.preserve();
this.video.muted = true;
if (this.video.style) this.video.style.opacity = "0";
this.video.playbackRate = 16;
this.video.autoplay = true;
return restore;
Expand All @@ -49,6 +52,7 @@ export class TransientMediaState {
this.video.defaultPlaybackRate = snapshot.defaultPlaybackRate;
this.video.playbackRate = snapshot.playbackRate;
this.video.muted = snapshot.muted;
if (snapshot.opacity !== null && this.video.style) this.video.style.opacity = snapshot.opacity;
this.video.autoplay = snapshot.autoplay;
this.snapshot = null;
this.revision += 1;
Expand Down
13 changes: 11 additions & 2 deletions src/type-type-mse-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,13 @@ import type {
/** Starts or resumes playback after loading. */ async play(): Promise<void> {
ensurePlayerAlive(this.destroyed);
this.playbackIntent.play();
if (!this.session || this.playerState.value === "loading") return;
if (
!this.session ||
this.playerState.value === "loading" ||
this.playerState.value === "seeking"
) {
return;
}
if (this.pendingPrerollTargetMs !== null) {
const targetMs = this.pendingPrerollTargetMs;
await this.runDecodePreroll(targetMs, true, this.operation.signal);
Expand All @@ -163,7 +169,9 @@ import type {
/** Pauses playback while preserving the current session and buffer. */ pause(): void {
this.playbackIntent.pause();
this.video.pause();
this.playerState.set("ready");
if (this.playerState.value !== "loading" && this.playerState.value !== "seeking") {
this.playerState.set("ready");
}
}
/** Seeks to a millisecond position without replacing the media element. */
async seek(positionMs: number): Promise<void> {
Expand Down Expand Up @@ -301,6 +309,7 @@ import type {
this.operation.ensureCurrent(this.destroyed, revision);
}
this.playerState.set("seeking");
if (!quality) this.video.pause();
this.emitter.emit({ type: "seek", positionMs: targetMs });
try {
const response = await this.deps.playback.seek(
Expand Down
Loading