Skip to content

Commit 77d4618

Browse files
authored
Simulcast (#17)
* Simulcast support and refactoring * WIP * Simulcast improvements. Former-commit-id: 9576927
1 parent f70c200 commit 77d4618

16 files changed

Lines changed: 264 additions & 198 deletions

File tree

app/index.html

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,19 @@
1212
<meta property="og:url" content="https://hang.live/" />
1313
<meta property="og:title" content="hang.live" />
1414
<meta property="og:description" content="A free, fun, and weird place to hang out with your friends LIVE. Powered by the latest (open source) web technologies." />
15-
<meta property="og:image" content="https://hang.live/image/icon-default-256px.png" />
15+
<meta property="og:image" content="https://hang.live/image/icon-default-128px.png" />
1616
<meta property="og:image:type" content="image/png" />
17-
<meta property="og:image:width" content="256" />
18-
<meta property="og:image:height" content="256" />
17+
<meta property="og:image:width" content="128" />
18+
<meta property="og:image:height" content="128" />
1919

2020
<!-- Twitter -->
2121
<meta property="twitter:card" content="summary_large_image" />
2222
<meta property="twitter:url" content="https://hang.live/" />
2323
<meta property="twitter:title" content="hang.live" />
2424
<meta property="twitter:description" content="A free, fun, and weird place to hang out with your friends LIVE. Powered by the latest (open source) web technologies." />
25-
<meta property="twitter:image" content="https://hang.live/image/icon-default-256px.png" />
26-
<meta property="twitter:image:width" content="256" />
27-
<meta property="twitter:image:height" content="256" />
25+
<meta property="twitter:image" content="https://hang.live/image/icon-default-128px.png" />
26+
<meta property="twitter:image:width" content="128" />
27+
<meta property="twitter:image:height" content="128" />
2828

2929
<!-- Additional SEO -->
3030
<meta name="description" content="A free, fun, and weird place to hang out with your friends LIVE. Powered by the latest (open source) web technologies." />

app/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@tauri-apps/plugin-opener": "^2.5.0",
2828
"@tauri-apps/plugin-process": "^2.3.0",
2929
"@tauri-apps/plugin-updater": "^2.5.0",
30+
"@types/semver": "^7.7.1",
3031
"comlink": "^4.4.2",
3132
"dompurify": "^3.2.6",
3233
"jszip": "^3.10.1",
Lines changed: 3 additions & 0 deletions
Loading

app/public/image/icon-default-256px.png

Lines changed: 0 additions & 3 deletions
This file was deleted.

app/src/components/profile.tsx

Lines changed: 5 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import solid from "@kixelated/signals/solid";
44
import { createSignal, JSX, onCleanup, Show } from "solid-js";
55
import * as Api from "../api";
66
import { Camera, Microphone } from "../controls";
7-
import { Broadcast } from "../room/broadcast";
87
import { Canvas } from "../room/canvas";
98
import { Local } from "../room/local";
109
import { Sound } from "../room/sound";
@@ -118,7 +117,6 @@ export default function Profile(props: { local: Local }): JSX.Element {
118117
*/
119118
class LocalPreview {
120119
canvas: Canvas;
121-
broadcast: Broadcast<Publish.Broadcast>;
122120
sound: Sound;
123121
space: Space;
124122

@@ -137,26 +135,16 @@ class LocalPreview {
137135
profile: true,
138136
});
139137

140-
// Create a broadcast wrapper for rendering
141-
this.broadcast = new Broadcast(camera, this.canvas, this.sound, {
142-
visible: true,
143-
position: {
144-
x: 0,
145-
y: 0,
146-
z: 0,
147-
s: 1,
148-
},
149-
});
150-
151-
this.space.add("local", this.broadcast);
138+
const broadcast = this.space.add("local", camera);
139+
this.signals.cleanup(() => this.space.remove("local"));
152140

153141
this.signals.effect((effect: Effect) => {
154-
const position = effect.get(this.broadcast.position);
142+
const position = effect.get(broadcast.position);
155143
if (position.x === 0 && position.y === 0 && position.s === 1) return;
156144

157145
// Reset the position after 2 seconds.
158146
effect.timer(() => {
159-
this.broadcast.position.set({
147+
broadcast.position.set({
160148
x: 0,
161149
y: 0,
162150
z: 0,
@@ -167,10 +155,9 @@ class LocalPreview {
167155
}
168156

169157
close() {
158+
this.signals.close();
170159
this.space.close();
171160
this.canvas.close();
172161
this.sound.close();
173-
this.broadcast.close(); // NOTE: Doesn't close the source broadcast.
174-
this.signals.close();
175162
}
176163
}

app/src/controls.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,7 @@ export function Camera(props: { local: Local; room?: Room }): JSX.Element {
266266
const toggle = () => {
267267
props.local.webcam.enabled.update((prev: boolean) => !prev);
268268
};
269-
const media = solid(props.local.webcam.stream);
269+
const media = solid(props.local.webcam.source);
270270

271271
const [showMenu, setShowMenu] = createSignal(false);
272272
const [deviceChangeIndicator, setDeviceChangeIndicator] = createSignal(false);
@@ -448,7 +448,7 @@ function Screen(props: { local: Local; room: Room }): JSX.Element {
448448
const toggle = () => {
449449
props.local.screen.enabled.update((prev: boolean) => !prev);
450450
};
451-
const media = solid(props.local.screen.stream);
451+
const media = solid(props.local.screen.source);
452452

453453
return (
454454
<Tooltip content={media() ? "Disable screen sharing" : "Enable screen sharing"} position="top">

app/src/room/audio.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -141,8 +141,8 @@ export class Audio {
141141

142142
ctx.translate(bounds.position.x, bounds.position.y);
143143

144-
const RADIUS = 12 * this.broadcast.scale;
145-
const PADDING = 12 * this.broadcast.scale;
144+
const RADIUS = 12 * this.broadcast.zoom.peek();
145+
const PADDING = 12 * this.broadcast.zoom.peek();
146146

147147
// Background outline
148148
ctx.beginPath();
@@ -166,7 +166,7 @@ export class Audio {
166166
if (!analyserBuffer) return; // undefined in potato mode
167167

168168
const bounds = this.broadcast.bounds.peek();
169-
const scale = this.broadcast.scale;
169+
const scale = this.broadcast.zoom.peek();
170170

171171
ctx.save();
172172
ctx.translate(bounds.position.x, bounds.position.y);
@@ -217,7 +217,7 @@ export class Audio {
217217
// Add an additional border if we're speaking, ramping up/down the alpha
218218
if (this.#speakingAlpha > 0) {
219219
ctx.strokeStyle = `hsla(${hue}, 80%, 45%, ${this.#speakingAlpha})`;
220-
ctx.lineWidth = 6 * this.broadcast.scale;
220+
ctx.lineWidth = 6 * scale;
221221
ctx.stroke();
222222
}
223223

app/src/room/broadcast.ts

Lines changed: 83 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { type Catalog, Publish, Watch } from "@kixelated/hang";
1+
import { Publish, Watch } from "@kixelated/hang";
22
import { Effect, Signal } from "@kixelated/signals";
3-
import { Audio, type AudioProps } from "./audio";
3+
import { Audio } from "./audio";
44
import { Canvas } from "./canvas";
55
import { Captions } from "./captions";
66
import { Chat } from "./chat";
@@ -19,12 +19,6 @@ export type ChatMessage = {
1919
expires: DOMHighResTimeStamp;
2020
};
2121

22-
export type BroadcastProps = {
23-
audio?: AudioProps;
24-
position?: Catalog.Position;
25-
visible?: boolean;
26-
};
27-
2822
// Catalog.Position but all fields are required.
2923
type Position = {
3024
x: number;
@@ -33,6 +27,13 @@ type Position = {
3327
s: number;
3428
};
3529

30+
export interface BroadcastProps<T extends BroadcastSource = BroadcastSource> {
31+
source: T;
32+
canvas: Canvas;
33+
sound: Sound;
34+
scale: Signal<number>;
35+
}
36+
3637
export class Broadcast<T extends BroadcastSource = BroadcastSource> {
3738
source: T;
3839
canvas: Canvas;
@@ -46,7 +47,6 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
4647
message = new Signal<HTMLElement | undefined>(undefined);
4748

4849
bounds: Signal<Bounds>; // 0 to canvas
49-
scale = 1.0; // 1 is 100%
5050
velocity = Vector.create(0, 0); // in pixels per ?
5151

5252
// Replaced by position
@@ -64,31 +64,35 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
6464
meme = new Signal<HTMLVideoElement | HTMLAudioElement | undefined>(undefined);
6565
memeName = new Signal<string | undefined>(undefined);
6666

67+
scale: Signal<number>; // room scale, 1 is 100%
68+
zoom = new Signal<number>(1.0); // local zoom, 1 is 100%
69+
6770
// Show a locator arrow for 8 seconds to show our position on join.
6871
#locatorStart?: DOMHighResTimeStamp;
6972

7073
signals = new Effect();
7174

72-
constructor(source: T, canvas: Canvas, sound: Sound, props?: BroadcastProps) {
73-
this.source = source;
74-
this.canvas = canvas;
75-
this.visible = new Signal(props?.visible ?? true);
75+
constructor(props: BroadcastProps<T>) {
76+
this.source = props.source;
77+
this.canvas = props.canvas;
78+
this.visible = new Signal(true); // TODO
79+
this.scale = props.scale;
7680

7781
// Unless provided, start them at the center of the screen with a tiiiiny bit of variance to break ties.
7882
const start = () => (Math.random() - 0.5) / 100;
7983
const position = {
80-
x: props?.position?.x ?? start(),
81-
y: props?.position?.y ?? start(),
82-
z: props?.position?.z ?? 0,
83-
s: props?.position?.s ?? 1,
84+
x: start(),
85+
y: start(),
86+
z: 0,
87+
s: 1,
8488
};
8589

8690
this.position = new Signal(position);
8791

8892
this.video = new Video(this);
89-
this.audio = new Audio(this, sound, props?.audio);
90-
this.chat = new Chat(this, canvas);
91-
this.captions = new Captions(this, canvas);
93+
this.audio = new Audio(this, props.sound);
94+
this.chat = new Chat(this, props.canvas);
95+
this.captions = new Captions(this, props.canvas);
9296

9397
const viewport = this.canvas.viewport.peek();
9498

@@ -101,36 +105,38 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
101105
// Normalize to find the closest edge of the screen.
102106
startPosition = startPosition.normalize().mult(viewport.length()).add(viewport.div(2));
103107

104-
this.bounds = new Signal(new Bounds(startPosition, this.video.targetSize));
105-
106-
// Load the broadcaster's position from the network.
107-
this.signals.effect((effect) => {
108-
if (!effect.get(this.visible)) {
109-
// Change the target position to somewhere outside the screen.
110-
this.position.update((prev) => {
111-
const offscreen = Vector.create(prev.x, prev.y).normalize().mult(2);
112-
return { ...prev, x: offscreen.x, y: offscreen.y };
113-
});
114-
115-
return;
116-
}
108+
this.bounds = new Signal(new Bounds(startPosition, this.video.targetSize.peek()));
117109

118-
// Update the target position from the network.
119-
const location = effect.get(this.source.location.window.position);
120-
if (!location) return;
110+
this.signals.effect(this.#runLocation.bind(this));
111+
this.signals.effect(this.#runChat.bind(this));
112+
this.signals.effect(this.#runTarget.bind(this));
113+
}
121114

115+
// Load the broadcaster's position from the network.
116+
#runLocation(effect: Effect) {
117+
if (!effect.get(this.visible)) {
118+
// Change the target position to somewhere outside the screen.
122119
this.position.update((prev) => {
123-
return {
124-
...prev,
125-
x: location.x ?? prev.x,
126-
y: location.y ?? prev.y,
127-
z: location.z ?? prev.z,
128-
s: location.s ?? prev.s,
129-
};
120+
const offscreen = Vector.create(prev.x, prev.y).normalize().mult(2);
121+
return { ...prev, x: offscreen.x, y: offscreen.y };
130122
});
131-
});
132123

133-
this.signals.effect(this.#runChat.bind(this));
124+
return;
125+
}
126+
127+
// Update the target position from the network.
128+
const location = effect.get(this.source.location.window.position);
129+
if (!location) return;
130+
131+
this.position.update((prev) => {
132+
return {
133+
...prev,
134+
x: location.x ?? prev.x,
135+
y: location.y ?? prev.y,
136+
z: location.z ?? prev.z,
137+
s: location.s ?? prev.s,
138+
};
139+
});
134140
}
135141

136142
#runChat(effect: Effect) {
@@ -165,8 +171,29 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
165171
});
166172
}
167173

174+
// Decides the simulcast size to use based on the number of pixels.
175+
#runTarget(effect: Effect) {
176+
if (!(this.source instanceof Watch.Broadcast)) return;
177+
178+
const catalog = effect.get(this.source.video.catalog);
179+
if (!catalog) return;
180+
181+
for (const rendition of catalog) {
182+
if (!rendition.config.displayAspectHeight || !rendition.config.displayAspectWidth) continue;
183+
184+
const pixels = rendition.config.displayAspectHeight * rendition.config.displayAspectWidth;
185+
const scale = effect.get(this.scale);
186+
const zoom = effect.get(this.zoom);
187+
188+
const scaled = pixels * scale * zoom;
189+
effect.set(this.source.video.target, { pixels: scaled });
190+
191+
return;
192+
}
193+
}
194+
168195
// TODO Also make scale a signal
169-
tick(scale: number) {
196+
tick() {
170197
this.video.tick();
171198

172199
const bounds = this.bounds.peek();
@@ -213,8 +240,12 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
213240
}
214241

215242
// Apply everything now.
216-
const targetSize = this.video.targetSize.mult(this.scale * scale);
217-
this.scale += (targetPosition.s - this.scale) * 0.1;
243+
const targetSize = this.video.targetSize.peek().mult(this.zoom.peek() * this.scale.peek());
244+
245+
const dz = (targetPosition.s - this.zoom.peek()) * 0.1;
246+
if (Math.abs(dz) >= 0.002) {
247+
this.zoom.update((prev) => prev + dz);
248+
}
218249

219250
// Apply the velocity and size.
220251
const dx = this.velocity.x / 50;
@@ -275,9 +306,9 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
275306
ctx.globalAlpha *= alpha;
276307

277308
// Calculate arrow position and animation
278-
const arrowSize = 12 * this.scale;
309+
const arrowSize = 12 * this.zoom.peek();
279310
const pulseScale = 1 + Math.sin(now / 500) * 0.1; // Subtle pulsing effect
280-
const offset = 10 * this.scale;
311+
const offset = 10 * this.zoom.peek();
281312

282313
const gap = 2 * (arrowSize + offset);
283314

@@ -294,14 +325,14 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
294325
ctx.closePath();
295326

296327
// Style the arrow
297-
ctx.lineWidth = 4 * this.scale;
328+
ctx.lineWidth = 4 * this.zoom.peek();
298329
ctx.strokeStyle = "#000"; // Gold color
299330
ctx.fillStyle = "#FFD700";
300331
ctx.stroke();
301332
ctx.fill();
302333

303334
// Draw "YOU" text
304-
const fontSize = Math.round(32 * this.scale); // round to avoid busting font caches
335+
const fontSize = Math.round(32 * this.zoom.peek()); // round to avoid busting font caches
305336
ctx.font = `bold ${fontSize}px Arial`;
306337
ctx.textAlign = "center";
307338
ctx.textBaseline = "middle";

0 commit comments

Comments
 (0)