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
34 changes: 22 additions & 12 deletions app/src/controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,25 @@ export function Controls(props: {
}): JSX.Element {
return (
<div class="controls pointer-gaps" role="toolbar" aria-label="Media controls">
<Microphone audio={props.camera.audio} />
<Camera video={props.camera.video} room={props.room} />
<Screen video={props.screen.video} audio={props.screen.audio} room={props.room} />
<Chat broadcast={props.camera} />
<div style={{ "flex-grow": "1", "pointer-events": "none", "backdrop-filter": "none" }} />
<Volume room={props.room} />
<ClosedCaptions />
<Advanced />
<Fullscreen canvas={props.canvas} />
{/* Left group */}
<div class="flex gap-inherit">
<Microphone audio={props.camera.audio} />
<Camera video={props.camera.video} room={props.room} />
<Screen video={props.screen.video} audio={props.screen.audio} room={props.room} />
</div>

{/* Center group */}
<div class="flex-1 flex justify-center">
<Chat broadcast={props.camera} />
</div>

{/* Right group */}
<div class="flex gap-inherit">
<Volume room={props.room} />
<ClosedCaptions />
<Advanced />
<Fullscreen canvas={props.canvas} />
</div>
</div>
);
}
Expand Down Expand Up @@ -279,17 +289,17 @@ function Chat(props: { broadcast: Publish.Broadcast }): JSX.Element {
};

return (
<form id="chat" onSubmit={submit} class="flex-1 min-w-48">
<form id="chat" onSubmit={submit} class="w-full max-w-md">
<input
type="text"
autocomplete="off"
placeholder="chat"
placeholder="type to chat"
ref={setInput}
value={message()}
onInput={(e) => setMessage(e.currentTarget.value)}
aria-label="Chat message"
tabIndex={0}
class="w-full"
class="w-full text-center placeholder:text-center"
/>
</form>
);
Expand Down
5 changes: 5 additions & 0 deletions app/src/room/broadcast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import DOMPurify from "dompurify";
import { marked } from "marked";
import { Audio, type AudioProps } from "./audio";
import { Canvas } from "./canvas";
import { Captions } from "./captions";
import { Chat } from "./chat";
import { FakeBroadcast } from "./fake";
import { Bounds, Vector } from "./geometry";
Expand Down Expand Up @@ -164,6 +165,7 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
audio: Audio;
video: Video;
chat: Chat;
captions: Captions;

// The current chat message, if any.
message = new Signal<DocumentFragment | undefined>(undefined);
Expand Down Expand Up @@ -219,6 +221,7 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
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);

// Actually start the
// TODO This seems kinda buggy?
Expand Down Expand Up @@ -592,5 +595,7 @@ export class Broadcast<T extends BroadcastSource = BroadcastSource> {
this.signals.close();
this.source.close();
this.audio.close();
this.chat.close();
this.captions.close();
}
}
117 changes: 117 additions & 0 deletions app/src/room/captions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Effect } from "@kixelated/signals";
import { render } from "solid-js/web";
import IconCaption from "~icons/mdi/microphone";
import Settings from "../settings";
import type { Broadcast } from "./broadcast";
import { Canvas } from "./canvas";

export class Captions {
canvas: Canvas;
broadcast: Broadcast;

signals = new Effect();

constructor(broadcast: Broadcast, canvas: Canvas) {
this.broadcast = broadcast;
this.canvas = canvas;

this.signals.effect(this.#render.bind(this));
}

#render(effect: Effect) {
const root = document.createElement("div");

this.signals.effect((effect) => {
if (!effect.get(Settings.renderCaptions)) return;

const caption = effect.get(this.broadcast.source.audio.captions.text);
if (!caption) return;

this.#caption(effect, root, document.createTextNode(caption));
});

document.body.appendChild(root);
effect.cleanup(() => document.body.removeChild(root));
}

#caption(effect: Effect, root: HTMLElement, node: Node) {
const wrapper = document.createElement("div");
wrapper.className =
"flex items-center gap-2 px-3 py-2 backdrop-blur-md rounded-lg transition-opacity transition-transform duration-500 transform scale-125 opacity-0 shadow-lg bg-black/40 fixed max-w-sm";
root.appendChild(wrapper);

effect.effect((effect) => {
const bounds = effect.get(this.broadcast.bounds).div(window.devicePixelRatio);
const viewport = effect.get(this.broadcast.canvas.viewport).div(window.devicePixelRatio);

// Get the canvas element's position on the page
const canvasRect = this.canvas.element.getBoundingClientRect();

// Scale bounds from canvas coordinates to page coordinates
const scaleX = canvasRect.width / viewport.x;
const scaleY = canvasRect.height / viewport.y;

// Transform bounds to page coordinates
const pageBounds = {
x: bounds.position.x * scaleX + canvasRect.left,
y: bounds.position.y * scaleY + canvasRect.top,
width: bounds.size.x * scaleX,
height: bounds.size.y * scaleY,
};

// Position caption ABOVE the broadcast
const captionBottom = pageBounds.y;
const top = Math.max(canvasRect.top, captionBottom - wrapper.clientHeight - 10);

// Center caption horizontally on broadcast
const captionCenterX = pageBounds.x + pageBounds.width / 2;
const left = Math.min(
Math.max(canvasRect.left, captionCenterX - wrapper.clientWidth / 2),
canvasRect.left + canvasRect.width - wrapper.clientWidth,
);

wrapper.style.left = `${left}px`;
wrapper.style.top = `${top}px`;
wrapper.style.fontSize = "18px";
});

effect.effect((effect) => {
const z = effect.get(this.broadcast.targetPosition).z;
wrapper.style.zIndex = `${100 + z}`;
});

const iconContainer = document.createElement("div");
iconContainer.className = "animate-pulse";
wrapper.appendChild(iconContainer);

render(() => IconCaption({ class: "w-5 h-5 text-link-hue" }), iconContainer);

wrapper.appendChild(node);

// Animate in with multiple effects
effect.timer(() => {
wrapper.classList.remove("scale-125", "opacity-0");
wrapper.classList.add("scale-100", "opacity-100");
}, 10);

effect.timer(() => {
iconContainer.classList.remove("animate-pulse");
}, 2000);

effect.cleanup(() => {
// Ensure smooth fade out
wrapper.style.transition = "all 500ms ease-out";
wrapper.style.opacity = "0";
wrapper.style.transform = "scale(0.95) translateY(10px)";
wrapper.style.pointerEvents = "none";

setTimeout(() => {
root.removeChild(wrapper);
}, 500);
});
}

