video player
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -10,4 +10,6 @@ import {
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default {};
|
||||
export default {
|
||||
timePosition: __t.f64(),
|
||||
};
|
||||
@@ -10,6 +10,6 @@ import {
|
||||
type Infer as __Infer,
|
||||
} from "spacetimedb";
|
||||
|
||||
export default __t.row({
|
||||
name: __t.string(),
|
||||
});
|
||||
export default {
|
||||
timePosition: __t.f64(),
|
||||
};
|
||||
15
src/lib/st-bindings/seek_reducer.ts
Normal file
15
src/lib/st-bindings/seek_reducer.ts
Normal 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(),
|
||||
};
|
||||
@@ -11,5 +11,5 @@ import {
|
||||
} from "spacetimedb";
|
||||
|
||||
export default {
|
||||
name: __t.string(),
|
||||
url: __t.string(),
|
||||
};
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
19
src/lib/st-bindings/video_state_table.ts
Normal file
19
src/lib/st-bindings/video_state_table.ts
Normal 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"),
|
||||
});
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user