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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user