diff --git a/app/src/components/tutorial.tsx b/app/src/components/tutorial.tsx
new file mode 100644
index 00000000..e3e5765f
--- /dev/null
+++ b/app/src/components/tutorial.tsx
@@ -0,0 +1,140 @@
+import solid from "@kixelated/signals/solid";
+import { onCleanup, onMount, Show } from "solid-js";
+import type { JSX } from "solid-js/jsx-runtime";
+import Settings from "../settings";
+
+export function Tutorial(): JSX.Element {
+ const step = solid(Settings.tutorial.step);
+
+ const steps = [
+ {
+ title: "Share Stuff",
+ description:
+ "Enable your microphone or webcam down here. Or share your screen, just make sure you close that tab first. You know the one.",
+ arrow: "bottom-left",
+ styles: { bottom: "5rem", left: "1rem" },
+ },
+ {
+ title: "Talk Stuff",
+ description: "Spam unfunny messages down here. There's also a dank meme selector.",
+ arrow: "bottom",
+ styles: { bottom: "5rem", left: "50%", transform: "translateX(-50%)" },
+ },
+ {
+ title: "Advanced Stuff",
+ description: "I guess.",
+ arrow: "bottom-right",
+ styles: { bottom: "5rem", right: "1rem" },
+ },
+ {
+ title: "Other Stuff",
+ description: "Favorite the room if you want to hang later. Or leave unannounced, cold.",
+ arrow: "top-right",
+ styles: { top: "5rem", right: "1rem" },
+ },
+ ];
+
+ const nextStep = () => {
+ Settings.tutorial.step.set(step() + 1);
+ };
+
+ const skipTutorial = () => {
+ Settings.tutorial.step.set(steps.length);
+ };
+
+ onMount(() => {
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && step() < steps.length) {
+ skipTutorial();
+ }
+ };
+ document.addEventListener("keydown", handleEscape);
+ onCleanup(() => document.removeEventListener("keydown", handleEscape));
+ });
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Tutorial tooltip */}
+
+
+
+ {steps[step()].title}
+
+
+
+
+
{steps[step()].description}
+
+
+
+ {steps.map((_, index) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ {/* Arrow */}
+
+
+
+ );
+}
diff --git a/app/src/controls.tsx b/app/src/controls.tsx
index 1c5d6da3..bbc8bcac 100644
--- a/app/src/controls.tsx
+++ b/app/src/controls.tsx
@@ -15,39 +15,236 @@ import {
import type { JSX } from "solid-js/jsx-runtime";
import { MemeSelector } from "./components/meme-selector";
import Tooltip from "./components/tooltip";
+import { Tutorial } from "./components/tutorial";
import type { Room } from "./room";
import type { Canvas } from "./room/canvas";
import { Local } from "./room/local";
import { Sound } from "./room/sound";
import Settings, { Modal } from "./settings";
+import { isMobile } from "./util/mobile";
+
+type MobileSection = "publish" | "chat" | "settings" | null;
export function Controls(props: { room: Room; local: Local; canvas: Canvas }): JSX.Element {
+ const mobile = isMobile();
+ const [expandedSection, setExpandedSection] = createSignal
(null);
+ const tutorial = solid(Settings.tutorial.step);
+
+ const toggleSection = (section: MobileSection) => {
+ setExpandedSection((prev) => (prev === section ? null : section));
+ };
+
+ const collapseAll = () => {
+ setExpandedSection(null);
+ };
+
+ // Check if any publish source is active
+ const micActive = solid(props.local.camera.audio.root);
+ const cameraActive = solid(props.local.webcam.source);
+ const screenActive = solid(props.local.screen.source);
+ const anySourceActive = createMemo(() => !!micActive() || !!cameraActive() || !!screenActive());
+
+ // Close expanded sections when clicking outside (mobile only)
+ let publishRef: HTMLDivElement | undefined;
+ let chatRef: HTMLDivElement | undefined;
+ let settingsRef: HTMLDivElement | undefined;
+
+ onMount(() => {
+ const handleClick = (e: MouseEvent) => {
+ if (!mobile() || !expandedSection()) return;
+
+ const target = e.target as Node;
+ const expanded = expandedSection();
+
+ // Check if click is inside the expanded section
+ if (expanded === "publish" && publishRef?.contains(target)) return;
+ if (expanded === "settings" && settingsRef?.contains(target)) return;
+
+ // Click is outside the expanded section, so collapse
+ collapseAll();
+ };
+ document.addEventListener("click", handleClick, true); // Use capture phase
+ onCleanup(() => document.removeEventListener("click", handleClick, true));
+ });
+
return (
-
- {/* Left group */}
-
-
-
-
+ <>
+
+
+
+ {/* Left: Publish section */}
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
- {/* Center group */}
-
+ {/* Center: Chat */}
+
- {/* Right group */}
-
-
- {/*
*/}
-
-
+ {/* Right: Settings section */}
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
-
+ >
);
}
@@ -606,18 +803,33 @@ function Chat(props: { broadcast: Publish.Broadcast; room: Room }): JSX.Element
};
return (
-
+
+
+
+
setShowMemeSelector(false)}
/>
-
);
}
diff --git a/app/src/layout/app.tsx b/app/src/layout/app.tsx
index 1ce96c6a..0180f421 100644
--- a/app/src/layout/app.tsx
+++ b/app/src/layout/app.tsx
@@ -4,15 +4,107 @@ import { createResource, createSignal, JSX, Show } from "solid-js";
import * as Api from "../api";
import Login from "../components/login";
import Tooltip from "../components/tooltip";
+import Settings from "../settings";
+import { isMobile } from "../util/mobile";
import { Logo } from "./logo";
export default function App(props: { children: JSX.Element; connection: Moq.Connection.Reload; room: string }) {
return (
-
+ );
+}
+
+function Header(props: { connection: Moq.Connection.Reload; room: string }) {
+ const mobile = isMobile();
+ const [showMobileNav, setShowMobileNav] = createSignal(false);
+ const activeStep = solid(Settings.tutorial.step);
+
+ return (
+
+
+
+
-
-
- {props.children}
-
+
+
+
);
}
@@ -50,7 +140,51 @@ function RoomNav(props: { room: string }) {
+
+
+
+
+
+
+
+ >
+ );
+}
+
+function RoomNavMobile(props: { room: string }) {
+ const [showCopiedNotification, setShowCopiedNotification] = createSignal(false);
+
+ const share = async () => {
+ const url = window.location.href;
+ await navigator.clipboard.writeText(url);
+
+ setShowCopiedNotification(true);
+ setTimeout(() => {
+ setShowCopiedNotification(false);
+ }, 3000);
+ };
+
+ return (
+ <>
+
+
@@ -64,7 +198,7 @@ function RoomNav(props: { room: string }) {