diff --git a/app/src/controls.tsx b/app/src/controls.tsx
index a9c9f712..3f7eaabf 100644
--- a/app/src/controls.tsx
+++ b/app/src/controls.tsx
@@ -27,15 +27,25 @@ export function Controls(props: {
}): JSX.Element {
return (
-
-
-
-
-
-
-
-
-
+ {/* Left group */}
+
+
+
+
+
+
+ {/* Center group */}
+
+
+
+
+ {/* Right group */}
+
);
}
@@ -279,17 +289,17 @@ function Chat(props: { broadcast: Publish.Broadcast }): JSX.Element {
};
return (
-
);
diff --git a/app/src/room/broadcast.ts b/app/src/room/broadcast.ts
index 1737659a..6296fc0d 100644
--- a/app/src/room/broadcast.ts
+++ b/app/src/room/broadcast.ts
@@ -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";
@@ -164,6 +165,7 @@ export class Broadcast {
audio: Audio;
video: Video;
chat: Chat;
+ captions: Captions;
// The current chat message, if any.
message = new Signal(undefined);
@@ -219,6 +221,7 @@ export class Broadcast {
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?
@@ -592,5 +595,7 @@ export class Broadcast {
this.signals.close();
this.source.close();
this.audio.close();
+ this.chat.close();
+ this.captions.close();
}
}
diff --git a/app/src/room/captions.ts b/app/src/room/captions.ts
new file mode 100644
index 00000000..b6434919
--- /dev/null
+++ b/app/src/room/captions.ts
@@ -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();
+ }
+}
diff --git a/app/src/room/chat.ts b/app/src/room/chat.ts
index 94c21474..7ecf6126 100644
--- a/app/src/room/chat.ts
+++ b/app/src/room/chat.ts
@@ -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";
@@ -12,8 +10,6 @@ export class Chat {
signals = new Effect();
- #offset = new Signal(undefined);
-
constructor(broadcast: Broadcast, canvas: Canvas) {
this.broadcast = broadcast;
this.canvas = canvas;
@@ -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) => {
@@ -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) => {
@@ -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);
@@ -123,10 +106,6 @@ export class Chat {
root.removeChild(wrapper);
}, 500);
});
-
- if (type === "text") {
- effect.set(this.#offset, wrapper.clientHeight);
- }
}
close() {
diff --git a/app/src/room/fake.ts b/app/src/room/fake.ts
index 878360b4..1e766c03 100644
--- a/app/src/room/fake.ts
+++ b/app/src/room/fake.ts
@@ -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);
});
}
diff --git a/app/src/room/index.ts b/app/src/room/index.ts
index 61f7ec3f..c9c8c802 100644
--- a/app/src/room/index.ts
+++ b/app/src/room/index.ts
@@ -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