Fullfører oppgave 16.1: LiveKit-klient i frontend
Installerer livekit-client og bygger grunnlaget for sanntidslyd: - $lib/livekit.ts: LiveKit-wrapper med romtilkobling, deltakersporing, og Web Audio-ruting. Auto-attach av <audio> er deaktivert — all lyd rutes gjennom AudioContext med GainNode per deltaker (klar for mixer). - api.ts: joinCommunication/leaveCommunication API-funksjoner - RecordingTrait.svelte: UI for tilkobling, mikrofon-toggle og deltakerliste med live speaking-indikator Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7555038106
commit
a838e0e1c2
7 changed files with 647 additions and 5 deletions
124
frontend/package-lock.json
generated
124
frontend/package-lock.json
generated
|
|
@ -17,6 +17,7 @@
|
|||
"@tiptap/pm": "^3.20.4",
|
||||
"@tiptap/starter-kit": "^3.20.4",
|
||||
"d3": "^7.9.0",
|
||||
"livekit-client": "^2.17.3",
|
||||
"spacetimedb": "^2.0.4",
|
||||
"wavesurfer.js": "^7.12.4"
|
||||
},
|
||||
|
|
@ -96,6 +97,12 @@
|
|||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@bufbuild/protobuf": {
|
||||
"version": "1.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
|
||||
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
|
||||
"license": "(Apache-2.0 AND BSD-3-Clause)"
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
|
|
@ -557,6 +564,21 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@livekit/mutex": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
|
||||
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@livekit/protocol": {
|
||||
"version": "1.44.0",
|
||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.44.0.tgz",
|
||||
"integrity": "sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@panva/hkdf": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
|
||||
|
|
@ -2079,6 +2101,13 @@
|
|||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/dom-mediacapture-record": {
|
||||
"version": "1.0.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
|
||||
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
|
|
@ -2789,6 +2818,15 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
|
|
@ -3202,12 +3240,45 @@
|
|||
"integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/livekit-client": {
|
||||
"version": "2.17.3",
|
||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.17.3.tgz",
|
||||
"integrity": "sha512-htwsAL/BMylY/zwdcT/z00U789csbi9DldSW7DO+5tz7Q15pwu++E1X+ZdtZDfkmlysfQLLibdcqlyg9FY7veQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@livekit/mutex": "1.1.1",
|
||||
"@livekit/protocol": "1.44.0",
|
||||
"events": "^3.3.0",
|
||||
"jose": "^6.1.0",
|
||||
"loglevel": "^1.9.2",
|
||||
"sdp-transform": "^2.15.0",
|
||||
"tslib": "2.8.1",
|
||||
"typed-emitter": "^2.1.0",
|
||||
"webrtc-adapter": "^9.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/dom-mediacapture-record": "^1"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-character": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/loglevel": {
|
||||
"version": "1.9.2",
|
||||
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
|
||||
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/loglevel"
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.21",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
||||
|
|
@ -3718,6 +3789,16 @@
|
|||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/rxjs": {
|
||||
"version": "7.8.2",
|
||||
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
|
||||
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sade": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||
|
|
@ -3746,6 +3827,21 @@
|
|||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sdp": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
|
||||
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sdp-transform": {
|
||||
"version": "2.15.0",
|
||||
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
|
||||
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"sdp-verify": "checker.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.0.1.tgz",
|
||||
|
|
@ -3938,6 +4034,21 @@
|
|||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/typed-emitter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
|
||||
"license": "MIT",
|
||||
"optionalDependencies": {
|
||||
"rxjs": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
|
|
@ -4069,6 +4180,19 @@
|
|||
"integrity": "sha512-b/+XnWfJejNdvNUmtm4M5QzQepHhUbTo+62wYybwdV1B/Sn9vHhgb1xckRm0rGY2ZefJwLkE7lYcKnLfIia4cQ==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/webrtc-adapter": {
|
||||
"version": "9.0.4",
|
||||
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.4.tgz",
|
||||
"integrity": "sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"sdp": "^3.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0",
|
||||
"npm": ">=3.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zimmerframe": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
"@tiptap/pm": "^3.20.4",
|
||||
"@tiptap/starter-kit": "^3.20.4",
|
||||
"d3": "^7.9.0",
|
||||
"livekit-client": "^2.17.3",
|
||||
"spacetimedb": "^2.0.4",
|
||||
"wavesurfer.js": "^7.12.4"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1148,6 +1148,51 @@ export async function fetchMyUsage(
|
|||
return res.json();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LiveKit / Kommunikasjonsrom (oppgave 16.1)
|
||||
// =============================================================================
|
||||
|
||||
export interface JoinCommunicationRequest {
|
||||
communication_id: string;
|
||||
role?: 'publisher' | 'subscriber';
|
||||
}
|
||||
|
||||
export interface RoomParticipantInfo {
|
||||
user_id: string;
|
||||
display_name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface JoinCommunicationResponse {
|
||||
livekit_room_name: string;
|
||||
livekit_token: string;
|
||||
livekit_url: string;
|
||||
identity: string;
|
||||
participants: RoomParticipantInfo[];
|
||||
}
|
||||
|
||||
export interface LeaveCommunicationResponse {
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** Bli med i et LiveKit-rom for en kommunikasjonsnode. */
|
||||
export function joinCommunication(
|
||||
accessToken: string,
|
||||
data: JoinCommunicationRequest
|
||||
): Promise<JoinCommunicationResponse> {
|
||||
return post(accessToken, '/intentions/join_communication', data);
|
||||
}
|
||||
|
||||
/** Forlat et LiveKit-rom. */
|
||||
export function leaveCommunication(
|
||||
accessToken: string,
|
||||
communicationId: string
|
||||
): Promise<LeaveCommunicationResponse> {
|
||||
return post(accessToken, '/intentions/leave_communication', {
|
||||
communication_id: communicationId
|
||||
});
|
||||
}
|
||||
|
||||
/** Hent ressursforbruk for en spesifikk node (kun eier). */
|
||||
export async function fetchNodeUsage(
|
||||
accessToken: string,
|
||||
|
|
|
|||
|
|
@ -1,17 +1,216 @@
|
|||
<script lang="ts">
|
||||
import type { Node } from '$lib/spacetime';
|
||||
import { edgeStore, nodeStore } from '$lib/spacetime';
|
||||
import TraitPanel from './TraitPanel.svelte';
|
||||
import {
|
||||
joinCommunication,
|
||||
leaveCommunication,
|
||||
type JoinCommunicationResponse,
|
||||
} from '$lib/api';
|
||||
import {
|
||||
connect,
|
||||
disconnect,
|
||||
toggleMute,
|
||||
getStatus,
|
||||
getParticipants,
|
||||
getLocalIdentity,
|
||||
subscribe,
|
||||
type LiveKitParticipant,
|
||||
type RoomStatus,
|
||||
} from '$lib/livekit';
|
||||
|
||||
interface Props {
|
||||
collection: Node;
|
||||
config: Record<string, unknown>;
|
||||
accessToken?: string;
|
||||
}
|
||||
|
||||
let { collection, config }: Props = $props();
|
||||
let { collection, config, accessToken }: Props = $props();
|
||||
|
||||
let status: RoomStatus = $state('disconnected');
|
||||
let participants: LiveKitParticipant[] = $state([]);
|
||||
let localIdentity: string = $state('');
|
||||
let error: string | null = $state(null);
|
||||
let joinInfo: JoinCommunicationResponse | null = $state(null);
|
||||
|
||||
// Subscribe to LiveKit state changes
|
||||
$effect(() => {
|
||||
const unsub = subscribe(() => {
|
||||
status = getStatus();
|
||||
participants = getParticipants();
|
||||
localIdentity = getLocalIdentity();
|
||||
});
|
||||
return unsub;
|
||||
});
|
||||
|
||||
// Clean up on component destroy
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (status !== 'disconnected') {
|
||||
disconnect();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
/** Find communication nodes linked to this collection */
|
||||
const communicationNodes = $derived.by(() => {
|
||||
const nodes: Node[] = [];
|
||||
for (const edge of edgeStore.byTarget(collection.id)) {
|
||||
if (edge.edgeType !== 'belongs_to') continue;
|
||||
const node = nodeStore.get(edge.sourceId);
|
||||
if (node && node.nodeKind === 'communication') {
|
||||
nodes.push(node);
|
||||
}
|
||||
}
|
||||
for (const edge of edgeStore.bySource(collection.id)) {
|
||||
if (edge.edgeType !== 'has_channel') continue;
|
||||
const node = nodeStore.get(edge.targetId);
|
||||
if (node && node.nodeKind === 'communication') {
|
||||
nodes.push(node);
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
});
|
||||
|
||||
const primaryComm = $derived(communicationNodes[0] ?? null);
|
||||
|
||||
async function handleJoin() {
|
||||
if (!accessToken || !primaryComm) return;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
const resp = await joinCommunication(accessToken, {
|
||||
communication_id: primaryComm.id,
|
||||
});
|
||||
joinInfo = resp;
|
||||
await connect(resp.livekit_url, resp.livekit_token);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLeave() {
|
||||
if (!accessToken || !primaryComm) return;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
await disconnect();
|
||||
await leaveCommunication(accessToken, primaryComm.id);
|
||||
joinInfo = null;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleToggleMute() {
|
||||
try {
|
||||
await toggleMute();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
const isConnected = $derived(status === 'connected');
|
||||
const isConnecting = $derived(status === 'connecting' || status === 'reconnecting');
|
||||
const localParticipant = $derived(participants.find(p => p.identity === localIdentity));
|
||||
const remoteParticipants = $derived(participants.filter(p => p.identity !== localIdentity));
|
||||
</script>
|
||||
|
||||
<TraitPanel name="recording" label="Opptak" icon="🎤">
|
||||
{#snippet children()}
|
||||
<p class="text-sm text-gray-500">LiveKit-studio for opptak og sanntidslyd.</p>
|
||||
{#if !accessToken}
|
||||
<p class="text-sm text-gray-400">Logg inn for å bruke opptak.</p>
|
||||
{:else if !primaryComm}
|
||||
<p class="text-sm text-gray-400">Ingen kommunikasjonskanal knyttet til denne samlingen.</p>
|
||||
{:else}
|
||||
<!-- Status & controls -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="inline-block h-2.5 w-2.5 rounded-full {isConnected ? 'bg-green-500' : isConnecting ? 'bg-yellow-400 animate-pulse' : 'bg-gray-300'}"></span>
|
||||
<span class="text-sm text-gray-600">
|
||||
{#if isConnected}
|
||||
Tilkoblet
|
||||
{:else if isConnecting}
|
||||
Kobler til…
|
||||
{:else}
|
||||
Frakoblet
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
{#if isConnected}
|
||||
<button
|
||||
onclick={handleToggleMute}
|
||||
class="rounded-lg px-3 py-1.5 text-xs font-medium transition-colors {localParticipant?.isMuted ? 'bg-red-100 text-red-700 hover:bg-red-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
|
||||
>
|
||||
{localParticipant?.isMuted ? 'Slå på mikrofon' : 'Demp'}
|
||||
</button>
|
||||
<button
|
||||
onclick={handleLeave}
|
||||
class="rounded-lg bg-red-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Forlat
|
||||
</button>
|
||||
{:else}
|
||||
<button
|
||||
onclick={handleJoin}
|
||||
disabled={isConnecting}
|
||||
class="rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isConnecting ? 'Kobler til…' : 'Koble til'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
{#if error}
|
||||
<div class="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Participant list -->
|
||||
{#if isConnected && participants.length > 0}
|
||||
<div class="mt-4">
|
||||
<h4 class="mb-2 text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Deltakere ({participants.length})
|
||||
</h4>
|
||||
<ul class="space-y-1.5">
|
||||
{#each participants as p (p.identity)}
|
||||
<li class="flex items-center gap-2 rounded-lg px-2.5 py-1.5 {p.isSpeaking ? 'bg-green-50' : 'bg-gray-50'}">
|
||||
<!-- Speaking indicator -->
|
||||
<span class="inline-block h-2 w-2 rounded-full flex-shrink-0 {p.isSpeaking ? 'bg-green-500' : p.isMuted ? 'bg-red-400' : 'bg-gray-300'}"></span>
|
||||
|
||||
<!-- Name -->
|
||||
<span class="text-sm text-gray-700 truncate flex-1">
|
||||
{p.displayName}
|
||||
{#if p.identity === localIdentity}
|
||||
<span class="text-xs text-gray-400">(deg)</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<!-- Mute icon -->
|
||||
{#if p.isMuted}
|
||||
<span class="text-xs text-red-400 flex-shrink-0" title="Dempet">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM12.293 7.293a1 1 0 011.414 0L15 8.586l1.293-1.293a1 1 0 111.414 1.414L16.414 10l1.293 1.293a1 1 0 01-1.414 1.414L15 11.414l-1.293 1.293a1 1 0 01-1.414-1.414L13.586 10l-1.293-1.293a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Room info (debug) -->
|
||||
{#if isConnected && joinInfo}
|
||||
<p class="mt-3 text-xs text-gray-400">
|
||||
Rom: {joinInfo.livekit_room_name}
|
||||
</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</TraitPanel>
|
||||
|
|
|
|||
274
frontend/src/lib/livekit.ts
Normal file
274
frontend/src/lib/livekit.ts
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
/**
|
||||
* LiveKit client wrapper for Synops.
|
||||
*
|
||||
* Handles room connection, participant tracking, and Web Audio routing.
|
||||
* LiveKit's auto-attach of <audio> elements is disabled — all audio is
|
||||
* routed through the Web Audio API so the mixer (Fase 16) can process it.
|
||||
*/
|
||||
|
||||
import {
|
||||
Room,
|
||||
RoomEvent,
|
||||
Track,
|
||||
ConnectionState,
|
||||
type RemoteTrack,
|
||||
type RemoteTrackPublication,
|
||||
type RemoteParticipant,
|
||||
type Participant,
|
||||
} from 'livekit-client';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface LiveKitParticipant {
|
||||
identity: string;
|
||||
displayName: string;
|
||||
isSpeaking: boolean;
|
||||
audioLevel: number;
|
||||
isMuted: boolean;
|
||||
}
|
||||
|
||||
export type RoomStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting';
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let room: Room | null = null;
|
||||
let audioContext: AudioContext | null = null;
|
||||
|
||||
/** Map from participant identity → their Web Audio source node */
|
||||
const audioSources = new Map<string, MediaStreamAudioSourceNode>();
|
||||
/** Map from participant identity → their GainNode (for future mixer control) */
|
||||
const gainNodes = new Map<string, GainNode>();
|
||||
|
||||
// Reactive state via callbacks
|
||||
type StateListener = () => void;
|
||||
const listeners = new Set<StateListener>();
|
||||
|
||||
let _status: RoomStatus = 'disconnected';
|
||||
let _participants: LiveKitParticipant[] = [];
|
||||
let _localIdentity: string = '';
|
||||
|
||||
export function getStatus(): RoomStatus { return _status; }
|
||||
export function getParticipants(): LiveKitParticipant[] { return _participants; }
|
||||
export function getLocalIdentity(): string { return _localIdentity; }
|
||||
|
||||
export function subscribe(fn: StateListener): () => void {
|
||||
listeners.add(fn);
|
||||
return () => { listeners.delete(fn); };
|
||||
}
|
||||
|
||||
function notify() {
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
|
||||
function setStatus(s: RoomStatus) {
|
||||
_status = s;
|
||||
notify();
|
||||
}
|
||||
|
||||
// ─── Participant tracking ───────────────────────────────────────────────────
|
||||
|
||||
function buildParticipantList(): LiveKitParticipant[] {
|
||||
if (!room) return [];
|
||||
const list: LiveKitParticipant[] = [];
|
||||
|
||||
// Local participant
|
||||
const local = room.localParticipant;
|
||||
list.push({
|
||||
identity: local.identity,
|
||||
displayName: local.name || local.identity,
|
||||
isSpeaking: local.isSpeaking,
|
||||
audioLevel: local.audioLevel,
|
||||
isMuted: !local.isMicrophoneEnabled,
|
||||
});
|
||||
|
||||
// Remote participants
|
||||
for (const [, p] of room.remoteParticipants) {
|
||||
list.push({
|
||||
identity: p.identity,
|
||||
displayName: p.name || p.identity,
|
||||
isSpeaking: p.isSpeaking,
|
||||
audioLevel: p.audioLevel,
|
||||
isMuted: isParticipantMuted(p),
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
function isParticipantMuted(p: RemoteParticipant): boolean {
|
||||
for (const [, pub] of p.trackPublications) {
|
||||
if (pub.kind === Track.Kind.Audio) {
|
||||
return pub.isMuted;
|
||||
}
|
||||
}
|
||||
return true; // no audio track = effectively muted
|
||||
}
|
||||
|
||||
function refreshParticipants() {
|
||||
_participants = buildParticipantList();
|
||||
notify();
|
||||
}
|
||||
|
||||
// ─── Web Audio routing ─────────────────────────────────────────────────────
|
||||
|
||||
function ensureAudioContext(): AudioContext {
|
||||
if (!audioContext || audioContext.state === 'closed') {
|
||||
audioContext = new AudioContext();
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a remote participant's audio track through Web Audio API
|
||||
* instead of letting LiveKit auto-attach an <audio> element.
|
||||
*/
|
||||
function attachTrackToWebAudio(track: RemoteTrack, participant: RemoteParticipant) {
|
||||
if (track.kind !== Track.Kind.Audio) return;
|
||||
|
||||
const mediaStream = track.mediaStream;
|
||||
if (!mediaStream) return;
|
||||
|
||||
const ctx = ensureAudioContext();
|
||||
|
||||
// Clean up previous source for this participant
|
||||
detachParticipantAudio(participant.identity);
|
||||
|
||||
const source = ctx.createMediaStreamSource(mediaStream);
|
||||
const gain = ctx.createGain();
|
||||
gain.gain.value = 1.0;
|
||||
|
||||
source.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
audioSources.set(participant.identity, source);
|
||||
gainNodes.set(participant.identity, gain);
|
||||
}
|
||||
|
||||
function detachParticipantAudio(identity: string) {
|
||||
const source = audioSources.get(identity);
|
||||
if (source) {
|
||||
source.disconnect();
|
||||
audioSources.delete(identity);
|
||||
}
|
||||
const gain = gainNodes.get(identity);
|
||||
if (gain) {
|
||||
gain.disconnect();
|
||||
gainNodes.delete(identity);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Room connection ────────────────────────────────────────────────────────
|
||||
|
||||
export async function connect(wsUrl: string, token: string): Promise<void> {
|
||||
if (room) {
|
||||
await disconnect();
|
||||
}
|
||||
|
||||
const newRoom = new Room({
|
||||
// Disable auto-attach — we route audio through Web Audio API
|
||||
audioCaptureDefaults: {
|
||||
autoGainControl: true,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
},
|
||||
adaptiveStream: true,
|
||||
dynacast: true,
|
||||
});
|
||||
|
||||
// Set up event handlers before connecting
|
||||
newRoom
|
||||
.on(RoomEvent.Connected, () => {
|
||||
_localIdentity = newRoom.localParticipant.identity;
|
||||
setStatus('connected');
|
||||
refreshParticipants();
|
||||
})
|
||||
.on(RoomEvent.Reconnecting, () => {
|
||||
setStatus('reconnecting');
|
||||
})
|
||||
.on(RoomEvent.Reconnected, () => {
|
||||
setStatus('connected');
|
||||
refreshParticipants();
|
||||
})
|
||||
.on(RoomEvent.Disconnected, () => {
|
||||
cleanupAudio();
|
||||
setStatus('disconnected');
|
||||
_participants = [];
|
||||
notify();
|
||||
})
|
||||
.on(RoomEvent.ParticipantConnected, () => {
|
||||
refreshParticipants();
|
||||
})
|
||||
.on(RoomEvent.ParticipantDisconnected, (participant: RemoteParticipant) => {
|
||||
detachParticipantAudio(participant.identity);
|
||||
refreshParticipants();
|
||||
})
|
||||
.on(RoomEvent.TrackSubscribed, (track: RemoteTrack, pub: RemoteTrackPublication, participant: RemoteParticipant) => {
|
||||
attachTrackToWebAudio(track, participant);
|
||||
refreshParticipants();
|
||||
})
|
||||
.on(RoomEvent.TrackUnsubscribed, (_track: RemoteTrack, _pub: RemoteTrackPublication, participant: RemoteParticipant) => {
|
||||
detachParticipantAudio(participant.identity);
|
||||
refreshParticipants();
|
||||
})
|
||||
.on(RoomEvent.TrackMuted, () => {
|
||||
refreshParticipants();
|
||||
})
|
||||
.on(RoomEvent.TrackUnmuted, () => {
|
||||
refreshParticipants();
|
||||
})
|
||||
.on(RoomEvent.ActiveSpeakersChanged, () => {
|
||||
refreshParticipants();
|
||||
});
|
||||
|
||||
room = newRoom;
|
||||
setStatus('connecting');
|
||||
|
||||
await newRoom.connect(wsUrl, token, {
|
||||
autoSubscribe: true,
|
||||
});
|
||||
|
||||
// Enable microphone after connecting
|
||||
await newRoom.localParticipant.setMicrophoneEnabled(true);
|
||||
|
||||
refreshParticipants();
|
||||
}
|
||||
|
||||
export async function disconnect(): Promise<void> {
|
||||
if (!room) return;
|
||||
|
||||
cleanupAudio();
|
||||
await room.disconnect();
|
||||
room = null;
|
||||
_status = 'disconnected';
|
||||
_participants = [];
|
||||
_localIdentity = '';
|
||||
notify();
|
||||
}
|
||||
|
||||
function cleanupAudio() {
|
||||
for (const [identity] of audioSources) {
|
||||
detachParticipantAudio(identity);
|
||||
}
|
||||
if (audioContext && audioContext.state !== 'closed') {
|
||||
audioContext.close();
|
||||
audioContext = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle local microphone mute */
|
||||
export async function toggleMute(): Promise<boolean> {
|
||||
if (!room) return false;
|
||||
const enabled = room.localParticipant.isMicrophoneEnabled;
|
||||
await room.localParticipant.setMicrophoneEnabled(!enabled);
|
||||
refreshParticipants();
|
||||
return !enabled;
|
||||
}
|
||||
|
||||
/** Get the GainNode for a participant (for future mixer integration) */
|
||||
export function getParticipantGain(identity: string): GainNode | undefined {
|
||||
return gainNodes.get(identity);
|
||||
}
|
||||
|
||||
export function isConnected(): boolean {
|
||||
return room?.state === ConnectionState.Connected;
|
||||
}
|
||||
|
|
@ -164,7 +164,7 @@
|
|||
{:else if trait === 'calendar'}
|
||||
<CalendarTrait collection={collectionNode} config={traits[trait]} userId={nodeId} />
|
||||
{:else if trait === 'recording'}
|
||||
<RecordingTrait collection={collectionNode} config={traits[trait]} />
|
||||
<RecordingTrait collection={collectionNode} config={traits[trait]} {accessToken} />
|
||||
{:else if trait === 'transcription'}
|
||||
<TranscriptionTrait collection={collectionNode} config={traits[trait]} />
|
||||
{:else if trait === 'studio'}
|
||||
|
|
|
|||
3
tasks.md
3
tasks.md
|
|
@ -177,8 +177,7 @@ Uavhengige faser kan fortsatt plukkes.
|
|||
|
||||
Ref: `docs/features/lydmixer.md`
|
||||
|
||||
- [~] 16.1 LiveKit-klient i frontend: installer `livekit-client`, koble til rom, vis deltakerliste. Deaktiver LiveKit sin auto-attach av `<audio>`-elementer — lyd rutes gjennom Web Audio API i stedet.
|
||||
> Påbegynt: 2026-03-18T04:44
|
||||
- [x] 16.1 LiveKit-klient i frontend: installer `livekit-client`, koble til rom, vis deltakerliste. Deaktiver LiveKit sin auto-attach av `<audio>`-elementer — lyd rutes gjennom Web Audio API i stedet.
|
||||
- [ ] 16.2 Web Audio mixer-graf: opprett `AudioContext`, `MediaStreamSourceNode` per remote track → per-kanal `GainNode` → master `GainNode` → `destination`. `AnalyserNode` per kanal for VU-meter.
|
||||
- [ ] 16.3 Mixer-UI (MixerTrait-komponent): kanalstripe per deltaker med volumslider (0–150%), nød-mute-knapp (stor, rød), VU-meter (canvas/CSS), navnelabel. Master-fader og master-mute. Responsivt design (mobil: kompakt fader-modus).
|
||||
- [ ] 16.4 Delt mixer-kontroll via SpacetimeDB: `MixerChannel`-tabell + reducers (`set_gain`, `set_mute`, `toggle_effect`). Frontend abonnerer og oppdaterer Web Audio-graf ved endring fra andre deltakere. Visuell feedback (sliders beveger seg i sanntid). Tilgangskontroll: eier/admin kan sette deltaker til viewer-modus.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue