From 06aac366fdaa200312f632db96be4e7b4126bff5 Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Thu, 18 Sep 2025 12:00:57 +0900 Subject: [PATCH 1/2] meta(voice): init architecture spec --- docs/architecture/voice-chat.md | 73 +++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/architecture/voice-chat.md diff --git a/docs/architecture/voice-chat.md b/docs/architecture/voice-chat.md new file mode 100644 index 0000000..48db27e --- /dev/null +++ b/docs/architecture/voice-chat.md @@ -0,0 +1,73 @@ +# Voice Chat Architecture (Convex-Hosted) + +Goals + +- Low-latency, reliable voice in rooms with mute, speaking indicators, and reconnection. +- Scale from huddles to large rooms; enable recording and moderation later. + +High-Level + +- Control plane (Convex): auth, room state, presence, SFU/TURN token minting. +- Media/signaling (SFU): LiveKit recommended for scalable audio routing. +- Client: WebRTC with Opus 48 kHz, AEC/NS/AGC; simple voice UI. + +Components + +- Convex Voice Module + - join/leave room; setMute/setDevice; heartbeat. + - mint SFU (LiveKit) access tokens; issue TURN `iceServers` creds (TTL). + - enforce org/channel permissions for join/mute/kick. +- SFU (LiveKit or mediasoup) + - Handles WS signaling and RTP forwarding; recording and moderation hooks. +- TURN/STUN + - `coturn` over TLS (`turns:`) with long‑term/HMAC creds. + +Client Flow + +1. Call Convex `joinVoiceRoom(roomId)`. + - Returns: `{ roomState, iceServers, sfu: { url, token } }`. +2. Create SFU connection and publish mic. +3. Render participant list (Convex room state + SFU events). +4. Handle device change + ICE restart; VAD for speaking indicator. + +Auth & Security + +- Convex mints short‑lived SFU tokens: claims `sub`, `room`, `role`, `exp`. +- Rate‑limit Convex voice actions (QPS, payload size); audit logs. +- TLS everywhere; DTLS‑SRTP for media. Optional E2EE via Insertable Streams. + +Room Model (Convex) + +- `voiceRooms` (orgId, channelId, active, createdAt). +- `voiceParticipants` (roomId, userId, muted, device, joinedAt, lastSeen, speaking). +- TTL cleanup on `lastSeen` via periodic task. + +Directory Plan + +- `packages/convex/src/convex/voice/` + - `rooms.ts` (create/get/join/leave) + - `participants.ts` (setMute/setDevice/heartbeat) + - `tokens.ts` (mint SFU + TURN creds) + - `cleanup.ts` (TTL tasks) +- `packages/client/src/features/voice/` + - `components/` + - `JoinButton.svelte`, `VoicePanel.svelte`, `DevicePicker.svelte` + - `livekit.ts` (connect/publish helpers) +- `servers/livekit/` (livekit definition) + +Operations + +- Deployment: reuse Convex deployment; run SFU per region; multi‑region TURN. +- Observability: metrics `rooms_active`, `participants_active`, `join_latency_ms`, `actions_rate`, `sfu_join_failures`, `turn_alloc_failures`. +- Logs: include `roomId`, `userId`, `callId`, `region`; client periodically posts `getStats()` summaries to Convex. + +Milestones + +- M1: Convex join/leave + token mint + LiveKit connect; participant list; local mute. +- M2: TURN enabled; device switch + ICE restart; speaking indicator. +- M3: Server‑side mute/kick; TTL cleanup; rate‑limits; basic metrics. +- M4: Recording/transcription path; regional routing; admin tools. + +Open Items + +- Target max participants per room; recording/E2EE requirements; regional rollout order. From 1db5283ef02ef4aaa749da173c9f5efb0ce08dad Mon Sep 17 00:00:00 2001 From: aster <137767097+aster-void@users.noreply.github.com> Date: Sun, 19 Oct 2025 13:40:40 +0900 Subject: [PATCH 2/2] wip --- AGENTS.md | 63 ++++++ bun.lock | 39 +++- packages/client/package.json | 7 +- .../livekit-vc/VideoCallController.svelte.ts | 203 ++++++++++++++++++ .../livekit-vc/components/VideoCall.svelte | 79 +++++++ .../livekit-vc/icons/camera-disabled.svg | 12 ++ .../src/features/livekit-vc/icons/camera.svg | 5 + .../livekit-vc/icons/microphone-disabled.svg | 15 ++ .../features/livekit-vc/icons/microphone.svg | 8 + .../src/features/livekit-vc/icons/phone.svg | 5 + .../client/src/features/livekit-vc/setup.ts | 42 ++++ .../livekit-vc/snippets/CameraFeed.svelte | 67 ++++++ .../livekit-vc/snippets/Controls.svelte | 103 +++++++++ .../livekit-vc/snippets/DebugInfo.svelte | 61 ++++++ .../livekit-vc/snippets/Header.svelte | 37 ++++ .../snippets/OtherParticipants.svelte | 49 +++++ .../snippets/ParticipantsList.svelte | 81 +++++++ .../livekit-vc/snippets/RoomJoin.svelte | 75 +++++++ .../client/src/features/livekit-vc/types.ts | 21 ++ packages/client/src/lib/env.ts | 19 +- packages/client/src/lib/svelte/Mount.svelte | 19 ++ packages/client/src/routes/+layout.server.ts | 4 +- packages/client/src/routes/+layout.svelte | 6 +- packages/client/src/routes/+page.svelte | 3 + packages/client/svelte.config.js | 3 + packages/client/vite.config.ts | 3 + packages/convex/src/convex/env.ts | 2 +- shell.nix | 1 + 28 files changed, 1014 insertions(+), 18 deletions(-) create mode 100644 AGENTS.md create mode 100644 packages/client/src/features/livekit-vc/VideoCallController.svelte.ts create mode 100644 packages/client/src/features/livekit-vc/components/VideoCall.svelte create mode 100644 packages/client/src/features/livekit-vc/icons/camera-disabled.svg create mode 100644 packages/client/src/features/livekit-vc/icons/camera.svg create mode 100644 packages/client/src/features/livekit-vc/icons/microphone-disabled.svg create mode 100644 packages/client/src/features/livekit-vc/icons/microphone.svg create mode 100644 packages/client/src/features/livekit-vc/icons/phone.svg create mode 100644 packages/client/src/features/livekit-vc/setup.ts create mode 100644 packages/client/src/features/livekit-vc/snippets/CameraFeed.svelte create mode 100644 packages/client/src/features/livekit-vc/snippets/Controls.svelte create mode 100644 packages/client/src/features/livekit-vc/snippets/DebugInfo.svelte create mode 100644 packages/client/src/features/livekit-vc/snippets/Header.svelte create mode 100644 packages/client/src/features/livekit-vc/snippets/OtherParticipants.svelte create mode 100644 packages/client/src/features/livekit-vc/snippets/ParticipantsList.svelte create mode 100644 packages/client/src/features/livekit-vc/snippets/RoomJoin.svelte create mode 100644 packages/client/src/features/livekit-vc/types.ts create mode 100644 packages/client/src/lib/svelte/Mount.svelte diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..823452d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,63 @@ +# Repository Guidelines + +## Project Structure & Module Organization + +- `packages/` + - `client`: SvelteKit frontend with Storybook, Tauri shell, feature folders under `src/` + - `convex`: Convex functions plus auth flows; codegen output lives beside handwritten modules + - `utils`: Shared TypeScript helpers covered by Vitest + - `markdown`: Documentation tooling and content previews +- `docs/`: Architecture notes and specs shared across agents +- `tasks/`: Hivemind Procfiles orchestrating dev services +- Keep files under 100 lines; split by feature or concern before they grow + +## Build, Test, and Development Commands + +```sh +bun install --frozen-lockfile # install dependencies +bun dev # start Convex + web client via Hivemind +bun run:web # frontend only (Vite dev server) +bun run:convex # backend only (Convex dev) +bun run:storybook # Storybook UI catalog +bun run:tauri # Native shell preview +bun check # biome lint + type + formatting checks +bun fix # safe lint/format fixes +bun test # run workspace test suites +cd packages/client && bun vite build # build client bundle +``` + +## Coding Style & Naming Conventions + +- TypeScript-first; Svelte 5 components with two-space indentation and double quotes +- Components stay PascalCase; utilities, stores, and snippets use camelCase +- Prefer module aliases (`~/...`) over deep relative paths +- Styling uses Tailwind with DaisyUI themes; compose UI via DaisyUI component classes before writing Tailwind CSS. +- do NEVER EVER USE `any`. +- don't use `.bind`. use arrow functions to bind context. + +## Isolation & Composition + +(project-level) + +- Co-locate styles, tests, and support files with their feature to keep directories compact. + +(file-level) + +- If a svelte markup is too big (>= 70 lines of markup), separate each part of the markup into snippets `{#snippet}{/snippet}`, then composite them into the final product. +- If a svelte logic is too big (>= 100 lines of script), separate them into their own reactive class / function. it should be called `*Controller` and be located at `[feature]/controllers/*Controller.svelte.ts` +- All SVGs should have their own file `{icon}.svg` - don't inline them in svelte files. + +## Testing Guidelines + +- `bun test` fans out to package-level suites; CI expects green before merge +- Playwright lives in `packages/client/tests` for end-to-end specs; name files after features (`orgs.spec.ts`) +- Vitest with Happy DOM guards utilities in `packages/utils/tests`; add coverage whenever a helper ships +- Record reproduction steps or context in failing test comments to aid reviewers +- Run `bun run --filter=@packages/client test:e2e` before UI-heavy PRs and capture flaky cases locally + +## Commit & Pull Request Guidelines + +- Follow the existing short, present-tense subjects with optional scope (`feat(convex): add member`) +- Lefthook pre-commit runs linting and types; only bypass with `git commit -n` when sharing a failing draft +- PRs should summarize intent, list verification commands, and attach UI screenshots or recordings when UX changes +- Link issues in commit or PR titles where possible and request review before merging diff --git a/bun.lock b/bun.lock index 061620d..b9b8ba7 100644 --- a/bun.lock +++ b/bun.lock @@ -31,9 +31,11 @@ "convex": "^1.27.3", "convex-svelte": "^0.0.11", "emoji-picker-element": "^1.27.0", + "livekit-client": "^2.15.11", "robot3": "^1.2.0", "runed": "^0.34.0", "unplugin-icons": "^22.4.2", + "valibot": "^1.1.0", }, "devDependencies": { "@chromatic-com/storybook": "^4.1.1", @@ -56,6 +58,7 @@ "tailwindcss": "^4.1.14", "typescript": "^5.9.3", "vite": "^7.1.9", + "vite-plugin-devtools-json": "^1.0.0", }, }, "packages/convex": { @@ -165,6 +168,8 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-F/jhuXCssPFAuciMhHKk00xnCAxJRS/pUzVfXYmOMUp//XW7mO6QeCjsjvnm8L4AO/dG2VOB0O+fJPiJ2uXtIw=="], + "@bufbuild/protobuf": ["@bufbuild/protobuf@1.10.1", "", {}, "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ=="], + "@chromatic-com/storybook": ["@chromatic-com/storybook@4.1.1", "", { "dependencies": { "@neoconfetti/react": "^1.0.0", "chromatic": "^12.0.0", "filesize": "^10.0.12", "jsonfile": "^6.1.0", "strip-ansi": "^7.1.0" }, "peerDependencies": { "storybook": "^0.0.0-0 || ^9.0.0 || ^9.1.0-0 || ^9.2.0-0 || ^10.0.0-0" } }, "sha512-+Ib4cHtEjKl/Do+4LyU0U1FhLPbIU2Q/zgbOKHBCV+dTC4T3/vGzPqiGsgkdnZyTsK/zXg96LMPSPC4jjOiapg=="], "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.0", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA=="], @@ -347,6 +352,10 @@ "@lezer/markdown": ["@lezer/markdown@1.4.3", "", { "dependencies": { "@lezer/common": "^1.0.0", "@lezer/highlight": "^1.0.0" } }, "sha512-kfw+2uMrQ/wy/+ONfrH83OkdFNM0ye5Xq96cLlaCy7h5UT9FO54DU4oRoIc0CSBh5NWmWuiIJA7NGLMJbQ+Oxg=="], + "@livekit/mutex": ["@livekit/mutex@1.1.1", "", {}, "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw=="], + + "@livekit/protocol": ["@livekit/protocol@1.42.2", "", { "dependencies": { "@bufbuild/protobuf": "^1.10.0" } }, "sha512-0jeCwoMJKcwsZICg5S6RZM4xhJoF78qMvQELjACJQn6/VB+jmiySQKOSELTXvPBVafHfEbMlqxUw2UR1jTXs2g=="], + "@lix-js/sdk": ["@lix-js/sdk@0.4.7", "", { "dependencies": { "@lix-js/server-protocol-schema": "0.1.1", "dedent": "1.5.1", "human-id": "^4.1.1", "js-sha256": "^0.11.0", "kysely": "^0.27.4", "sqlite-wasm-kysely": "0.3.0", "uuid": "^10.0.0" } }, "sha512-pRbW+joG12L0ULfMiWYosIW0plmW4AsUdiPCp+Z8rAsElJ+wJ6in58zhD3UwUcd4BNcpldEGjg6PdA7e0RgsDQ=="], "@lix-js/server-protocol-schema": ["@lix-js/server-protocol-schema@0.1.1", "", {}, "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ=="], @@ -619,6 +628,8 @@ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/dom-mediacapture-record": ["@types/dom-mediacapture-record@1.0.22", "", {}, "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], @@ -777,6 +788,8 @@ "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], "expect-type": ["expect-type@1.2.2", "", {}, "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA=="], @@ -899,10 +912,14 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.1", "", { "os": "win32", "cpu": "x64" }, "sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg=="], + "livekit-client": ["livekit-client@2.15.11", "", { "dependencies": { "@livekit/mutex": "1.1.1", "@livekit/protocol": "1.42.2", "events": "^3.3.0", "jose": "^6.1.0", "loglevel": "^1.9.2", "sdp-transform": "^2.15.0", "ts-debounce": "^4.0.0", "tslib": "2.8.1", "typed-emitter": "^2.1.0", "webrtc-adapter": "^9.0.1" }, "peerDependencies": { "@types/dom-mediacapture-record": "^1" } }, "sha512-9cHdAbSibPGyt7wWM+GAUswIOuklQHF9y561Oruzh0nNFNvRzMsE10oqJvjs0k6s2Jl+j/Z5Ar90bzVwLpu1yg=="], + "local-pkg": ["local-pkg@1.1.2", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A=="], "locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="], + "loglevel": ["loglevel@1.9.2", "", {}, "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg=="], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -1015,12 +1032,18 @@ "runed": ["runed@0.34.0", "", { "dependencies": { "dequal": "^2.0.3", "esm-env": "^1.0.0", "lz-string": "^1.5.0" }, "peerDependencies": { "@sveltejs/kit": "^2.21.0", "svelte": "^5.7.0" }, "optionalPeers": ["@sveltejs/kit"] }, "sha512-hdDCoxWCuOCa7HnuU2ihu2tXuAOacNXtvTDDZ02km+rguHZBtglzAoo3dVYtssZjFsooY9xawvYX9HmDJqaPTA=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], + "sade": ["sade@1.8.1", "", { "dependencies": { "mri": "^1.1.0" } }, "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="], + "sdp": ["sdp@3.2.1", "", {}, "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw=="], + + "sdp-transform": ["sdp-transform@2.15.0", "", { "bin": { "sdp-verify": "checker.js" } }, "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw=="], + "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "server-only": ["server-only@0.0.1", "", {}, "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA=="], @@ -1105,12 +1128,16 @@ "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], + "ts-debounce": ["ts-debounce@4.0.0", "", {}, "sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg=="], + "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "type-fest": ["type-fest@2.19.0", "", {}, "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA=="], + "typed-emitter": ["typed-emitter@2.1.0", "", { "optionalDependencies": { "rxjs": "*" } }, "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "ufo": ["ufo@1.6.1", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="], @@ -1129,12 +1156,16 @@ "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], - "uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + + "valibot": ["valibot@1.1.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw=="], "vite": ["vite@7.1.9", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4nVGliEpxmhCL8DslSAUdxlB6+SMrhB0a1v5ijlh1xB1nEPuy1mxaHxysVucLHuWryAxLWg6a5ei+U4TLn/rFg=="], "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], + "vite-plugin-devtools-json": ["vite-plugin-devtools-json@1.0.0", "", { "dependencies": { "uuid": "^11.1.0" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-MobvwqX76Vqt/O4AbnNMNWoXWGrKUqZbphCUle/J2KXH82yKQiunOeKnz/nqEPosPsoWWPP9FtNuPBSYpiiwkw=="], + "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], @@ -1143,6 +1174,8 @@ "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + "webrtc-adapter": ["webrtc-adapter@9.0.3", "", { "dependencies": { "sdp": "^3.2.0" } }, "sha512-5fALBcroIl31OeXAdd1YUntxiZl1eHlZZWzNg3U4Fn+J9/cGL3eT80YlrsWGvj2ojuz1rZr2OXkgCzIxAZ7vRQ=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -1183,8 +1216,12 @@ "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.5.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ=="], + "@inlang/sdk/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + "@lix-js/sdk/dedent": ["dedent@1.5.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg=="], + "@lix-js/sdk/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + "@mmailaender/convex-auth-svelte/cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], "@mmailaender/convex-auth-svelte/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], diff --git a/packages/client/package.json b/packages/client/package.json index ef13e3e..6e21a02 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -37,7 +37,8 @@ "svelte-check": "^4.3.2", "tailwindcss": "^4.1.14", "typescript": "^5.9.3", - "vite": "^7.1.9" + "vite": "^7.1.9", + "vite-plugin-devtools-json": "^1.0.0" }, "dependencies": { "@auth/core": "^0.40.0", @@ -53,8 +54,10 @@ "convex": "^1.27.3", "convex-svelte": "^0.0.11", "emoji-picker-element": "^1.27.0", + "livekit-client": "^2.15.11", "robot3": "^1.2.0", "runed": "^0.34.0", - "unplugin-icons": "^22.4.2" + "unplugin-icons": "^22.4.2", + "valibot": "^1.1.0" } } diff --git a/packages/client/src/features/livekit-vc/VideoCallController.svelte.ts b/packages/client/src/features/livekit-vc/VideoCallController.svelte.ts new file mode 100644 index 0000000..9e72933 --- /dev/null +++ b/packages/client/src/features/livekit-vc/VideoCallController.svelte.ts @@ -0,0 +1,203 @@ +import { Room, RoomEvent, Track } from "livekit-client"; +import { env } from "~/lib/env"; +import type { + ConnectionState, + LocalParticipantState, + Participant, +} from "./types.ts"; + +export class VideoCallController { + room: Room; + mediaEls = new Map(); + connectionState = $state({ connected: false }); + localParticipantState = $state({ + identity: "", + name: "", + isActive: false, + isMicrophoneEnabled: false, + isCameraEnabled: false, + }); + + constructor() { + this.room = new Room(); + this.setupEventListeners(); + } + + private setupEventListeners() { + // Track events + this.room.on( + RoomEvent.TrackSubscribed, + (track, _publication, _participant) => { + console.log("Track subscribed:", track.kind, _participant.identity); + if ( + track.kind === Track.Kind.Video || + track.kind === Track.Kind.Audio + ) { + const el = track.attach(); + this.mediaEls.set(`${_participant.identity}-${track.kind}`, el); + } + }, + ); + + this.room.on( + RoomEvent.TrackUnsubscribed, + (track, _publication, _participant) => { + console.log("Track unsubscribed:", track.kind, _participant.identity); + const key = `${_participant.identity}-${track.kind}`; + const el = this.mediaEls.get(key); + if (el) { + el.remove(); + this.mediaEls.delete(key); + } + }, + ); + + // Local participant events + this.room.on( + RoomEvent.LocalTrackPublished, + (_publication, _participant) => { + console.log("Local track published"); + this.updateLocalParticipantState(); + }, + ); + + this.room.on( + RoomEvent.LocalTrackUnpublished, + (_publication, _participant) => { + console.log("Local track unpublished"); + this.updateLocalParticipantState(); + }, + ); + + // Connection events + this.room.on(RoomEvent.Connected, () => { + console.log("Room connected"); + this.connectionState = { + connected: true, + roomName: this.room.name || "Unknown", + participantCount: this.room.numParticipants, + }; + this.updateLocalParticipantState(); + }); + + this.room.on(RoomEvent.Disconnected, () => { + console.log("Room disconnected"); + this.connectionState = { connected: false }; + this.updateLocalParticipantState(); + }); + + // Participant events + this.room.on(RoomEvent.ParticipantConnected, (_participant) => { + console.log("Participant connected:", _participant.identity); + }); + + this.room.on(RoomEvent.ParticipantDisconnected, (_participant) => { + console.log("Participant disconnected:", _participant.identity); + }); + + // Track mute/unmute events + this.room.on(RoomEvent.TrackMuted, (_publication, _participant) => { + console.log("Track muted:", _publication.kind, _participant.identity); + if (_participant === this.room.localParticipant) { + this.updateLocalParticipantState(); + } + }); + + this.room.on(RoomEvent.TrackUnmuted, (_publication, _participant) => { + console.log("Track unmuted:", _publication.kind, _participant.identity); + if (_participant === this.room.localParticipant) { + this.updateLocalParticipantState(); + } + }); + } + + get allParticipants(): Participant[] { + return [ + this.room.localParticipant, + ...Array.from(this.room.remoteParticipants.values()), + ]; + } + + get remoteParticipants() { + return this.room.remoteParticipants; + } + + updateLocalParticipantState() { + const localParticipant = this.room.localParticipant; + if (localParticipant) { + this.localParticipantState.identity = localParticipant.identity || ""; + this.localParticipantState.name = localParticipant.name || ""; + this.localParticipantState.isActive = localParticipant.isActive; + this.localParticipantState.isMicrophoneEnabled = + localParticipant.isMicrophoneEnabled; + this.localParticipantState.isCameraEnabled = + localParticipant.isCameraEnabled; + } + } + + async joinRoom(token: string) { + try { + // 既に接続している場合は切断 + if (this.connectionState.connected) { + await this.room.disconnect(); + } + + await this.room.connect(env.PUBLIC_LIVEKIT_WSURL, token, { + autoSubscribe: true, + }); + // 接続状態は RoomEvent.Connected で自動更新される + } catch (error) { + console.error("Failed to join room:", error); + this.connectionState = { connected: false }; + } + } + + async toggleMicrophone() { + try { + await this.room.localParticipant.setMicrophoneEnabled( + !this.localParticipantState.isMicrophoneEnabled, + ); + // State will be updated automatically via TrackMuted/TrackUnmuted events + } catch (error) { + console.error("Failed to toggle microphone:", error); + } + } + + async toggleCamera() { + try { + await this.room.localParticipant.setCameraEnabled( + !this.localParticipantState.isCameraEnabled, + ); + if (this.localParticipantState.isCameraEnabled) { + setTimeout(() => { + this.attachLocalVideoTrack(); + }, 1000); + } + // State will be updated automatically via TrackMuted/TrackUnmuted events + } catch (error) { + console.error("Failed to toggle camera:", error); + } + } + + async disconnect() { + try { + await this.room.disconnect(); + // 接続状態は RoomEvent.Disconnected で自動更新される + } catch (error) { + console.error("Failed to disconnect:", error); + } + } + + attachLocalVideoTrack() { + const localParticipant = this.room.localParticipant; + if (localParticipant?.isCameraEnabled) { + const videoTrack = localParticipant.videoTrackPublications + .values() + .next().value; + if (videoTrack?.track) { + const el = videoTrack.track.attach(); + this.mediaEls.set("local-video", el); + } + } + } +} diff --git a/packages/client/src/features/livekit-vc/components/VideoCall.svelte b/packages/client/src/features/livekit-vc/components/VideoCall.svelte new file mode 100644 index 0000000..d8f3398 --- /dev/null +++ b/packages/client/src/features/livekit-vc/components/VideoCall.svelte @@ -0,0 +1,79 @@ + + +
+
+ + {#if !ctl.connectionState.connected} + +
+
+
📞
+

ビデオ通話を開始

+

+ トークンを入力して通話ルームに参加しましょう +

+
+ + ctl.joinRoom(token)} + disconnect={() => ctl.disconnect()} + /> +
+ {:else} + +
+ +
+ ctl.joinRoom(token)} + disconnect={() => ctl.disconnect()} + /> + + ctl.toggleMicrophone()} + toggleCamera={() => ctl.toggleCamera()} + disconnect={() => ctl.disconnect()} + /> +
+ + +
+ + + +
+ + +
+ + + +
+
+ {/if} +
diff --git a/packages/client/src/features/livekit-vc/icons/camera-disabled.svg b/packages/client/src/features/livekit-vc/icons/camera-disabled.svg new file mode 100644 index 0000000..3d650d7 --- /dev/null +++ b/packages/client/src/features/livekit-vc/icons/camera-disabled.svg @@ -0,0 +1,12 @@ + + + + + \ No newline at end of file diff --git a/packages/client/src/features/livekit-vc/icons/camera.svg b/packages/client/src/features/livekit-vc/icons/camera.svg new file mode 100644 index 0000000..8c7a393 --- /dev/null +++ b/packages/client/src/features/livekit-vc/icons/camera.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/client/src/features/livekit-vc/icons/microphone-disabled.svg b/packages/client/src/features/livekit-vc/icons/microphone-disabled.svg new file mode 100644 index 0000000..5c5bc17 --- /dev/null +++ b/packages/client/src/features/livekit-vc/icons/microphone-disabled.svg @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/packages/client/src/features/livekit-vc/icons/microphone.svg b/packages/client/src/features/livekit-vc/icons/microphone.svg new file mode 100644 index 0000000..6bb06ba --- /dev/null +++ b/packages/client/src/features/livekit-vc/icons/microphone.svg @@ -0,0 +1,8 @@ + + + + diff --git a/packages/client/src/features/livekit-vc/icons/phone.svg b/packages/client/src/features/livekit-vc/icons/phone.svg new file mode 100644 index 0000000..9412a2b --- /dev/null +++ b/packages/client/src/features/livekit-vc/icons/phone.svg @@ -0,0 +1,5 @@ + + + diff --git a/packages/client/src/features/livekit-vc/setup.ts b/packages/client/src/features/livekit-vc/setup.ts new file mode 100644 index 0000000..af465e9 --- /dev/null +++ b/packages/client/src/features/livekit-vc/setup.ts @@ -0,0 +1,42 @@ +import { Room, type RoomOptions } from "livekit-client"; +import { env } from "$lib/env.ts"; + +// Room configuration to ensure server-side routing +const roomOptions: RoomOptions = { + adaptiveStream: true, + dynacast: true, +}; + +export const room = new Room(roomOptions); + +// Generate a unique room name +function generateRoomName(): string { + return `room-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +// Connect to room and enable camera/microphone +export async function initializeRoom(roomName?: string) { + try { + const finalRoomName = roomName || generateRoomName(); + + console.log("Connecting to LiveKit server:", env.PUBLIC_LIVEKIT_WSURL); + console.log("Room name:", finalRoomName); + + await room.connect(env.PUBLIC_LIVEKIT_WSURL, env.PUBLIC_LIVEKIT_TOKEN, { + // Ensure server-side processing + autoSubscribe: true, + }); + + console.log("Connected to room:", room.name); + console.log("Room participants:", room.numParticipants); + + // Don't enable camera and microphone by default + // Users can enable them manually using the UI controls + console.log("Connected to room - camera and microphone are off by default"); + + return room; + } catch (error) { + console.error("Failed to connect to room:", error); + throw error; + } +} diff --git a/packages/client/src/features/livekit-vc/snippets/CameraFeed.svelte b/packages/client/src/features/livekit-vc/snippets/CameraFeed.svelte new file mode 100644 index 0000000..a7fc68d --- /dev/null +++ b/packages/client/src/features/livekit-vc/snippets/CameraFeed.svelte @@ -0,0 +1,67 @@ + + + +
+
+

