feat: implement Discord-like stream widget system with separate tiles

Streams now appear as separate tiles in the voice grid alongside the
user's camera/avatar tile, matching Discord's model. Each stream tile
has independent volume, mute, watch/unwatch controls, quality badges,
and stream attenuation that ducks audio when someone speaks.
This commit is contained in:
Jannis Braun
2026-02-20 03:59:28 +01:00
parent a8656e6a3b
commit 1aa40cca15
23 changed files with 2216 additions and 482 deletions
+275 -64
View File
@@ -2,9 +2,9 @@ import { useState, useCallback, useRef, useEffect } from 'react';
import { Room, RoomEvent, Track, ConnectionState, VideoPresets, VideoPreset, } from 'livekit-client';
import { api } from '../api/client';
import { useVoiceStore } from '../stores/voiceStore';
import { AudioManager } from '../audio/AudioManager';
/**
* OPENCORD NATIVE OVERDRIVE PIPELINE v22
* "Soft-Launch Protocol": Always starts low to clear handshake, then upgrades to target.
* OPENCORD NATIVE OVERDRIVE PIPELINE v32
*/
const QUALITY_MAP = {
'1080p60': new VideoPreset(1920, 1080, 12_000_000, 60),
@@ -19,6 +19,40 @@ let _activeRoom = null;
export function getActiveRoom() {
return _activeRoom;
}
export function deriveGridTiles(participants) {
const tiles = [];
for (const p of participants) {
tiles.push({
kind: 'user',
key: p.identity,
participant: p,
videoTrack: (p.isCameraOn && p.videoTrack?.readyState === 'live') ? p.videoTrack : null,
audioTrack: p.audioTrack,
});
if (p.isScreenSharing) {
tiles.push({
kind: 'stream',
key: `${p.identity}:stream`,
participant: p,
screenTrack: p.screenTrack,
screenAudioTrack: p.screenAudioTrack,
});
}
}
return tiles;
}
export function setStreamSubscription(room, targetIdentity, subscribed) {
if (!room)
return;
const rp = room.remoteParticipants.get(targetIdentity);
if (!rp)
return;
rp.trackPublications.forEach((pub) => {
if (pub.source === Track.Source.ScreenShare || pub.source === Track.Source.ScreenShareAudio) {
pub.setSubscribed(subscribed);
}
});
}
function parseIdentity(identity) {
const parts = identity.split(':');
return { userId: parts[0] ?? identity, username: parts[1] ?? identity };
@@ -33,13 +67,11 @@ async function applyOverdriveHammer(room, source, preset) {
const pc = engine?.pcManager?.publisher?.pc || engine?.publisher?.pc || engine?.pc;
if (pc) {
const senders = pc.getSenders();
const sender = senders.find(s => s.track?.id === pub.track?.mediaStreamTrack?.id);
const sender = senders.find(s => s.track?.id === pub.track.mediaStreamTrack?.id);
if (sender) {
const params = sender.getParameters();
if (params.encodings && params.encodings[0]) {
console.log(`[Overdrive] Upgrading ${source} to ${preset.encoding.maxBitrate}bps`);
params.encodings[0].maxBitrate = preset.encoding.maxBitrate;
// Gentle floor to keep stable
params.encodings[0].minBitrate = 2_000_000;
params.encodings[0].maxFramerate = preset.encoding.maxFramerate;
params.encodings[0].networkPriority = 'high';
@@ -49,9 +81,6 @@ async function applyOverdriveHammer(room, source, preset) {
}
}
}
if (pub.track.mediaStreamTrack) {
pub.track.mediaStreamTrack.contentHint = 'motion';
}
}
catch (err) { }
}
@@ -60,27 +89,43 @@ export function useLiveKit() {
const [participants, setParticipants] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [connectionState, setConnectionState] = useState(ConnectionState.Disconnected);
const [connectedChannelId, setConnectedChannelId] = useState(null);
const [connectionError, setConnectionError] = useState(null);
const roomRef = useRef(null);
const connectedChannelRef = useRef(null);
const isMuted = useVoiceStore((s) => s.isMuted);
const isDeafened = useVoiceStore((s) => s.isDeafened);
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const videoQuality = useVoiceStore((s) => s.videoQuality);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const inputVolume = useVoiceStore((s) => s.inputVolume);
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
const updateParticipants = useCallback(() => {
const r = roomRef.current;
if (!r)
return;
const allParticipants = [];
const processParticipant = (p, isLocal) => {
if (!p.identity)
return;
const { userId, username } = parseIdentity(p.identity);
let audioTrack = null;
let videoTrack = null;
let screenTrack = null;
let screenAudioTrack = null;
let hasScreenSharePublication = false;
p.trackPublications.forEach((pub) => {
// Detect screen share publication even if unsubscribed
if (pub.source === Track.Source.ScreenShare)
hasScreenSharePublication = true;
const track = pub.track;
if (!track)
return;
// Strict check: Track must be subscribed AND not muted to be considered "active"
if (pub.isMuted || !pub.isSubscribed)
return;
const mt = track.mediaStreamTrack;
if (!mt || mt.readyState !== 'live')
return;
@@ -88,46 +133,150 @@ export function useLiveKit() {
audioTrack = mt;
else if (pub.source === Track.Source.Camera && p.isCameraEnabled)
videoTrack = mt;
else if (pub.source === Track.Source.ScreenShare && p.isScreenShareEnabled)
else if (pub.source === Track.Source.ScreenShare)
screenTrack = mt;
else if (pub.source === Track.Source.ScreenShareAudio)
screenAudioTrack = mt;
});
let isDeafened = false;
try {
if (p.metadata) {
const meta = JSON.parse(p.metadata);
isDeafened = meta.deafened === true;
}
const userState = useVoiceStore.getState().voiceUserStates.get(userId);
let isPartDeafened = false;
let isPartMuted = !p.isMicrophoneEnabled;
if (isLocal) {
isPartDeafened = useVoiceStore.getState().isDeafened;
isPartMuted = useVoiceStore.getState().isMuted;
}
catch { }
allParticipants.push({ identity: p.identity, userId, username, isSpeaking: p.isSpeaking, isMuted: !p.isMicrophoneEnabled, isDeafened, isCameraOn: p.isCameraEnabled, isScreenSharing: p.isScreenShareEnabled, isLocal, audioTrack, videoTrack, screenTrack });
else {
isPartDeafened = userState?.isDeafened ?? useVoiceStore.getState().deafenedUserIds.has(userId);
if (userState)
isPartMuted = userState.isMuted;
}
allParticipants.push({
identity: p.identity,
userId,
username,
isSpeaking: p.isSpeaking,
isMuted: isPartMuted,
isDeafened: isPartDeafened,
isCameraOn: !!videoTrack,
isScreenSharing: hasScreenSharePublication, // True even when unsubscribed
isLocal,
audioTrack,
videoTrack,
screenTrack,
screenAudioTrack,
});
};
processParticipant(r.localParticipant, true);
r.remoteParticipants.forEach((p) => processParticipant(p, false));
setParticipants(allParticipants);
}, []);
const handleDataReceived = useCallback((payload, participant) => {
try {
const text = new TextDecoder().decode(payload);
const msg = JSON.parse(text);
if (msg.type === 'deafen' && participant) {
const { userId } = parseIdentity(participant.identity);
useVoiceStore.getState().setUserDeafened(userId, msg.deafened === true);
updateParticipants();
}
}
catch { }
}, [updateParticipants]);
// Handle Input Device & Mute Logic via AudioManager
useEffect(() => {
const r = roomRef.current;
if (!r || !isConnected)
return;
const syncMic = async () => {
try {
const audioManager = AudioManager.getInstance();
// If muted or deafened, unpublish mic
if (isMuted || isDeafened) {
const pub = r.localParticipant.getTrackPublications().find(p => p.source === Track.Source.Microphone);
if (pub) {
await r.localParticipant.unpublishTrack(pub.track);
}
return;
}
// Ensure device is set and volume is sync'd
await audioManager.setInputDevice(inputDeviceId);
audioManager.setInputVolume(inputVolume);
// Check if already published
const existingPub = r.localParticipant.getTrackPublications().find(p => p.source === Track.Source.Microphone);
if (existingPub && existingPub.track) {
// If track is alive, we are good.
if (existingPub.track.mediaStreamTrack?.readyState === 'live') {
return;
}
// If track died, unpublish so we can republish
await r.localParticipant.unpublishTrack(existingPub.track);
}
// Get a FRESH track (clone) for this specific publication
const audioTrack = audioManager.getFreshTrack();
if (!audioTrack)
return;
console.log('[LiveKit] Publishing fresh microphone track');
await r.localParticipant.publishTrack(audioTrack, {
name: 'microphone',
source: Track.Source.Microphone,
});
}
catch (err) {
console.error('[LiveKit] Failed to sync mic state:', err);
}
};
syncMic();
// Re-sync when AudioManager resumes
const unsubscribe = AudioManager.getInstance().onResumed(() => {
syncMic();
});
return () => {
unsubscribe();
};
}, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected]);
const connect = useCallback(async (channelId) => {
if (connectedChannelRef.current === channelId && roomRef.current)
if (connectedChannelRef.current === channelId && roomRef.current?.state === ConnectionState.Connected)
return;
const gen = ++_connectGeneration;
if (roomRef.current) {
try {
roomRef.current.disconnect();
}
catch { }
roomRef.current = null;
}
// 1. Reset state immediately to reflect "Loading/Switching" in UI
setRoom(null);
setParticipants([]);
setIsConnected(false);
setIsConnecting(true);
setConnectionState(ConnectionState.Connecting);
setConnectionError(null);
setConnectedChannelId(null); // Clear this so AppLayout knows we are transitioning
useVoiceStore.getState().setIsLiveKitConnected(false);
// 2. Strictly disconnect previous room (Local Ref OR Global Ref)
// This handles cases where AppLayout might have remounted, losing roomRef but leaving _activeRoom alive.
const roomToDisconnect = roomRef.current || _activeRoom;
if (roomToDisconnect) {
try {
console.log('[LiveKit] Disconnecting previous room:', roomToDisconnect.name);
await roomToDisconnect.disconnect();
}
catch (err) {
console.warn('Error disconnecting from previous room:', err);
}
roomRef.current = null;
_activeRoom = null;
}
try {
const { token, url } = await api.livekit.token(channelId);
if (gen !== _connectGeneration)
return;
// Disable simulcast for better 60fps stability on local networks
const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } });
roomRef.current = newRoom;
const guardedUpdate = () => { if (roomRef.current === newRoom)
updateParticipants(); };
newRoom.on(RoomEvent.ParticipantConnected, guardedUpdate);
// ... existing event listeners ...
newRoom.on(RoomEvent.ParticipantConnected, (participant) => {
guardedUpdate();
if (useVoiceStore.getState().isDeafened) {
const encoder = new TextEncoder();
newRoom.localParticipant.publishData(encoder.encode(JSON.stringify({ type: 'deafen', deafened: true })), { reliable: true }).catch(() => { });
}
});
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
newRoom.on(RoomEvent.TrackSubscribed, guardedUpdate);
newRoom.on(RoomEvent.TrackUnsubscribed, guardedUpdate);
@@ -137,16 +286,42 @@ export function useLiveKit() {
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
newRoom.on(RoomEvent.ParticipantMetadataChanged, guardedUpdate);
newRoom.on(RoomEvent.TrackPublished, (publication, participant) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(participant.identity);
useVoiceStore.getState().watchStream(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.TrackUnpublished, (publication, participant) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(participant.identity);
const state = useVoiceStore.getState();
state.unwatchStream(userId);
state.clearStreamVolume(userId);
state.clearStreamMute(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.DataReceived, handleDataReceived);
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) {
setConnectionState(state);
const connected = state === ConnectionState.Connected;
const connecting = state === ConnectionState.Connecting || state === ConnectionState.Reconnecting;
setIsConnected(connected);
setIsConnecting(connecting);
useVoiceStore.getState().setIsLiveKitConnected(connected);
if (connected) {
updateParticipants();
}
}
});
newRoom.on(RoomEvent.Disconnected, () => {
if (roomRef.current !== newRoom)
return;
setConnectionState(ConnectionState.Disconnected);
setConnectedChannelId(null);
roomRef.current = null;
_activeRoom = null;
setIsConnected(false);
@@ -161,18 +336,18 @@ export function useLiveKit() {
}
_activeRoom = newRoom;
connectedChannelRef.current = channelId;
setConnectedChannelId(channelId);
setRoom(newRoom);
setIsConnected(true);
useVoiceStore.getState().setIsLiveKitConnected(true);
updateParticipants();
useVoiceStore.setState({ isMuted: false, isCameraOn: false, isScreenSharing: false });
try {
await newRoom.localParticipant.setMicrophoneEnabled(true);
updateParticipants();
}
catch {
useVoiceStore.setState({ isMuted: true });
// Initial mute state check
const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState();
useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false });
if (wasDeafened) {
newRoom.remoteParticipants.forEach((p) => p.setVolume(0));
}
updateParticipants();
}
catch (err) {
if (gen === _connectGeneration)
@@ -182,17 +357,30 @@ export function useLiveKit() {
if (gen === _connectGeneration)
setIsConnecting(false);
}
}, [updateParticipants]);
}, [updateParticipants, handleDataReceived]);
const connectDm = useCallback(async (dmChannelId) => {
const gen = ++_connectGeneration;
if (roomRef.current) {
try {
roomRef.current.disconnect();
}
catch { }
roomRef.current = null;
}
// 1. Reset state immediately
setRoom(null);
setParticipants([]);
setIsConnected(false);
setIsConnecting(true);
setConnectionState(ConnectionState.Connecting);
setConnectionError(null);
setConnectedChannelId(null);
// 2. Strictly disconnect previous room (Local Ref OR Global Ref)
const roomToDisconnect = roomRef.current || _activeRoom;
if (roomToDisconnect) {
try {
console.log('[LiveKit] Disconnecting previous room (DM):', roomToDisconnect.name);
await roomToDisconnect.disconnect();
}
catch (err) {
console.warn('Error disconnecting from previous room:', err);
}
roomRef.current = null;
_activeRoom = null;
}
try {
const { token, url } = await api.livekit.dmToken(dmChannelId);
if (gen !== _connectGeneration)
@@ -210,11 +398,34 @@ export function useLiveKit() {
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
newRoom.on(RoomEvent.TrackPublished, (publication, participant) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(participant.identity);
useVoiceStore.getState().watchStream(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.TrackUnpublished, (publication, participant) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(participant.identity);
const state = useVoiceStore.getState();
state.unwatchStream(userId);
state.clearStreamVolume(userId);
state.clearStreamMute(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) {
setConnectionState(state);
const connected = state === ConnectionState.Connected;
const connecting = state === ConnectionState.Connecting || state === ConnectionState.Reconnecting;
setIsConnected(connected);
setIsConnecting(connecting);
useVoiceStore.getState().setIsLiveKitConnected(connected);
if (connected) {
updateParticipants();
}
}
});
await newRoom.connect(url, token);
@@ -222,20 +433,20 @@ export function useLiveKit() {
newRoom.disconnect();
return;
}
const fullId = `dm-${dmChannelId}`;
_activeRoom = newRoom;
connectedChannelRef.current = `dm-${dmChannelId}`;
connectedChannelRef.current = fullId;
setConnectedChannelId(fullId);
setRoom(newRoom);
setIsConnected(true);
useVoiceStore.getState().setIsLiveKitConnected(true);
updateParticipants();
useVoiceStore.setState({ isMuted: false, isCameraOn: false, isScreenSharing: false });
try {
await newRoom.localParticipant.setMicrophoneEnabled(true);
updateParticipants();
}
catch {
useVoiceStore.setState({ isMuted: true });
const { isMuted: wasMuted, isDeafened: wasDeafened } = useVoiceStore.getState();
useVoiceStore.setState({ isCameraOn: false, isScreenSharing: false });
if (wasDeafened) {
newRoom.remoteParticipants.forEach((p) => p.setVolume(0));
}
updateParticipants();
}
catch (err) {
if (gen === _connectGeneration)
@@ -245,30 +456,32 @@ export function useLiveKit() {
if (gen === _connectGeneration)
setIsConnecting(false);
}
}, [updateParticipants]);
}, [updateParticipants, handleDataReceived]);
const disconnect = useCallback(async () => {
_connectGeneration++;
connectedChannelRef.current = null;
setConnectedChannelId(null);
if (roomRef.current) {
await roomRef.current.disconnect();
roomRef.current = null;
_activeRoom = null;
connectedChannelRef.current = null;
setRoom(null);
setIsConnected(false);
setIsConnecting(false);
setConnectionState(ConnectionState.Disconnected);
setParticipants([]);
useVoiceStore.getState().setIsLiveKitConnected(false);
}
}, []);
const toggleMic = useCallback(async () => { if (roomRef.current) {
await roomRef.current.localParticipant.setMicrophoneEnabled(isMuted);
updateParticipants();
} }, [isMuted, updateParticipants]);
const toggleMic = useCallback(async () => {
await AudioManager.getInstance().resumeContext();
useVoiceStore.getState().toggleMic();
}, []);
const toggleCamera = useCallback(async () => {
if (roomRef.current) {
if (!isCameraOn) {
const preset = QUALITY_MAP[videoQuality] || VideoPresets.h720;
await roomRef.current.localParticipant.setCameraEnabled(true, { resolution: preset.resolution, frameRate: preset.encoding.maxFramerate }, { videoCodec: 'h264', videoEncoding: preset.encoding, simulcast: false });
// Soft Start Camera
setTimeout(() => { if (roomRef.current)
applyOverdriveHammer(roomRef.current, Track.Source.Camera, preset); }, 2000);
}
@@ -282,9 +495,8 @@ export function useLiveKit() {
if (roomRef.current) {
if (!isScreenSharing) {
const preset = QUALITY_MAP[videoQuality] || AUTO_PRESET;
console.log('[LiveKit] Soft-Launching Screen Share (360p start)...');
// SOFT LAUNCH: Start at 360p 30fps to clear handshake
const track = await roomRef.current.localParticipant.setScreenShareEnabled(true, {
audio: true,
resolution: VideoPresets.h360.resolution,
// @ts-ignore
frameRate: 30,
@@ -292,10 +504,8 @@ export function useLiveKit() {
videoCodec: 'h264', videoEncoding: VideoPresets.h360.encoding, simulcast: false, priority: 'very-high'
});
if (track) {
// UPGRADE: After 2 seconds, switch to full 60fps quality
setTimeout(async () => {
if (roomRef.current && isScreenSharing) {
console.log('[LiveKit] Upgrading to Target Quality...');
const screenPub = roomRef.current.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
if (screenPub?.track?.mediaStreamTrack) {
await screenPub.track.mediaStreamTrack.applyConstraints({
@@ -307,7 +517,6 @@ export function useLiveKit() {
}
}
}, 2000);
// Re-apply hammer
setTimeout(() => applyOverdriveHammer(roomRef.current, Track.Source.ScreenShare, preset), 5000);
}
}
@@ -317,7 +526,9 @@ export function useLiveKit() {
updateParticipants();
}
}, [isScreenSharing, videoQuality, updateParticipants]);
// Sync quality changes
useEffect(() => {
updateParticipants();
}, [voiceUserStates, isMuted, isDeafened, updateParticipants]);
useEffect(() => {
if (!room)
return;
@@ -371,5 +582,5 @@ export function useLiveKit() {
_activeRoom = null;
} };
}, []);
return { room, participants, isConnected, isConnecting, connectionError, connect, connectDm, disconnect, toggleMic, toggleCamera, toggleScreenShare };
return { room, participants, isConnected, isConnecting, connectionState, connectedChannelId, connectionError, connect, connectDm, disconnect, toggleMic, toggleCamera, toggleScreenShare };
}
+98 -7
View File
@@ -5,6 +5,7 @@ import {
Track,
Participant,
RemoteParticipant,
RemoteTrackPublication,
ConnectionState,
VideoPresets,
VideoPreset,
@@ -52,6 +53,58 @@ export interface ParticipantInfo {
screenAudioTrack: MediaStreamTrack | null;
}
export interface UserTile {
kind: 'user';
key: string; // participant.identity
participant: ParticipantInfo;
videoTrack: MediaStreamTrack | null; // camera only
audioTrack: MediaStreamTrack | null; // mic
}
export interface StreamTile {
kind: 'stream';
key: string; // `${identity}:stream`
participant: ParticipantInfo;
screenTrack: MediaStreamTrack | null;
screenAudioTrack: MediaStreamTrack | null;
}
export type GridTile = UserTile | StreamTile;
export function deriveGridTiles(participants: ParticipantInfo[]): GridTile[] {
const tiles: GridTile[] = [];
for (const p of participants) {
tiles.push({
kind: 'user',
key: p.identity,
participant: p,
videoTrack: (p.isCameraOn && p.videoTrack?.readyState === 'live') ? p.videoTrack : null,
audioTrack: p.audioTrack,
});
if (p.isScreenSharing) {
tiles.push({
kind: 'stream',
key: `${p.identity}:stream`,
participant: p,
screenTrack: p.screenTrack,
screenAudioTrack: p.screenAudioTrack,
});
}
}
return tiles;
}
export function setStreamSubscription(room: Room | null, targetIdentity: string, subscribed: boolean) {
if (!room) return;
const rp = room.remoteParticipants.get(targetIdentity);
if (!rp) return;
rp.trackPublications.forEach((pub) => {
if (pub.source === Track.Source.ScreenShare || pub.source === Track.Source.ScreenShareAudio) {
(pub as RemoteTrackPublication).setSubscribed(subscribed);
}
});
}
function parseIdentity(identity: string): { userId: string; username: string } {
const parts = identity.split(':');
return { userId: parts[0] ?? identity, username: parts[1] ?? identity };
@@ -116,7 +169,11 @@ export function useLiveKit() {
let videoTrack: MediaStreamTrack | null = null;
let screenTrack: MediaStreamTrack | null = null;
let screenAudioTrack: MediaStreamTrack | null = null;
let hasScreenSharePublication = false;
p.trackPublications.forEach((pub) => {
// Detect screen share publication even if unsubscribed
if (pub.source === Track.Source.ScreenShare) hasScreenSharePublication = true;
const track = pub.track;
if (!track) return;
// Strict check: Track must be subscribed AND not muted to be considered "active"
@@ -130,11 +187,11 @@ export function useLiveKit() {
else if (pub.source === Track.Source.ScreenShare) screenTrack = mt;
else if (pub.source === Track.Source.ScreenShareAudio) screenAudioTrack = mt;
});
const userState = useVoiceStore.getState().voiceUserStates.get(userId);
let isPartDeafened = false;
let isPartMuted = !p.isMicrophoneEnabled;
if (isLocal) {
isPartDeafened = useVoiceStore.getState().isDeafened;
isPartMuted = useVoiceStore.getState().isMuted;
@@ -150,8 +207,8 @@ export function useLiveKit() {
isSpeaking: p.isSpeaking,
isMuted: isPartMuted,
isDeafened: isPartDeafened,
isCameraOn: !!videoTrack, // Strictly derived from active track
isScreenSharing: !!screenTrack, // Strictly derived from active track
isCameraOn: !!videoTrack,
isScreenSharing: hasScreenSharePublication, // True even when unsubscribed
isLocal,
audioTrack,
videoTrack,
@@ -294,6 +351,23 @@ export function useLiveKit() {
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
newRoom.on(RoomEvent.ParticipantMetadataChanged, guardedUpdate);
newRoom.on(RoomEvent.TrackPublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(participant.identity);
useVoiceStore.getState().watchStream(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.TrackUnpublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(participant.identity);
const state = useVoiceStore.getState();
state.unwatchStream(userId);
state.clearStreamVolume(userId);
state.clearStreamMute(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.DataReceived, handleDataReceived);
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) {
@@ -384,17 +458,34 @@ export function useLiveKit() {
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
newRoom.on(RoomEvent.TrackUnmuted, guardedUpdate);
newRoom.on(RoomEvent.ActiveSpeakersChanged, guardedUpdate);
newRoom.on(RoomEvent.TrackPublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(participant.identity);
useVoiceStore.getState().watchStream(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.TrackUnpublished, (publication: RemoteTrackPublication, participant: RemoteParticipant) => {
if (publication.source === Track.Source.ScreenShare) {
const { userId } = parseIdentity(participant.identity);
const state = useVoiceStore.getState();
state.unwatchStream(userId);
state.clearStreamVolume(userId);
state.clearStreamMute(userId);
}
guardedUpdate();
});
newRoom.on(RoomEvent.ConnectionStateChanged, (state) => {
if (roomRef.current === newRoom) {
setConnectionState(state);
const connected = state === ConnectionState.Connected;
const connecting = state === ConnectionState.Connecting || state === ConnectionState.Reconnecting;
setIsConnected(connected);
setIsConnecting(connecting);
useVoiceStore.getState().setIsLiveKitConnected(connected);
if (connected) {
updateParticipants();
}
+27 -3
View File
@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react';
import React, { useEffect, useRef } from 'react';
import { useAuthStore } from '../stores/authStore';
import { useServerStore } from '../stores/serverStore';
import { useChatStore } from '../stores/chatStore';
@@ -13,7 +13,7 @@ function handleEvent(event) {
const { setUser } = useAuthStore.getState();
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember } = useServerStore.getState();
const { addMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, setVoiceUsers } = useVoiceStore.getState();
const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState();
switch (event.type) {
case 'ready':
setUser(event.user);
@@ -41,6 +41,22 @@ function handleEvent(event) {
setVoiceUsers(channelId, userIds);
}
}
// Populate voice user statuses (mute/deafen) from server
if (event.voiceUserStates) {
for (const [uid, status] of Object.entries(event.voiceUserStates)) {
setVoiceUserStatus(uid, status.isMuted, status.isDeafened);
}
}
// Re-register in voice channel if we're still connected to LiveKit
// (WebSocket reconnect causes server to drop our voice tracking)
{
const { currentVoiceChannelId, isMuted: curMuted, isDeafened: curDeafened } = useVoiceStore.getState();
if (currentVoiceChannelId) {
console.log('[WebSocket] Re-syncing voice status on reconnect:', { currentVoiceChannelId, curMuted, curDeafened });
wsSend({ type: 'voice_join', channelId: currentVoiceChannelId });
wsSend({ type: 'voice_status', isMuted: curMuted, isDeafened: curDeafened });
}
}
break;
case 'message_created':
addMessage(event.message.channelId, event.message);
@@ -71,6 +87,9 @@ function handleEvent(event) {
removeVoiceUser(event.channelId, event.userId);
}
break;
case 'voice_status_update':
setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened);
break;
case 'member_joined':
addMember(event.member);
break;
@@ -226,6 +245,7 @@ export function wsSend(event) {
export function useWebSocket() {
const token = useAuthStore((s) => s.token);
const prevToken = useRef(token);
const [isConnected, setIsConnected] = React.useState(false);
useEffect(() => {
if (token && (!isInitialized || token !== prevToken.current)) {
currentToken = token;
@@ -238,9 +258,13 @@ export function useWebSocket() {
prevToken.current = token;
}, [token]);
useEffect(() => {
const checkStatus = setInterval(() => {
setIsConnected(!!globalWs && globalWs.readyState === WebSocket.OPEN);
}, 500);
return () => {
clearInterval(checkStatus);
disconnect();
};
}, []);
return { send: wsSend };
return { send: wsSend, isConnected };
}