close() {
this.signals.close();
}
}
31 changes: 5 additions & 26 deletions app/src/room/chat.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { Effect, Signal } from "@kixelated/signals";
import { Effect } from "@kixelated/signals";
import { render } from "solid-js/web";
import IconText from "~icons/mdi/comment-text";
import IconCaption from "~icons/mdi/microphone";
import Settings from "../settings";
import type { Broadcast } from "./broadcast";
import { Canvas } from "./canvas";

Expand All @@ -12,8 +10,6 @@ export class Chat {

signals = new Effect();

#offset = new Signal<number | undefined>(undefined);

constructor(broadcast: Broadcast, canvas: Canvas) {
this.broadcast = broadcast;
this.canvas = canvas;
Expand All @@ -28,26 +24,17 @@ export class Chat {
const message = effect.get(this.broadcast.message);
if (!message) return;

this.#message(effect, root, message.cloneNode(true), "text");
});

this.signals.effect((effect) => {
if (!effect.get(Settings.renderCaptions)) return;

const caption = effect.get(this.broadcast.source.audio.captions.text);
if (!caption) return;

this.#message(effect, root, document.createTextNode(caption), "caption");
this.#message(effect, root, message.cloneNode(true));
});

document.body.appendChild(root);
effect.cleanup(() => document.body.removeChild(root));
}

#message(effect: Effect, root: HTMLElement, node: Node, type: "text" | "caption") {
#message(effect: Effect, root: HTMLElement, node: Node) {
const wrapper = document.createElement("div");
wrapper.className =
"flex items-center gap-2 px-3 py-2 backdrop-blur-md rounded-lg transition-opacity transition-margin transition-transform duration-500 transform scale-125 opacity-0 shadow-lg bg-black/40 fixed max-w-sm";
"flex items-center gap-2 px-3 py-2 backdrop-blur-md rounded-lg transition-opacity transition-transform duration-500 transform scale-125 opacity-0 shadow-lg bg-black/40 fixed max-w-sm";
root.appendChild(wrapper);

effect.effect((effect) => {
Expand Down Expand Up @@ -80,12 +67,9 @@ export class Chat {
canvasRect.left + canvasRect.width - wrapper.clientWidth,
);

const offset = type === "caption" ? (effect.get(this.#offset) ?? 0) : 0;

wrapper.style.left = `${left}px`;
wrapper.style.top = `${top}px`;
wrapper.style.fontSize = "18px";
wrapper.style.marginTop = `${offset}px`;
});

effect.effect((effect) => {
Expand All @@ -97,8 +81,7 @@ export class Chat {
iconContainer.className = "animate-pulse";
wrapper.appendChild(iconContainer);

const icon = type === "text" ? IconText : IconCaption;
render(() => icon({ class: "w-5 h-5 text-link-hue" }), iconContainer);
render(() => IconText({ class: "w-5 h-5 text-link-hue" }), iconContainer);

wrapper.appendChild(node);

Expand All @@ -123,10 +106,6 @@ export class Chat {
root.removeChild(wrapper);
}, 500);
});

if (type === "text") {
effect.set(this.#offset, wrapper.clientHeight);
}
}

close() {
Expand Down
4 changes: 2 additions & 2 deletions app/src/room/fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,14 @@ export class FakeBroadcast {
const message = effect.get(this.chat.message);
if (!message) return;

effect.timer(() => this.chat.message.set(undefined), 5000);
effect.timer(() => this.chat.message.set(undefined), 10000);
});

this.signals.effect((effect) => {
const caption = effect.get(this.audio.captions.text);
if (!caption) return;

effect.timer(() => this.audio.captions.text.set(undefined), 5000);
effect.timer(() => this.audio.captions.text.set(undefined), 10000);
});
}

Expand Down
4 changes: 2 additions & 2 deletions app/src/room/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,10 @@ export class Room {
const message = effect.get(this.camera.chat.message);
if (!message) return;

// Clear the message after 5 seconds.
// Clear the message after 10 seconds.
effect.timer(() => {
this.camera.chat.message.set(undefined);
}, 5000);
}, 10000);
});

// Monitor VAD signal with some debouncing
Expand Down
Loading