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";
const spacetimedb = schema({
person: table(
videoState: table(
{ 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 const init = spacetimedb.init((_ctx) => {
// Called when the module is initially published
export const init = spacetimedb.init((ctx) => {
// 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) => {
// Called every time a new client connects
export const onConnect = spacetimedb.clientConnected((_ctx) => {});
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) => {
// Called every time a client disconnects
export const play = spacetimedb.reducer({ time_position: t.f64() }, (ctx, { time_position }) => {
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 }) => {
ctx.db.person.insert({ name });
export const pause = spacetimedb.reducer({ time_position: t.f64() }, (ctx, { time_position }) => {
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) => {
for (const person of ctx.db.person.iter()) {
console.info(`Hello, ${person.name}!`);
}
console.info("Hello, World!");
export const seek = spacetimedb.reducer({ time_position: t.f64() }, (ctx, { time_position }) => {
const row = ctx.db.videoState.id.find(1n);
if (!row) return;
ctx.db.videoState.id.update({
...row,
timePosition: time_position,
lastUpdatedAt: ctx.timestamp,
});
});

View File

@@ -34,31 +34,39 @@ import {
} from "spacetimedb";
// Import all reducer arg schemas
import AddReducer from "./add_reducer";
import SayHelloReducer from "./say_hello_reducer";
import PauseReducer from "./pause_reducer";
import PlayReducer from "./play_reducer";
import SeekReducer from "./seek_reducer";
import SetUrlReducer from "./set_url_reducer";
// Import all procedure arg schemas
// Import all table schema definitions
import PersonRow from "./person_table";
import VideoStateRow from "./video_state_table";
/** 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. */
const tablesSchema = __schema({
person: __table({
name: 'person',
videoState: __table({
name: 'video_state',
indexes: [
{ accessor: 'id', name: 'video_state_id_idx_btree', algorithm: 'btree', columns: [
'id',
] },
],
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. */
const reducersSchema = __reducers(
__reducerSchema("add", AddReducer),
__reducerSchema("say_hello", SayHelloReducer),
__reducerSchema("pause", PauseReducer),
__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. */

View File

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

View File

@@ -10,6 +10,6 @@ import {
type Infer as __Infer,
} from "spacetimedb";
export default __t.row({
name: __t.string(),
});
export default {
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";
export default {
name: __t.string(),
url: __t.string(),
};

View File

@@ -10,8 +10,12 @@ import {
type Infer as __Infer,
} from "spacetimedb";
export const Person = __t.object("Person", {
name: __t.string(),
export const VideoState = __t.object("VideoState", {
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 all reducer arg schemas
import AddReducer from "../add_reducer";
import SayHelloReducer from "../say_hello_reducer";
import PauseReducer from "../pause_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 SayHelloParams = __Infer<typeof SayHelloReducer>;
export type PauseParams = __Infer<typeof PauseReducer>;
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);
</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()}

View File

@@ -4,56 +4,114 @@
const conn = useSpacetimeDB();
// Subscribe to all people in the database
const [people] = useTable(tables.person);
const [videoStates] = useTable(tables.videoState);
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();
if (!name.trim() || !$conn.isActive) return;
// Call the add reducer
addReducer({ name: name });
name = "";
if (!newUrl.trim() || !$conn.isActive) return;
setUrlReducer({ url: newUrl });
newUrl = "";
}
</script>
<div style="padding: 2rem;">
<h1>SpacetimeDB Svelte App</h1>
<div class="p-4">
<h1>Space Stream</h1>
<div style="margin-bottom: 1rem;">
<div class="mb-2">
Status:
<strong style="color: {$conn.isActive ? 'green' : 'red'}">
{$conn.isActive ? "Connected" : "Disconnected"}
</strong>
</div>
<form onsubmit={addPerson} style="margin-bottom: 2rem;">
<form onsubmit={handleSetUrl} class="mb-2">
<input
type="text"
placeholder="Enter name"
bind:value={name}
style="padding: 0.5rem; margin-right: 0.5rem;"
type="url"
placeholder="Enter new video URL"
bind:value={newUrl}
class="mr-2 w-96 p-2"
disabled={!$conn.isActive}
/>
<button type="submit" style="padding: 0.5rem 1rem;" disabled={!$conn.isActive}>
Add Person
</button>
<button type="submit" class="p-2" disabled={!$conn.isActive}>Set URL</button>
</form>
<div>
<h2>People ({$people.length})</h2>
{#if $people.length === 0}
<p>No people yet. Add someone above!</p>
{:else}
<ul>
{#each $people as person}
<li>{person.name}</li>
{/each}
</ul>
{/if}
<!-- svelte-ignore a11y_media_has_caption -->
<video
bind:this={videoElement}
controls
onplay={handlePlay}
onpause={handlePause}
onseeked={handleSeeked}
class="w-full max-w-2xl bg-black"
>
Your browser does not support the video tag.
</video>
</div>
</div>