4 Commits

11 changed files with 114 additions and 23 deletions

View File

@@ -19,6 +19,7 @@
"svelte-check": "^4.4.2",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vidstack": "^1.12.13",
"vite": "^7.3.1",
"vite-plugin-devtools-json": "^1.0.0",
"wrangler": "^4.81.1",
@@ -98,6 +99,12 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
"@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
"@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
@@ -342,10 +349,14 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"lit-html": ["lit-html@2.8.0", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"media-captions": ["media-captions@1.0.4", "", {}, "sha512-cyDNmuZvvO4H27rcBq2Eudxo9IZRDCOX/I7VEyqbxsEiD2Ei7UYUhG/Sc5fvMZjmathgz3fEK7iAKqvpY+Ux1w=="],
"miniflare": ["miniflare@4.20260409.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260409.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-ayl6To4av0YuXsSivGgWLj+Ug8xZ0Qz3sGV8+Ok2LhNVl6m8m5ktEBM3LX9iT9MtLZRJwBlJrKcraNs/DlZQfA=="],
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
@@ -424,16 +435,22 @@
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
"unplugin": ["unplugin@1.16.1", "", { "dependencies": { "acorn": "^8.14.0", "webpack-virtual-modules": "^0.6.2" } }, "sha512-4/u/j4FrCKdi17jaxuJA0jClGxB1AvU2hw/IuayPc4ay1XGaJs/rbb4v5WKwAjNifjmXK9PIFyuPiaK8azyR9w=="],
"url-polyfill": ["url-polyfill@1.1.14", "", {}, "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ=="],
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
"vidstack": ["vidstack@1.12.13", "", { "dependencies": { "@floating-ui/dom": "^1.6.10", "lit-html": "^2.8.0", "media-captions": "^1.0.4", "unplugin": "^1.12.0" } }, "sha512-vuNeyRmWoH/7EoFVDYjp9nkgcqtCMmal518LDeb78dYKgWb+p6+vtY0AzDhrkBv5q1UiCn+xwmjMmwvSlPLuhQ=="],
"vite": ["vite@7.3.2", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg=="],
"vite-plugin-devtools-json": ["vite-plugin-devtools-json@1.0.0", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-MobvwqX76Vqt/O4AbnNMNWoXWGrKUqZbphCUle/J2KXH82yKQiunOeKnz/nqEPosPsoWWPP9FtNuPBSYpiiwkw=="],
"vitefu": ["vitefu@1.1.3", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["vite"] }, "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"workerd": ["workerd@1.20260409.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260409.1", "@cloudflare/workerd-darwin-arm64": "1.20260409.1", "@cloudflare/workerd-linux-64": "1.20260409.1", "@cloudflare/workerd-linux-arm64": "1.20260409.1", "@cloudflare/workerd-windows-64": "1.20260409.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-kuWP20fAaqaLBqLbvUfY9nCF6c3C78L60G9lS6eVwBf+v8trVFIsAdLB/FtrnKm7vgVvpDzvFAfB80VIiVj95w=="],
"worktop": ["worktop@0.8.0-next.18", "", { "dependencies": { "mrmime": "^2.0.0", "regexparam": "^3.0.0" } }, "sha512-+TvsA6VAVoMC3XDKR5MoC/qlLqDixEfOBysDEKnPIPou/NvoPWCAuXHXMsswwlvmEuvX56lQjvELLyLuzTKvRw=="],

View File

@@ -33,6 +33,7 @@
"svelte-check": "^4.4.2",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vidstack": "^1.12.13",
"vite": "^7.3.1",
"vite-plugin-devtools-json": "^1.0.0",
"wrangler": "^4.81.1"

View File

@@ -6,6 +6,7 @@ const spacetimedb = schema({
{
id: t.u64().primaryKey(),
url: t.string(),
subtitleUrl: t.string(),
timePosition: t.f64(),
isPlaying: t.bool(),
lastUpdatedAt: t.timestamp(),
@@ -19,6 +20,7 @@ export const init = spacetimedb.init((ctx) => {
ctx.db.videoState.insert({
id: 1n,
url: "https://cdn.cazzzer.com/LycoReco08.mkv",
subtitleUrl: "",
timePosition: 0.0,
isPlaying: false,
lastUpdatedAt: ctx.timestamp,
@@ -34,6 +36,7 @@ export const set_url = spacetimedb.reducer({ url: t.string() }, (ctx, { url }) =
ctx.db.videoState.id.update({
...row,
url,
subtitleUrl: "", // Clear subtitle on new video
timePosition: 0.0,
isPlaying: false,
lastUpdatedAt: ctx.timestamp,
@@ -71,3 +74,13 @@ export const seek = spacetimedb.reducer({ time_position: t.f64() }, (ctx, { time
lastUpdatedAt: ctx.timestamp,
});
});
export const set_subtitle_url = spacetimedb.reducer({ url: t.string() }, (ctx, { url }) => {
const row = ctx.db.videoState.id.find(1n);
if (!row) return;
ctx.db.videoState.id.update({
...row,
subtitleUrl: url,
lastUpdatedAt: ctx.timestamp,
});
});

View File

@@ -37,6 +37,7 @@ import {
import PauseReducer from "./pause_reducer";
import PlayReducer from "./play_reducer";
import SeekReducer from "./seek_reducer";
import SetSubtitleUrlReducer from "./set_subtitle_url_reducer";
import SetUrlReducer from "./set_url_reducer";
// Import all procedure arg schemas
@@ -66,6 +67,7 @@ const reducersSchema = __reducers(
__reducerSchema("pause", PauseReducer),
__reducerSchema("play", PlayReducer),
__reducerSchema("seek", SeekReducer),
__reducerSchema("set_subtitle_url", SetSubtitleUrlReducer),
__reducerSchema("set_url", SetUrlReducer),
);

View File

@@ -0,0 +1,15 @@
// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE
// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD.
/* eslint-disable */
/* tslint:disable */
import {
TypeBuilder as __TypeBuilder,
t as __t,
type AlgebraicTypeType as __AlgebraicTypeType,
type Infer as __Infer,
} from "spacetimedb";
export default {
url: __t.string(),
};

View File

@@ -13,6 +13,7 @@ import {
export const VideoState = __t.object("VideoState", {
id: __t.u64(),
url: __t.string(),
subtitleUrl: __t.string(),
timePosition: __t.f64(),
isPlaying: __t.bool(),
lastUpdatedAt: __t.timestamp(),

View File

@@ -9,10 +9,12 @@ import { type Infer as __Infer } from "spacetimedb";
import PauseReducer from "../pause_reducer";
import PlayReducer from "../play_reducer";
import SeekReducer from "../seek_reducer";
import SetSubtitleUrlReducer from "../set_subtitle_url_reducer";
import SetUrlReducer from "../set_url_reducer";
export type PauseParams = __Infer<typeof PauseReducer>;
export type PlayParams = __Infer<typeof PlayReducer>;
export type SeekParams = __Infer<typeof SeekReducer>;
export type SetSubtitleUrlParams = __Infer<typeof SetSubtitleUrlReducer>;
export type SetUrlParams = __Infer<typeof SetUrlReducer>;

View File

@@ -13,6 +13,7 @@ import {
export default __t.row({
id: __t.u64().primaryKey(),
url: __t.string(),
subtitleUrl: __t.string().name("subtitle_url"),
timePosition: __t.f64().name("time_position"),
isPlaying: __t.bool().name("is_playing"),
lastUpdatedAt: __t.timestamp().name("last_updated_at"),

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import { useSpacetimeDB, useTable, useReducer } from "spacetimedb/svelte";
import { tables, reducers } from "$lib/st-bindings";
import "vidstack/bundle";
import type { MediaPlayerElement } from "vidstack/elements";
const conn = useSpacetimeDB();
@@ -8,12 +10,14 @@
const videoState = $derived($videoStates.find((state) => state.id === 1n));
const setUrlReducer = useReducer(reducers.setUrl);
const setSubtitleUrlReducer = useReducer(reducers.setSubtitleUrl);
const playReducer = useReducer(reducers.play);
const pauseReducer = useReducer(reducers.pause);
const seekReducer = useReducer(reducers.seek);
let videoElement: HTMLVideoElement | undefined = $state();
let videoElement: MediaPlayerElement | undefined = $state();
let newUrl = $state("");
let newSubtitleUrl = $state("");
let isSyncing = false;
let syncTimeout: ReturnType<typeof setTimeout> | undefined;
@@ -32,10 +36,6 @@
const el = videoElement;
const state = videoState;
if (el.src !== state.url) {
el.src = state.url;
}
// Account for server to client clock drift slightly, but MVP assumes relatively synced clocks.
const timeElapsedMicros = BigInt(Date.now()) * 1000n - state.lastUpdatedAt.microsSinceUnixEpoch;
const expectedTime = state.isPlaying
@@ -45,8 +45,9 @@
const diff = Math.abs(el.currentTime - expectedTime);
if (!state.isPlaying) {
if (el.currentTime < expectedTime && diff <= 2 && !el.paused) {
if (el.currentTime < expectedTime && diff <= 2) {
// Behind pause point. Keep playing to catch up. (timeupdate will pause it)
// If locally paused, stay paused (we likely dispatched the pause event + buffer)
} else {
// Ahead or reached pause point. Pause and rewind if needed.
syncAction(() => {
@@ -72,12 +73,14 @@
function handlePlay() {
if (isSyncing) return;
playReducer({ timePosition: videoElement?.currentTime ?? 0 });
// Send play slightly in future to adjust for network latency
playReducer({ timePosition: (videoElement?.currentTime ?? 0) + 0.15 });
}
function handlePause() {
if (isSyncing) return;
pauseReducer({ timePosition: videoElement?.currentTime ?? 0 });
// Send pause slightly in future, so remote clients coast into it
pauseReducer({ timePosition: (videoElement?.currentTime ?? 0) + 0.15 });
}
function handleSeeked() {
@@ -108,6 +111,13 @@
setUrlReducer({ url: newUrl });
newUrl = "";
}
function handleSetSubtitle(e: SubmitEvent) {
e.preventDefault();
if (!newSubtitleUrl.trim() || !$conn.isActive) return;
setSubtitleUrlReducer({ url: newSubtitleUrl });
newSubtitleUrl = "";
}
</script>
<div class="p-4">
@@ -131,18 +141,42 @@
<button type="submit" class="p-2" disabled={!$conn.isActive}>Set URL</button>
</form>
<form onsubmit={handleSetSubtitle} class="mb-2">
<input
type="url"
placeholder="Enter subtitle track URL (.vtt)"
bind:value={newSubtitleUrl}
class="mr-2 w-96 p-2"
/>
<button type="submit" class="p-2">Set Subtitles</button>
</form>
<div>
<!-- svelte-ignore a11y_media_has_caption -->
<video
bind:this={videoElement}
controls
onplay={handlePlay}
onpause={handlePause}
onseeked={handleSeeked}
ontimeupdate={handleTimeUpdate}
class="w-full max-w-2xl bg-black"
>
Your browser does not support the video tag.
</video>
{#if videoState?.url}
<media-player
bind:this={videoElement}
src={videoState.url}
muted
crossOrigin
onplay={handlePlay}
onpause={handlePause}
onseeked={handleSeeked}
ontimeupdate={handleTimeUpdate}
class="w-full max-w-2xl bg-black"
>
<media-provider>
{#if videoState?.subtitleUrl}
<track
src={videoState.subtitleUrl}
kind="subtitles"
srclang="en"
label="English"
default
/>
{/if}
</media-provider>
<media-video-layout> </media-video-layout>
</media-player>
{/if}
</div>
</div>

View File

@@ -11,7 +11,11 @@
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler",
"types": ["./src/worker-configuration.d.ts", "node"]
"types": [
"./src/worker-configuration.d.ts",
"node",
"vidstack/svelte",
]
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files

View File

@@ -2,5 +2,6 @@ import devtoolsJson from "vite-plugin-devtools-json";
import tailwindcss from "@tailwindcss/vite";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
import { vite as vidstack } from "vidstack/plugins";
export default defineConfig({ plugins: [tailwindcss(), sveltekit(), devtoolsJson()] });
export default defineConfig({ plugins: [tailwindcss(), vidstack(), sveltekit(), devtoolsJson()] });