Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,19 @@
<meta property="og:url" content="https://hang.live/" />
<meta property="og:title" content="hang.live" />
<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." />
<meta property="og:image" content="https://hang.live/image/icon-default-256px.png" />
<meta property="og:image" content="https://hang.live/image/icon-default-128px.png" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="256" />
<meta property="og:image:height" content="256" />
<meta property="og:image:width" content="128" />
<meta property="og:image:height" content="128" />

<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://hang.live/" />
<meta property="twitter:title" content="hang.live" />
<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." />
<meta property="twitter:image" content="https://hang.live/image/icon-default-256px.png" />
<meta property="twitter:image:width" content="256" />
<meta property="twitter:image:height" content="256" />
<meta property="twitter:image" content="https://hang.live/image/icon-default-128px.png" />
<meta property="twitter:image:width" content="128" />
<meta property="twitter:image:height" content="128" />

<!-- Additional SEO -->
<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." />
Expand Down
1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@tauri-apps/plugin-opener": "^2.5.0",
"@tauri-apps/plugin-process": "^2.3.0",
"@tauri-apps/plugin-updater": "^2.5.0",
"@types/semver": "^7.7.1",
"comlink": "^4.4.2",
"dompurify": "^3.2.6",
"jszip": "^3.10.1",
Expand Down
Binary file added app/public/image/icon-default-128px.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed app/public/image/icon-default-256px.png
Binary file not shown.
23 changes: 5 additions & 18 deletions app/src/components/profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import solid from "@kixelated/signals/solid";
import { createSignal, JSX, onCleanup, Show } from "solid-js";
import * as Api from "../api";
import { Camera, Microphone } from "../controls";
import { Broadcast } from "../room/broadcast";
import { Canvas } from "../room/canvas";
import { Local } from "../room/local";
import { Sound } from "../room/sound";
Expand Down Expand Up @@ -118,7 +117,6 @@ export default function Profile(props: { local: Local }): JSX.Element {
*/
class LocalPreview {
canvas: Canvas;
broadcast: Broadcast<Publish.Broadcast>;
sound: Sound;
space: Space;

Expand All @@ -137,26 +135,16 @@ class LocalPreview {
profile: true,
});

// Create a broadcast wrapper for rendering
this.broadcast = new Broadcast(camera, this.canvas, this.sound, {
visible: true,
position: {
x: 0,
y: 0,
z: 0,
s: 1,
},
});

this.space.add("local", this.broadcast);
const broadcast = this.space.add("local", camera);
this.signals.cleanup(() => this.space.remove("local"));

this.signals.effect((effect: Effect) => {
const position = effect.get(this.broadcast.position);
const position = effect.get(broadcast.position);
if (position.x === 0 && position.y === 0 && position.s === 1) return;

// Reset the position after 2 seconds.
effect.timer(() => {
this.broadcast.position.set({
broadcast.position.set({
x: 0,
y: 0,
z: 0,
Expand All @@ -167,10 +155,9 @@ class LocalPreview {
}

close() {
this.signals.close();
this.space.close();
this.canvas.close();
this.sound.close();
this.broadcast.close(); // NOTE: Doesn't close the source broadcast.
this.signals.close();
}
}
4 changes: 2 additions & 2 deletions app/src/controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ export function Camera(props: { local: Local; room?: Room }): JSX.Element {
const toggle = () => {
props.local.webcam.enabled.update((prev: boolean) => !prev);
};
const media = solid(props.local.webcam.stream);
const media = solid(props.local.webcam.source);

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

return (
<Tooltip content={media() ? "Disable screen sharing" : "Enable screen sharing"} position="top">
Expand Down
8 changes: 4 additions & 4 deletions app/src/room/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,8 @@ export class Audio {

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

const RADIUS = 12 * this.broadcast.scale;
const PADDING = 12 * this.broadcast.scale;
const RADIUS = 12 * this.broadcast.zoom.peek();
const PADDING = 12 * this.broadcast.zoom.peek();

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

const bounds = this.broadcast.bounds.peek();
const scale = this.broadcast.scale;
const scale = this.broadcast.zoom.peek();

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

Expand Down
135 changes: 83 additions & 52 deletions app/src/room/broadcast.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type Catalog, Publish, Watch } from "@kixelated/hang";
import { Publish, Watch } from "@kixelated/hang";
import { Effect, Signal } from "@kixelated/signals";
import { Audio, type AudioProps } from "./audio";
import { Audio } from "./audio";
import { Canvas } from "./canvas";
import { Captions } from "./captions";
import { Chat } from "./chat";
Expand All @@ -19,12 +19,6 @@ export type ChatMessage = {
expires: DOMHighResTimeStamp;
};

export type BroadcastProps = {
audio?: AudioProps;
position?: Catalog.Position;
visible?: boolean;
};

// Catalog.Position but all fields are required.
type Position = {
x: number;
Expand All @@ -33,6 +27,13 @@ type Position = {
s: number;
};

export interface BroadcastProps<T extends BroadcastSource = BroadcastSource> {
source: T;
canvas: Canvas;
sound: Sound;
scale: Signal<number>;
}

export class Broadcast<T extends BroadcastSource = BroadcastSource> {
source: T;
canvas: Canvas;
Expand All @@ -46,7 +47,6 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
message = new Signal<HTMLElement | undefined>(undefined);

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

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

scale: Signal<number>; // room scale, 1 is 100%
zoom = new Signal<number>(1.0); // local zoom, 1 is 100%

// Show a locator arrow for 8 seconds to show our position on join.
#locatorStart?: DOMHighResTimeStamp;

signals = new Effect();

constructor(source: T, canvas: Canvas, sound: Sound, props?: BroadcastProps) {
this.source = source;
this.canvas = canvas;
this.visible = new Signal(props?.visible ?? true);
constructor(props: BroadcastProps<T>) {
this.source = props.source;
this.canvas = props.canvas;
this.visible = new Signal(true); // TODO
this.scale = props.scale;

// Unless provided, start them at the center of the screen with a tiiiiny bit of variance to break ties.
const start = () => (Math.random() - 0.5) / 100;
const position = {
x: props?.position?.x ?? start(),
y: props?.position?.y ?? start(),
z: props?.position?.z ?? 0,
s: props?.position?.s ?? 1,
x: start(),
y: start(),
z: 0,
s: 1,
};

this.position = new Signal(position);

this.video = new Video(this);
this.audio = new Audio(this, sound, props?.audio);
this.chat = new Chat(this, canvas);
this.captions = new Captions(this, canvas);
this.audio = new Audio(this, props.sound);
this.chat = new Chat(this, props.canvas);
this.captions = new Captions(this, props.canvas);

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

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

this.bounds = new Signal(new Bounds(startPosition, this.video.targetSize));

// Load the broadcaster's position from the network.
this.signals.effect((effect) => {
if (!effect.get(this.visible)) {
// Change the target position to somewhere outside the screen.
this.position.update((prev) => {
const offscreen = Vector.create(prev.x, prev.y).normalize().mult(2);
return { ...prev, x: offscreen.x, y: offscreen.y };
});

return;
}
this.bounds = new Signal(new Bounds(startPosition, this.video.targetSize.peek()));

// Update the target position from the network.
const location = effect.get(this.source.location.window.position);
if (!location) return;
this.signals.effect(this.#runLocation.bind(this));
this.signals.effect(this.#runChat.bind(this));
this.signals.effect(this.#runTarget.bind(this));
}

// Load the broadcaster's position from the network.
#runLocation(effect: Effect) {
if (!effect.get(this.visible)) {
// Change the target position to somewhere outside the screen.
this.position.update((prev) => {
return {
...prev,
x: location.x ?? prev.x,
y: location.y ?? prev.y,
z: location.z ?? prev.z,
s: location.s ?? prev.s,
};
const offscreen = Vector.create(prev.x, prev.y).normalize().mult(2);
return { ...prev, x: offscreen.x, y: offscreen.y };
});
});

this.signals.effect(this.#runChat.bind(this));
return;
}

// Update the target position from the network.
const location = effect.get(this.source.location.window.position);
if (!location) return;

this.position.update((prev) => {
return {
...prev,
x: location.x ?? prev.x,
y: location.y ?? prev.y,
z: location.z ?? prev.z,
s: location.s ?? prev.s,
};
});
}

#runChat(effect: Effect) {
Expand Down Expand Up @@ -165,8 +171,29 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
});
}

// Decides the simulcast size to use based on the number of pixels.
#runTarget(effect: Effect) {
if (!(this.source instanceof Watch.Broadcast)) return;

const catalog = effect.get(this.source.video.catalog);
if (!catalog) return;

for (const rendition of catalog) {
if (!rendition.config.displayAspectHeight || !rendition.config.displayAspectWidth) continue;

const pixels = rendition.config.displayAspectHeight * rendition.config.displayAspectWidth;
const scale = effect.get(this.scale);
const zoom = effect.get(this.zoom);

const scaled = pixels * scale * zoom;
effect.set(this.source.video.target, { pixels: scaled });

return;
}
}

// TODO Also make scale a signal
tick(scale: number) {
tick() {
this.video.tick();

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

// Apply everything now.
const targetSize = this.video.targetSize.mult(this.scale * scale);
this.scale += (targetPosition.s - this.scale) * 0.1;
const targetSize = this.video.targetSize.peek().mult(this.zoom.peek() * this.scale.peek());

const dz = (targetPosition.s - this.zoom.peek()) * 0.1;
if (Math.abs(dz) >= 0.002) {
this.zoom.update((prev) => prev + dz);
}

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

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

const gap = 2 * (arrowSize + offset);

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

// Style the arrow
ctx.lineWidth = 4 * this.scale;
ctx.lineWidth = 4 * this.zoom.peek();
ctx.strokeStyle = "#000"; // Gold color
ctx.fillStyle = "#FFD700";
ctx.stroke();
ctx.fill();

// Draw "YOU" text
const fontSize = Math.round(32 * this.scale); // round to avoid busting font caches
const fontSize = Math.round(32 * this.zoom.peek()); // round to avoid busting font caches
ctx.font = `bold ${fontSize}px Arial`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
Expand Down
Loading
Loading