📹 あなたのカメラ

+ +
+ {#if localParticipantState.isCameraEnabled} + {#each Array.from(mediaEls.entries()) as [key, el]} + {#if key.startsWith("local-video") || key === "local-video"} +
+
+
+
+ カメラがオンです +
+
+
+ +
+
+ {/if} + {/each} + {#if !Array.from(mediaEls.entries()).some(([key]) => key.startsWith("local-video") || key === "local-video")} +
+
+
📹
+
+ カメラがオンですが、映像が表示されていません +
+
カメラの権限を確認してください
+
+
+ {/if} + {:else} +
+
+
+
📷
+
カメラがオフです
+
+ 「カメラオン」ボタンを押してカメラを開始してください +
+
+
+
+ {/if} +
+
+
diff --git a/packages/client/src/features/livekit-vc/snippets/Controls.svelte b/packages/client/src/features/livekit-vc/snippets/Controls.svelte new file mode 100644 index 0000000..8235428 --- /dev/null +++ b/packages/client/src/features/livekit-vc/snippets/Controls.svelte @@ -0,0 +1,103 @@ + + + +
+
+

🎛️ 通話コントロール

+ + +
+ + + + + + + + +
+ + +
+
+
+ マイク: {localParticipantState.isMicrophoneEnabled ? "ON" : "OFF"} +
+
+
+ カメラ: {localParticipantState.isCameraEnabled ? "ON" : "OFF"} +
+
+
+
diff --git a/packages/client/src/features/livekit-vc/snippets/DebugInfo.svelte b/packages/client/src/features/livekit-vc/snippets/DebugInfo.svelte new file mode 100644 index 0000000..07aa83b --- /dev/null +++ b/packages/client/src/features/livekit-vc/snippets/DebugInfo.svelte @@ -0,0 +1,61 @@ + + + +
+
+

🔧 Debug Info

+
+ Room Info: {JSON.stringify( + { + name: room.name, + state: room.state, + numParticipants: room.numParticipants, + serverUrl: env.PUBLIC_LIVEKIT_WSURL, + isConnected: room.state === "connected", + }, + null, + 2, + )} + + Local Participant: {JSON.stringify( + { + identity: localParticipantState.identity, + name: localParticipantState.name, + isActive: localParticipantState.isActive, + isMicrophoneEnabled: localParticipantState.isMicrophoneEnabled, + isCameraEnabled: localParticipantState.isCameraEnabled, + }, + null, + 2, + )} + + Remote Participants: {JSON.stringify( + remoteParticipants.map((p) => ({ + identity: p.identity, + name: p.name, + isActive: p.isActive, + isMicrophoneEnabled: p.isMicrophoneEnabled, + isCameraEnabled: p.isCameraEnabled, + })), + null, + 2, + )} + + Media Elements: {JSON.stringify(Array.from(mediaEls.keys()), null, 2)} +
+
+
diff --git a/packages/client/src/features/livekit-vc/snippets/Header.svelte b/packages/client/src/features/livekit-vc/snippets/Header.svelte new file mode 100644 index 0000000..58e8063 --- /dev/null +++ b/packages/client/src/features/livekit-vc/snippets/Header.svelte @@ -0,0 +1,37 @@ + + + +
+

📹 ビデオ通話

+
+
+
+ {connectionState.connected ? "接続中" : "接続待機中"} +
+ {#if connectionState.connected} +
ルーム: {connectionState.roomName}
+
+ 参加者: {connectionState.participantCount}人 +
+ {:else} +
ルーム: 未接続
+
参加者: 0人
+ {/if} +
+
diff --git a/packages/client/src/features/livekit-vc/snippets/OtherParticipants.svelte b/packages/client/src/features/livekit-vc/snippets/OtherParticipants.svelte new file mode 100644 index 0000000..ff872c4 --- /dev/null +++ b/packages/client/src/features/livekit-vc/snippets/OtherParticipants.svelte @@ -0,0 +1,49 @@ + + + +
+
+

👥 他の参加者

+ +
+ {#each Array.from(mediaEls.entries()) as [key, el]} + {#if !key.startsWith("local-")} +
+
+
+
+
+ {key} +
+
+
+ +
+
+
+ {/if} + {/each} + {#if Array.from(mediaEls.entries()).filter(([key]) => !key.startsWith("local-")).length === 0} +
+
+
👥
+
まだ他の参加者はいません
+
+ 他の人が同じルームに参加すると、ここに表示されます +
+
+
+ {/if} +
+
+
diff --git a/packages/client/src/features/livekit-vc/snippets/ParticipantsList.svelte b/packages/client/src/features/livekit-vc/snippets/ParticipantsList.svelte new file mode 100644 index 0000000..8352c5a --- /dev/null +++ b/packages/client/src/features/livekit-vc/snippets/ParticipantsList.svelte @@ -0,0 +1,81 @@ + + + +
+
+

👥 参加者一覧

+ +
+ {#each allParticipants as participant} +
+
+
+
+
+
+ + {#if participant === room.localParticipant} + 👤 + {:else} + 👥 + {/if} + +
+
+
+
+ {participant.identity || participant.name || "Unknown"} + {#if participant === room.localParticipant} +
あなた
+ {/if} +
+
+
+
+ マイク: {participant.isMicrophoneEnabled ? "ON" : "OFF"} +
+
+
+ カメラ: {participant.isCameraEnabled ? "ON" : "OFF"} +
+
+
+
+
+ {participant.isActive ? "アクティブ" : "非アクティブ"} +
+
+
+
+ {/each} +
+
+
diff --git a/packages/client/src/features/livekit-vc/snippets/RoomJoin.svelte b/packages/client/src/features/livekit-vc/snippets/RoomJoin.svelte new file mode 100644 index 0000000..912d222 --- /dev/null +++ b/packages/client/src/features/livekit-vc/snippets/RoomJoin.svelte @@ -0,0 +1,75 @@ + + + +{#if !connectionState.connected} +
+
+

🏠 ルームに参加

+ +
+ +
+ + 💡 サーバーから取得したトークンを入力してください + +
+
+ +
+ +
+ + 💡 トークンを入力してからボタンを押してください + +
+
+
+
+{:else} +
+
+

✅ ルームに接続中

+
+
+
+ ルーム: {connectionState.roomName} +
+
+ 参加者: {connectionState.participantCount}人 +
+
+ +
+ + 💡 通話を終了するにはボタンを押してください + +
+
+
+
+{/if} diff --git a/packages/client/src/features/livekit-vc/types.ts b/packages/client/src/features/livekit-vc/types.ts new file mode 100644 index 0000000..d7a32ad --- /dev/null +++ b/packages/client/src/features/livekit-vc/types.ts @@ -0,0 +1,21 @@ +import type { LocalParticipant, RemoteParticipant } from "livekit-client"; + +export interface LocalParticipantState { + identity: string; + name: string; + isActive: boolean; + isMicrophoneEnabled: boolean; + isCameraEnabled: boolean; +} + +export type ConnectionState = + | { + connected: false; + } + | { + connected: true; + roomName: string; + participantCount: number; + }; + +export type Participant = LocalParticipant | RemoteParticipant; diff --git a/packages/client/src/lib/env.ts b/packages/client/src/lib/env.ts index ff75c7c..2fe15d8 100644 --- a/packages/client/src/lib/env.ts +++ b/packages/client/src/lib/env.ts @@ -1,10 +1,11 @@ -export const PUBLIC_CONVEX_URL = assert( - "PUBLIC_CONVEX_URL", - import.meta.env.PUBLIC_CONVEX_URL, -); +import * as v from "valibot"; -function assert(envName: string, val: string | undefined | null): string { - if (val == null) - throw new Error(`Environment variable not found: ${envName}`); - return String(val); -} +const Env = v.object({ + PUBLIC_CONVEX_URL: v.string(), + PUBLIC_LIVEKIT_WSURL: v.string(), +}); + +export const env = v.parse(Env, { + PUBLIC_CONVEX_URL: import.meta.env.PUBLIC_CONVEX_URL, + PUBLIC_LIVEKIT_WSURL: import.meta.env.PUBLIC_LIVEKIT_WSURL, +}); diff --git a/packages/client/src/lib/svelte/Mount.svelte b/packages/client/src/lib/svelte/Mount.svelte new file mode 100644 index 0000000..79508dd --- /dev/null +++ b/packages/client/src/lib/svelte/Mount.svelte @@ -0,0 +1,19 @@ + + +
diff --git a/packages/client/src/routes/+layout.server.ts b/packages/client/src/routes/+layout.server.ts index e61b651..cadfdc2 100644 --- a/packages/client/src/routes/+layout.server.ts +++ b/packages/client/src/routes/+layout.server.ts @@ -1,9 +1,9 @@ import { createConvexAuthHandlers } from "@mmailaender/convex-auth-svelte/sveltekit/server"; -import { PUBLIC_CONVEX_URL } from "$lib/env.ts"; +import { env } from "$lib/env.ts"; import type { LayoutServerLoad } from "./$types"; const { getAuthState } = createConvexAuthHandlers({ - convexUrl: PUBLIC_CONVEX_URL, + convexUrl: env.PUBLIC_CONVEX_URL, }); export const load: LayoutServerLoad = async (event) => { diff --git a/packages/client/src/routes/+layout.svelte b/packages/client/src/routes/+layout.svelte index 4d83f8a..c897dd1 100644 --- a/packages/client/src/routes/+layout.svelte +++ b/packages/client/src/routes/+layout.svelte @@ -3,15 +3,15 @@ import { setupConvexAuth } from "@mmailaender/convex-auth-svelte/sveltekit"; import { setupConvex } from "convex-svelte"; - import { PUBLIC_CONVEX_URL } from "$lib/env.ts"; + import { env } from "$lib/env.ts"; const { children, data } = $props(); - setupConvex(PUBLIC_CONVEX_URL); + setupConvex(env.PUBLIC_CONVEX_URL); setupConvexAuth({ getServerState: () => data.authState, - convexUrl: PUBLIC_CONVEX_URL, + convexUrl: env.PUBLIC_CONVEX_URL, }); diff --git a/packages/client/src/routes/+page.svelte b/packages/client/src/routes/+page.svelte index bab5fe5..d75a004 100644 --- a/packages/client/src/routes/+page.svelte +++ b/packages/client/src/routes/+page.svelte @@ -3,6 +3,7 @@ import { type Id } from "@packages/convex"; import { goto } from "$app/navigation"; import OrganizationSelector from "~/components/organization/OrganizationSelector.svelte"; + import VideoCall from "~/features/livekit-vc/components/VideoCall.svelte"; const auth = useAuth(); @@ -28,3 +29,5 @@ {/if} + + diff --git a/packages/client/svelte.config.js b/packages/client/svelte.config.js index 81bf11c..554cd89 100644 --- a/packages/client/svelte.config.js +++ b/packages/client/svelte.config.js @@ -22,6 +22,9 @@ const config = { dir: "../..", }, }, + compilerOptions: { + runes: true, + }, }; export default config; diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts index 7fb95c1..8f037e9 100644 --- a/packages/client/vite.config.ts +++ b/packages/client/vite.config.ts @@ -1,12 +1,15 @@ +// vite plugins import { sveltekit } from "@sveltejs/kit/vite"; import tailwindcss from "@tailwindcss/vite"; import Icons from "unplugin-icons/vite"; import { defineConfig } from "vite"; +import devtoolsJson from "vite-plugin-devtools-json"; export default defineConfig({ plugins: [ tailwindcss(), sveltekit(), + devtoolsJson(), Icons({ compiler: "svelte", }), diff --git a/packages/convex/src/convex/env.ts b/packages/convex/src/convex/env.ts index 98bfe8f..17b052e 100644 --- a/packages/convex/src/convex/env.ts +++ b/packages/convex/src/convex/env.ts @@ -1,6 +1,6 @@ export const AUTH_RESEND_KEY = assert( "AUTH_RESEND_KEY", - process.env.AUTH_RESEND_KEY, + process.env.AUTH_RESEND_KEY ?? "nokey", ); function assert(envName: string, val: string | undefined | null): string { diff --git a/shell.nix b/shell.nix index 9de77ca..3583131 100644 --- a/shell.nix +++ b/shell.nix @@ -14,6 +14,7 @@ in pkgs.mkShell { packages = [ + pkgs.livekit pkgs.hivemind pkgs.pkg-config pkgs.cargo-tauri