video player

This commit is contained in:
2026-04-13 01:24:13 -07:00
parent c7c6d1e6b5
commit f90bf91fd4
11 changed files with 219 additions and 67 deletions

View File

@@ -1,34 +1,73 @@
import { schema, table, t } from "spacetimedb/server"; import { schema, table, t } from "spacetimedb/server";
const spacetimedb = schema({ const spacetimedb = schema({
person: table( videoState: table(
{ public: true }, { public: true },
{ {
name: t.string(), id: t.u64().primaryKey(),
url: t.string(),
timePosition: t.f64(),
isPlaying: t.bool(),
lastUpdatedAt: t.timestamp(),
}, },
), ),
}); });
export default spacetimedb; export default spacetimedb;
export const init = spacetimedb.init((_ctx) => { export const init = spacetimedb.init((ctx) => {
// Called when the module is initially published // Insert initial video state row on first deploy
ctx.db.videoState.insert({
id: 1n,
url: "https://cdn.cazzzer.com/LycoReco08.mkv",
timePosition: 0.0,
isPlaying: false,
lastUpdatedAt: ctx.timestamp,
});
}); });
export const onConnect = spacetimedb.clientConnected((_ctx) => { export const onConnect = spacetimedb.clientConnected((_ctx) => {});
// Called every time a new client connects export const onDisconnect = spacetimedb.clientDisconnected((_ctx) => {});
export const set_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,
url,
timePosition: 0.0,
isPlaying: false,
lastUpdatedAt: ctx.timestamp,
});
}); });
export const onDisconnect = spacetimedb.clientDisconnected((_ctx) => { export const play = spacetimedb.reducer({ time_position: t.f64() }, (ctx, { time_position }) => {
// Called every time a client disconnects const row = ctx.db.videoState.id.find(1n);
if (!row) return;
ctx.db.videoState.id.update({
...row,
timePosition: time_position,
isPlaying: true,
lastUpdatedAt: ctx.timestamp,
});
}); });
export const add = spacetimedb.reducer({ name: t.string() }, (ctx, { name }) => { export const pause = spacetimedb.reducer({ time_position: t.f64() }, (ctx, { time_position }) => {
ctx.db.person.insert({ name }); const row = ctx.db.videoState.id.find(1n);
if (!row) return;
ctx.db.videoState.id.update({
...row,
timePosition: time_position,
isPlaying: false,
lastUpdatedAt: ctx.timestamp,
});
}); });
export const sayHello = spacetimedb.reducer((ctx) => { export const seek = spacetimedb.reducer({ time_position: t.f64() }, (ctx, { time_position }) => {
for (const person of ctx.db.person.iter()) { const row = ctx.db.videoState.id.find(1n);
console.info(`Hello, ${person.name}!`); if (!row) return;
} ctx.db.videoState.id.update({
console.info("Hello, World!"); ...row,
timePosition: time_position,
lastUpdatedAt: ctx.timestamp,
});
}); });

View File

@@ -34,31 +34,39 @@ import {
} from "spacetimedb"; } from "spacetimedb";
// Import all reducer arg schemas // Import all reducer arg schemas
import AddReducer from "./add_reducer"; import PauseReducer from "./pause_reducer";
import SayHelloReducer from "./say_hello_reducer"; import PlayReducer from "./play_reducer";
import SeekReducer from "./seek_reducer";
import SetUrlReducer from "./set_url_reducer";
// Import all procedure arg schemas // Import all procedure arg schemas
// Import all table schema definitions // Import all table schema definitions
import PersonRow from "./person_table"; import VideoStateRow from "./video_state_table";
/** Type-only namespace exports for generated type groups. */ /** Type-only namespace exports for generated type groups. */
/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ /** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */
const tablesSchema = __schema({ const tablesSchema = __schema({
person: __table({ videoState: __table({
name: 'person', name: 'video_state',
indexes: [ indexes: [
{ accessor: 'id', name: 'video_state_id_idx_btree', algorithm: 'btree', columns: [
'id',
] },
], ],
constraints: [ constraints: [
{ name: 'video_state_id_key', constraint: 'unique', columns: ['id'] },
], ],
}, PersonRow), }, VideoStateRow),
}); });
/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ /** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */
const reducersSchema = __reducers( const reducersSchema = __reducers(
__reducerSchema("add", AddReducer), __reducerSchema("pause", PauseReducer),
__reducerSchema("say_hello", SayHelloReducer), __reducerSchema("play", PlayReducer),
__reducerSchema("seek", SeekReducer),
__reducerSchema("set_url", SetUrlReducer),
); );
/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ /** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */

View File

@@ -10,4 +10,6 @@ import {
type Infer as __Infer, type Infer as __Infer,
} from "spacetimedb"; } from "spacetimedb";
export default {}; export default {
timePosition: __t.f64(),
};

View File

@@ -10,6 +10,6 @@ import {
type Infer as __Infer, type Infer as __Infer,
} from "spacetimedb"; } from "spacetimedb";
export default __t.row({ export default {
name: __t.string(), timePosition: __t.f64(),
}); };

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 {
timePosition: __t.f64(),
};

View File

@@ -11,5 +11,5 @@ import {
} from "spacetimedb"; } from "spacetimedb";
export default { export default {
name: __t.string(), url: __t.string(),
}; };

View File

@@ -10,8 +10,12 @@ import {
type Infer as __Infer, type Infer as __Infer,
} from "spacetimedb"; } from "spacetimedb";
export const Person = __t.object("Person", { export const VideoState = __t.object("VideoState", {
name: __t.string(), id: __t.u64(),
url: __t.string(),
timePosition: __t.f64(),
isPlaying: __t.bool(),
lastUpdatedAt: __t.timestamp(),
}); });
export type Person = __Infer<typeof Person>; export type VideoState = __Infer<typeof VideoState>;

