Skip to content

Commit 0f9ada4

Browse files
committed
init sfu client
1 parent cbfd9f6 commit 0f9ada4

20 files changed

+2299
-32
lines changed

client/.gitignore

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Logs
2+
logs
3+
*.log
4+
npm-debug.log*
5+
yarn-debug.log*
6+
yarn-error.log*
7+
pnpm-debug.log*
8+
lerna-debug.log*
9+
10+
node_modules
11+
dist
12+
dist-ssr
13+
*.local
14+
15+
# Editor directories and files
16+
.vscode/*
17+
!.vscode/extensions.json
18+
.idea
19+
.DS_Store
20+
*.suo
21+
*.ntvs*
22+
*.njsproj
23+
*.sln
24+
*.sw?

client/README.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Svelte + TS + Vite
2+
3+
This template should help get you started developing with Svelte and TypeScript in Vite.
4+
5+
## Recommended IDE Setup
6+
7+
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
8+
9+
## Need an official Svelte framework?
10+
11+
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more.
12+
13+
## Technical considerations
14+
15+
**Why use this over SvelteKit?**
16+
17+
- It brings its own routing solution which might not be preferable for some users.
18+
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
19+
20+
This template contains as little as possible to get started with Vite + TypeScript + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
21+
22+
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
23+
24+
**Why `global.d.ts` instead of `compilerOptions.types` inside `jsconfig.json` or `tsconfig.json`?**
25+
26+
Setting `compilerOptions.types` shuts out all other types not explicitly listed in the configuration. Using triple-slash references keeps the default TypeScript setting of accepting type information from the entire workspace, while also adding `svelte` and `vite/client` type information.
27+
28+
**Why include `.vscode/extensions.json`?**
29+
30+
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
31+
32+
**Why enable `allowJs` in the TS template?**
33+
34+
While `allowJs: false` would indeed prevent the use of `.js` files in the project, it does not prevent the use of JavaScript syntax in `.svelte` files. In addition, it would force `checkJs: false`, bringing the worst of both worlds: not being able to guarantee the entire codebase is TypeScript, and also having worse typechecking for the existing JavaScript. In addition, there are valid use cases in which a mixed codebase may be relevant.
35+
36+
**Why is HMR not preserving my local component state?**
37+
38+
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/rixo/svelte-hmr#svelte-hmr).
39+
40+
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
41+
42+
```ts
43+
// store.ts
44+
// An extremely simple external store
45+
import { writable } from 'svelte/store'
46+
export default writable(0)
47+
```

client/index.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
7+
<title>Vite + Svelte + TS</title>
8+
</head>
9+
<body>
10+
<div id="app"></div>
11+
<script type="module" src="/src/main.ts"></script>
12+
</body>
13+
</html>

client/package.json

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "@pulsebeam/client",
3+
"private": true,
4+
"version": "0.0.0",
5+
"type": "module",
6+
"scripts": {
7+
"proto": "protoc --ts_out src/lib --proto_path proto proto/sfu.proto",
8+
"dev": "vite",
9+
"build": "vite build",
10+
"preview": "vite preview",
11+
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json"
12+
},
13+
"dependencies": {
14+
"@protobuf-ts/runtime": "^2.10.0",
15+
"nanostores": "^1.0.1"
16+
},
17+
"devDependencies": {
18+
"@protobuf-ts/plugin": "^2.10.0",
19+
"@sveltejs/vite-plugin-svelte": "^5.0.3",
20+
"@tsconfig/svelte": "^5.0.4",
21+
"svelte": "^5.28.1",
22+
"svelte-check": "^4.1.6",
23+
"typescript": "~5.8.3",
24+
"vite": "^6.3.5"
25+
}
26+
}

client/proto/sfu.proto

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
syntax = "proto3";
2+
3+
package sfu;
4+
5+
// Represents the kind of media track.
6+
enum TrackKind {
7+
TRACK_KIND_UNSPECIFIED = 0;
8+
VIDEO = 1;
9+
AUDIO = 2;
10+
}
11+
12+
// --- Client to Server Messages ---
13+
14+
message ClientSubscribePayload {
15+
string mid = 1; // The client's MID (transceiver slot) to use for this track.
16+
string track_id = 2; // The application-level ID of the remote track to subscribe to.
17+
TrackKind kind = 3; // The kind of track (video/audio).
18+
}
19+
20+
message ClientUnsubscribePayload {
21+
string mid = 1; // The client's MID (transceiver slot) to unsubscribe from.
22+
}
23+
24+
// ClientMessage encapsulates all possible messages from client to SFU.
25+
message ClientMessage {
26+
oneof payload {
27+
ClientSubscribePayload subscribe = 1;
28+
ClientUnsubscribePayload unsubscribe = 2;
29+
// Add other client-initiated actions here if needed
30+
// e.g., ChangeSubscriptionQualityPayload change_quality = 3;
31+
}
32+
}
33+
34+
35+
// --- Server to Client Messages ---
36+
37+
message SubscriptionOfferPayload {
38+
string subscription_id = 1; // A unique ID for this subscription attempt, useful for correlation.
39+
string mid = 2; // The client's MID that the SFU will use (confirming client's request).
40+
string remote_track_id = 3; // The remote track ID being offered/confirmed.
41+
TrackKind kind = 4; // The kind of track.
42+
// uint32 ssrc = 5; // Optional: SFU could inform client of the SSRC it will send for this track.
43+
}
44+
45+
message SubscriptionErrorPayload {
46+
optional string subscription_id = 1; // Correlates to a subscription attempt.
47+
optional string remote_track_id = 2; // Which track failed, if applicable.
48+
optional string mid = 3; // Which client MID failed, if applicable.
49+
string message = 4; // Error description.
50+
}
51+
52+
message TrackPublishedPayload {
53+
string remote_track_id = 1; // The ID of the newly available remote track.
54+
TrackKind kind = 2; // The kind of track.
55+
string participant_id = 3; // The ID of the participant who published this track.
56+
// map<string, string> metadata = 4; // Optional: any other app-specific metadata about the track.
57+
}
58+
59+
message TrackUnpublishedPayload {
60+
string remote_track_id = 1; // The ID of the remote track that is no longer available.
61+
}
62+
63+
message ErrorPayload {
64+
string message = 1; // General error message from the SFU.
65+
}
66+
67+
68+
// ServerMessage encapsulates all possible messages from SFU to client.
69+
message ServerMessage {
70+
oneof payload {
71+
SubscriptionOfferPayload subscription_offer = 1; // SFU confirms/offers a subscription to a track.
72+
SubscriptionErrorPayload subscription_error = 2; // SFU reports an error with a subscription.
73+
TrackPublishedPayload track_published = 3; // SFU informs client a new remote track is available.
74+
TrackUnpublishedPayload track_unpublished = 4; // SFU informs client a remote track is no longer available.
75+
ErrorPayload error = 5; // General error from SFU.
76+
// Add other server-initiated messages here if needed
77+
// e.g., ServerForceMutePayload force_mute = 6;
78+
}
79+
}

client/public/vite.svg

Lines changed: 1 addition & 0 deletions
Loading

client/src/App.svelte

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
<script lang="ts">
2+
import { onMount, onDestroy } from "svelte";
3+
import {
4+
PulsebeamClient,
5+
type ClientStatus, // Only import what's strictly needed for this minimal UI
6+
} from "./lib/lib";
7+
8+
let client: PulsebeamClient;
9+
let clientStatus: ClientStatus = "new";
10+
let errorMsg: string | null = null;
11+
let localVidTrack: MediaStreamTrack | null = null;
12+
let remoteVidTrack: MediaStreamTrack | null = null; // Simplification: only one remote track
13+
14+
let localVideoEl: HTMLVideoElement;
15+
let remoteVideoEl: HTMLVideoElement;
16+
17+
const sfuUrl = "ws://localhost:8080/sfu"; // Replace
18+
const roomId = "tiny-room";
19+
const participantId = `user-${Math.random().toString(36).slice(2, 7)}`;
20+
21+
let unsubs: Array<() => void> = [];
22+
23+
function setupClient() {
24+
if (client) client.disconnect(); // Disconnect previous if any
25+
client = new PulsebeamClient(sfuUrl, 1); // Max 1 downstream for simplicity
26+
27+
unsubs.push(client.status.subscribe((v) => (clientStatus = v)));
28+
unsubs.push(client.errorMsg.subscribe((v) => (errorMsg = v)));
29+
unsubs.push(
30+
client.localVideo.subscribe((track) => {
31+
localVidTrack = track;
32+
if (localVideoEl)
33+
localVideoEl.srcObject = track ? new MediaStream([track]) : null;
34+
}),
35+
);
36+
// Simplified remote track handling: find the first video track
37+
unsubs.push(
38+
client.remoteTracks.subscribe((tracks) => {
39+
const firstRemote = Object.values(tracks).find(
40+
(rt) => rt?.kind === "video" && rt.track,
41+
);
42+
remoteVidTrack = firstRemote?.track || null;
43+
if (remoteVideoEl)
44+
remoteVideoEl.srcObject = remoteVidTrack
45+
? new MediaStream([remoteVidTrack])
46+
: null;
47+
}),
48+
);
49+
}
50+
51+
onMount(() => {
52+
setupClient();
53+
});
54+
55+
onDestroy(() => {
56+
unsubs.forEach((unsub) => unsub());
57+
client?.disconnect();
58+
});
59+
60+
async function connect() {
61+
if (clientStatus === "new") await client.connect(roomId, participantId);
62+
}
63+
function disconnectAndReset() {
64+
unsubs.forEach((unsub) => unsub()); // Unsubscribe from old client stores
65+
unsubs = [];
66+
setupClient(); // Creates new client, status becomes 'new'
67+
}
68+
async function publish() {
69+
if (clientStatus !== "connected") return;
70+
try {
71+
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
72+
await client.publish(stream.getVideoTracks()[0]);
73+
} catch (e) {
74+
client.errorMsg.set("Cam access failed");
75+
}
76+
}
77+
function subscribeFirstVideo() {
78+
if (clientStatus !== "connected") return;
79+
const available = client.availableTracks.get();
80+
const firstVid = Object.values(available).find((t) => t.kind === "video");
81+
if (firstVid) client.subscribe(firstVid.remoteTrackId, "video");
82+
}
83+
</script>
84+
85+
<main>
86+
<div style="margin-bottom: 10px;">
87+
Status: <strong>{clientStatus}</strong>
88+
{#if errorMsg}<span style="color: red; margin-left: 10px;"
89+
>Error: {errorMsg}</span
90+
>{/if}
91+
</div>
92+
93+
<div style="margin-bottom: 10px;">
94+
<button on:click={connect} disabled={clientStatus !== "new"}>Connect</button
95+
>
96+
<button on:click={disconnectAndReset} disabled={clientStatus === "new"}
97+
>Disconnect & Reset</button
98+
>
99+
</div>
100+
101+
{#if clientStatus === "connected"}
102+
<div style="margin-bottom: 10px;">
103+
<button on:click={publish} disabled={!!localVidTrack}
104+
>Publish Video</button
105+
>
106+
<button on:click={subscribeFirstVideo} disabled={!!remoteVidTrack}
107+
>Subscribe to 1st Video</button
108+
>
109+
</div>
110+
{/if}
111+
112+
<div style="display: flex; gap: 10px;">
113+
<div>
114+
<p>Local Video</p>
115+
<video
116+
bind:this={localVideoEl}
117+
autoplay
118+
playsinline
119+
muted
120+
width="160"
121+
height="120"
122+
style="border:1px solid #ccc; background:#333;"
123+
></video>
124+
</div>
125+
<div>
126+
<p>Remote Video</p>
127+
<video
128+
bind:this={remoteVideoEl}
129+
autoplay
130+
playsinline
131+
width="160"
132+
height="120"
133+
style="border:1px solid #ccc; background:#333;"
134+
></video>
135+
</div>
136+
</div>
137+
</main>
138+
139+
<style>
140+
main {
141+
font-family: Arial, sans-serif;
142+
padding: 15px;
143+
max-width: 500px;
144+
margin: auto;
145+
}
146+
button {
147+
margin-right: 5px;
148+
padding: 5px 10px;
149+
}
150+
p {
151+
margin-top: 0;
152+
margin-bottom: 5px;
153+
}
154+
</style>

client/src/app.css

Whitespace-only changes.

0 commit comments

Comments
 (0)