View File

@@ -6,9 +6,13 @@
import { type Infer as __Infer } from "spacetimedb"; import { type Infer as __Infer } from "spacetimedb";
// Import all reducer arg schemas // Import all reducer arg schemas
import AddReducer from "../add_reducer"; import PauseReducer from "../pause_reducer";
import SayHelloReducer from "../say_hello_reducer"; import PlayReducer from "../play_reducer";
import SeekReducer from "../seek_reducer";
import SetUrlReducer from "../set_url_reducer";
export type AddParams = __Infer<typeof AddReducer>; export type PauseParams = __Infer<typeof PauseReducer>;
export type SayHelloParams = __Infer<typeof SayHelloReducer>; export type PlayParams = __Infer<typeof PlayReducer>;
export type SeekParams = __Infer<typeof SeekReducer>;
export type SetUrlParams = __Infer<typeof SetUrlReducer>;

View File

@@ -0,0 +1,19 @@
// 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 __t.row({
id: __t.u64().primaryKey(),
url: __t.string(),
timePosition: __t.f64().name("time_position"),
isPlaying: __t.bool().name("is_playing"),
lastUpdatedAt: __t.timestamp().name("last_updated_at"),
});

View File

@@ -39,5 +39,8 @@
createSpacetimeDBProvider(connectionBuilder); createSpacetimeDBProvider(connectionBuilder);
</script> </script>
<svelte:head><link rel="icon" href={favicon} /></svelte:head> <svelte:head>
<link rel="icon" href={favicon} />
<title>Space Stream</title>
</svelte:head>
{@render children()} {@render children()}

View File

@@ -4,56 +4,114 @@
const conn = useSpacetimeDB(); const conn = useSpacetimeDB();
// Subscribe to all people in the database const [videoStates] = useTable(tables.videoState);
const [people] = useTable(tables.person); const videoState = $derived($videoStates.find((state) => state.id === 1n));
const addReducer = useReducer(reducers.add); const setUrlReducer = useReducer(reducers.setUrl);
const playReducer = useReducer(reducers.play);
const pauseReducer = useReducer(reducers.pause);
const seekReducer = useReducer(reducers.seek);
let name = $state(""); let videoElement: HTMLVideoElement | undefined = $state();
let newUrl = $state("");
function addPerson(e: SubmitEvent) { let ignoreNextEvent = false;
$effect(() => {
if (!videoElement || !videoState) return;
const state = videoState;
if (videoElement.src !== state.url) {
videoElement.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
? state.timePosition + Number(timeElapsedMicros) / 1_000_000
: state.timePosition;
const diff = Math.abs(videoElement.currentTime - expectedTime);
// Allow up to 2 seconds drift without snapping
if (diff > 2.0) {
ignoreNextEvent = true;
videoElement.currentTime = expectedTime;
}
if (state.isPlaying && videoElement.paused) {
ignoreNextEvent = true;
videoElement.play().catch(console.error);
} else if (!state.isPlaying && !videoElement.paused) {
ignoreNextEvent = true;
videoElement.pause();
}
});
function handlePlay() {
if (ignoreNextEvent) {
ignoreNextEvent = false;
return;
}
playReducer({ timePosition: videoElement?.currentTime ?? 0 });
}
function handlePause() {
if (ignoreNextEvent) {
ignoreNextEvent = false;
return;
}
pauseReducer({ timePosition: videoElement?.currentTime ?? 0 });
}
function handleSeeked() {
if (ignoreNextEvent) {
ignoreNextEvent = false;
return;
}
seekReducer({ timePosition: videoElement?.currentTime ?? 0 });
}
function handleSetUrl(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
if (!name.trim() || !$conn.isActive) return; if (!newUrl.trim() || !$conn.isActive) return;
setUrlReducer({ url: newUrl });
// Call the add reducer newUrl = "";
addReducer({ name: name });
name = "";
} }
</script> </script>
<div style="padding: 2rem;"> <div class="p-4">
<h1>SpacetimeDB Svelte App</h1> <h1>Space Stream</h1>
<div style="margin-bottom: 1rem;"> <div class="mb-2">
Status: Status:
<strong style="color: {$conn.isActive ? 'green' : 'red'}"> <strong style="color: {$conn.isActive ? 'green' : 'red'}">
{$conn.isActive ? "Connected" : "Disconnected"} {$conn.isActive ? "Connected" : "Disconnected"}
</strong> </strong>
</div> </div>
<form onsubmit={addPerson} style="margin-bottom: 2rem;"> <form onsubmit={handleSetUrl} class="mb-2">
<input <input
type="text" type="url"
placeholder="Enter name" placeholder="Enter new video URL"
bind:value={name} bind:value={newUrl}
style="padding: 0.5rem; margin-right: 0.5rem;" class="mr-2 w-96 p-2"
disabled={!$conn.isActive} disabled={!$conn.isActive}
/> />
<button type="submit" style="padding: 0.5rem 1rem;" disabled={!$conn.isActive}> <button type="submit" class="p-2" disabled={!$conn.isActive}>Set URL</button>
Add Person
</button>
</form> </form>
<div> <div>
<h2>People ({$people.length})</h2> <!-- svelte-ignore a11y_media_has_caption -->
{#if $people.length === 0} <video
<p>No people yet. Add someone above!</p> bind:this={videoElement}
{:else} controls
<ul> onplay={handlePlay}
{#each $people as person} onpause={handlePause}
<li>{person.name}</li> onseeked={handleSeeked}
{/each} class="w-full max-w-2xl bg-black"
</ul> >
{/if} Your browser does not support the video tag.
</video>
</div> </div>
</div> </div>