chore: remove 62 tsc emit artifacts from src/, add noEmit to tsconfig
tsc was emitting compiled .js files directly into packages/web/src/ alongside the .tsx source files because noEmit was not set. These artifacts were never used — Vite compiles from .tsx source directly. - Add noEmit: true to packages/web/tsconfig.json (tsc = type-check only) - Delete all 62 orphaned .js files from src/ (-6,129 lines) - Add packages/web/src/**/*.js to .gitignore as safeguard
This commit is contained in:
@@ -1,88 +0,0 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { AudioManager } from '../audio/AudioManager';
|
||||
/**
|
||||
* Hybrid audio pipeline for a single remote audio track.
|
||||
*
|
||||
* Architecture:
|
||||
* 1. A MUTED <audio> element keeps Chrome's WebRTC audio pipeline alive
|
||||
* for the track. Chrome requires an HTML media element consuming a
|
||||
* WebRTC MediaStreamTrack or it stops processing it. The element is
|
||||
* always muted (volume=0, muted=true) — it never produces audible output.
|
||||
*
|
||||
* 2. A Web Audio pipeline handles ALL actual audio output:
|
||||
* MediaStreamTrack -> MediaStream -> MediaStreamAudioSourceNode -> GainNode -> ctx.destination
|
||||
*
|
||||
* This gives us:
|
||||
* - Chrome compatibility (muted <audio> keep-alive)
|
||||
* - No ducking (all elements are muted, only Web Audio produces sound)
|
||||
* - Clean mixing (single ctx.destination for all tracks)
|
||||
* - Full volume range (0.0 – 4.0+) via GainNode
|
||||
* - Smooth transitions via setTargetAtTime (no clicks/pops)
|
||||
*/
|
||||
export function useAudioTrackPlayer(opts) {
|
||||
const { track, volume, muted } = opts;
|
||||
const audioRef = useRef(null);
|
||||
const sourceRef = useRef(null);
|
||||
const gainRef = useRef(null);
|
||||
// Keep current volume/muted in refs so Effect 1 can read them
|
||||
// for the initial ramp without depending on them
|
||||
const volumeRef = useRef(volume);
|
||||
const mutedRef = useRef(muted);
|
||||
volumeRef.current = volume;
|
||||
mutedRef.current = muted;
|
||||
// Effect 1: Track attachment (keep-alive) + Web Audio pipeline build
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
// Tear down previous Web Audio graph
|
||||
if (sourceRef.current) {
|
||||
sourceRef.current.disconnect();
|
||||
sourceRef.current = null;
|
||||
}
|
||||
if (gainRef.current) {
|
||||
gainRef.current.disconnect();
|
||||
gainRef.current = null;
|
||||
}
|
||||
if (!track) {
|
||||
if (audioEl)
|
||||
audioEl.srcObject = null;
|
||||
return;
|
||||
}
|
||||
// --- Keep-alive: attach track to <audio> element (always muted) ---
|
||||
// Chrome needs an HTML element consuming the WebRTC track or it
|
||||
// stops the audio pipeline for that track entirely.
|
||||
if (audioEl) {
|
||||
audioEl.srcObject = new MediaStream([track]);
|
||||
audioEl.muted = true;
|
||||
audioEl.volume = 0;
|
||||
audioEl.play().catch(() => { });
|
||||
}
|
||||
// --- Web Audio pipeline for actual output ---
|
||||
const ctx = AudioManager.getInstance().ensureContext();
|
||||
const stream = new MediaStream([track]);
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
const gain = ctx.createGain();
|
||||
// Start gain at 0 to prevent pop, then ramp to target
|
||||
gain.gain.setValueAtTime(0, ctx.currentTime);
|
||||
const targetGain = mutedRef.current ? 0 : volumeRef.current;
|
||||
gain.gain.setTargetAtTime(targetGain, ctx.currentTime, 0.015);
|
||||
source.connect(gain);
|
||||
gain.connect(AudioManager.getInstance().getMasterOutput());
|
||||
sourceRef.current = source;
|
||||
gainRef.current = gain;
|
||||
return () => {
|
||||
source.disconnect();
|
||||
gain.disconnect();
|
||||
sourceRef.current = null;
|
||||
gainRef.current = null;
|
||||
};
|
||||
}, [track]);
|
||||
// Effect 2: Update gain when volume or muted changes (no graph rebuild)
|
||||
useEffect(() => {
|
||||
if (!gainRef.current)
|
||||
return;
|
||||
const ctx = AudioManager.getInstance().ensureContext();
|
||||
const targetGain = muted ? 0 : volume;
|
||||
gainRef.current.gain.setTargetAtTime(targetGain, ctx.currentTime, 0.015);
|
||||
}, [volume, muted]);
|
||||
return audioRef;
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
export function useAuth() {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const loadUser = useAuthStore((s) => s.loadUser);
|
||||
const navigate = useNavigate();
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
if (!user && !isLoading) {
|
||||
loadUser();
|
||||
}
|
||||
}, [token, user, isLoading, loadUser, navigate]);
|
||||
return { user, isLoading, isAuthenticated: !!token };
|
||||
}
|
||||
@@ -1,671 +0,0 @@
|
||||
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 v32
|
||||
*/
|
||||
const QUALITY_MAP = {
|
||||
'1080p60': new VideoPreset(1920, 1080, 12_000_000, 60),
|
||||
'1080p': new VideoPreset(1920, 1080, 8_000_000, 30),
|
||||
'720p60': new VideoPreset(1280, 720, 8_000_000, 60),
|
||||
'720p': new VideoPreset(1280, 720, 4_000_000, 30),
|
||||
'540p': new VideoPreset(960, 540, 2_000_000, 30),
|
||||
'360p': new VideoPreset(640, 360, 1_000_000, 30),
|
||||
};
|
||||
const AUTO_PRESET = QUALITY_MAP['720p60'];
|
||||
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 };
|
||||
}
|
||||
let _connectGeneration = 0;
|
||||
async function applyOverdriveHammer(room, source, preset) {
|
||||
try {
|
||||
const pub = room.localParticipant.getTrackPublications().find(p => p.source === source);
|
||||
if (!pub?.track)
|
||||
return;
|
||||
const engine = room.engine;
|
||||
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);
|
||||
if (sender) {
|
||||
const params = sender.getParameters();
|
||||
if (params.encodings && params.encodings[0]) {
|
||||
params.encodings[0].maxBitrate = preset.encoding.maxBitrate;
|
||||
params.encodings[0].minBitrate = 2_000_000;
|
||||
params.encodings[0].maxFramerate = preset.encoding.maxFramerate;
|
||||
params.encodings[0].networkPriority = 'high';
|
||||
// @ts-ignore
|
||||
params.degradationPreference = 'maintain-framerate';
|
||||
await sender.setParameters(params);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) { }
|
||||
}
|
||||
export function useLiveKit() {
|
||||
const [room, setRoom] = useState(null);
|
||||
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 echoCancellation = useVoiceStore((s) => s.echoCancellation);
|
||||
const noiseSuppression = useVoiceStore((s) => s.noiseSuppression);
|
||||
const autoGainControl = useVoiceStore((s) => s.autoGainControl);
|
||||
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
|
||||
const lastMicGenRef = useRef(0);
|
||||
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)
|
||||
return;
|
||||
if (!isLocal && !pub.isSubscribed)
|
||||
return;
|
||||
const mt = track.mediaStreamTrack;
|
||||
if (!mt || mt.readyState !== 'live')
|
||||
return;
|
||||
if (pub.source === Track.Source.Microphone)
|
||||
audioTrack = mt;
|
||||
else if (pub.source === Track.Source.Camera && p.isCameraEnabled)
|
||||
videoTrack = mt;
|
||||
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;
|
||||
}
|
||||
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
|
||||
// Mute uses setMicrophoneEnabled(false) to keep the track published (silence frames)
|
||||
// instead of unpublishTrack() which tears down the WebRTC transport.
|
||||
useEffect(() => {
|
||||
const r = roomRef.current;
|
||||
if (!r || !isConnected)
|
||||
return;
|
||||
const syncMic = async () => {
|
||||
try {
|
||||
const audioManager = AudioManager.getInstance();
|
||||
audioManager.setVoiceProcessing({ echoCancellation, noiseSuppression, autoGainControl });
|
||||
await audioManager.setRnnoiseEnabled(rnnoiseEnabled);
|
||||
audioManager.setScreenShareActive(isScreenSharing);
|
||||
const micPub = r.localParticipant.getTrackPublications()
|
||||
.find(p => p.source === Track.Source.Microphone);
|
||||
// If muted or deafened, mute the track in-place (keep it published)
|
||||
if (isMuted || isDeafened) {
|
||||
if (micPub?.track && !micPub.isMuted) {
|
||||
await r.localParticipant.setMicrophoneEnabled(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Not muted — ensure mic is published and live
|
||||
await audioManager.setInputDevice(inputDeviceId);
|
||||
audioManager.setInputVolume(inputVolume);
|
||||
const currentGen = audioManager.getStreamGeneration();
|
||||
if (micPub?.track) {
|
||||
// Track already published — check if it's still current
|
||||
if (micPub.track.mediaStreamTrack?.readyState === 'live' && lastMicGenRef.current === currentGen) {
|
||||
// Current and live — just unmute if needed
|
||||
if (micPub.isMuted) {
|
||||
await r.localParticipant.setMicrophoneEnabled(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Track is stale (device or constraint change) — replace it
|
||||
await r.localParticipant.unpublishTrack(micPub.track);
|
||||
}
|
||||
// Publish fresh track from AudioManager pipeline
|
||||
const audioTrack = audioManager.getFreshTrack();
|
||||
if (!audioTrack)
|
||||
return;
|
||||
console.log('[LiveKit] Publishing fresh microphone track (gen:', currentGen, ')');
|
||||
await r.localParticipant.publishTrack(audioTrack, {
|
||||
name: 'microphone',
|
||||
source: Track.Source.Microphone,
|
||||
});
|
||||
lastMicGenRef.current = currentGen;
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[LiveKit] Failed to sync mic state:', err);
|
||||
}
|
||||
};
|
||||
syncMic();
|
||||
const unsubscribe = AudioManager.getInstance().onResumed(() => {
|
||||
syncMic();
|
||||
});
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [isMuted, isDeafened, inputDeviceId, inputVolume, isConnected, echoCancellation, noiseSuppression, autoGainControl, rnnoiseEnabled, isScreenSharing]);
|
||||
const connect = useCallback(async (channelId) => {
|
||||
if (connectedChannelRef.current === channelId && roomRef.current?.state === ConnectionState.Connected)
|
||||
return;
|
||||
const gen = ++_connectGeneration;
|
||||
// Ensure AudioContext is created and resumed before tracks arrive
|
||||
await AudioManager.getInstance().resumeContext();
|
||||
// 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;
|
||||
const newRoom = new Room({ adaptiveStream: false, dynacast: false, publishDefaults: { videoCodec: 'h264', simulcast: false } });
|
||||
roomRef.current = newRoom;
|
||||
const guardedUpdate = () => { if (roomRef.current === newRoom)
|
||||
updateParticipants(); };
|
||||
// ... 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, (track, publication, participant) => {
|
||||
// LiveKit auto-attaches a hidden <audio> element for subscribed audio tracks.
|
||||
// GlobalAudioRenderer is the sole audio playback path with volume/attenuation/boost.
|
||||
// Detach LiveKit's internal element to prevent double-playback.
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
track.detach();
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, (track) => {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
track.detach();
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.LocalTrackPublished, (publication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
const { userId } = parseIdentity(newRoom.localParticipant.identity);
|
||||
useVoiceStore.getState().watchStream(userId);
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.LocalTrackUnpublished, (publication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
const { userId } = parseIdentity(newRoom.localParticipant.identity);
|
||||
useVoiceStore.getState().unwatchStream(userId);
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.TrackMuted, guardedUpdate);
|
||||
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 ||
|
||||
publication.source === Track.Source.ScreenShareAudio) {
|
||||
publication.setSubscribed(false);
|
||||
}
|
||||
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);
|
||||
setRoom(null);
|
||||
setParticipants([]);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(false);
|
||||
});
|
||||
await newRoom.connect(url, token);
|
||||
if (gen !== _connectGeneration) {
|
||||
newRoom.disconnect();
|
||||
return;
|
||||
}
|
||||
_activeRoom = newRoom;
|
||||
connectedChannelRef.current = channelId;
|
||||
setConnectedChannelId(channelId);
|
||||
setRoom(newRoom);
|
||||
setIsConnected(true);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(true);
|
||||
updateParticipants();
|
||||
// Unsubscribe from any remote screen share tracks that auto-subscribed during connect
|
||||
newRoom.remoteParticipants.forEach((rp) => {
|
||||
rp.trackPublications.forEach((pub) => {
|
||||
if ((pub.source === Track.Source.ScreenShare || pub.source === Track.Source.ScreenShareAudio) &&
|
||||
pub.isSubscribed) {
|
||||
pub.setSubscribed(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
// 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)
|
||||
setConnectionError('Failed to connect');
|
||||
}
|
||||
finally {
|
||||
if (gen === _connectGeneration)
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [updateParticipants, handleDataReceived]);
|
||||
const connectDm = useCallback(async (dmChannelId) => {
|
||||
const gen = ++_connectGeneration;
|
||||
// Ensure AudioContext is created and resumed before tracks arrive
|
||||
await AudioManager.getInstance().resumeContext();
|
||||
// 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)
|
||||
return;
|
||||
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);
|
||||
newRoom.on(RoomEvent.ParticipantDisconnected, guardedUpdate);
|
||||
newRoom.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
track.detach();
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.TrackUnsubscribed, (track) => {
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
track.detach();
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.LocalTrackPublished, (publication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
const { userId } = parseIdentity(newRoom.localParticipant.identity);
|
||||
useVoiceStore.getState().watchStream(userId);
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
newRoom.on(RoomEvent.LocalTrackUnpublished, (publication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
const { userId } = parseIdentity(newRoom.localParticipant.identity);
|
||||
useVoiceStore.getState().unwatchStream(userId);
|
||||
}
|
||||
guardedUpdate();
|
||||
});
|
||||
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 ||
|
||||
publication.source === Track.Source.ScreenShareAudio) {
|
||||
publication.setSubscribed(false);
|
||||
}
|
||||
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);
|
||||
if (gen !== _connectGeneration) {
|
||||
newRoom.disconnect();
|
||||
return;
|
||||
}
|
||||
const fullId = `dm-${dmChannelId}`;
|
||||
_activeRoom = newRoom;
|
||||
connectedChannelRef.current = fullId;
|
||||
setConnectedChannelId(fullId);
|
||||
setRoom(newRoom);
|
||||
setIsConnected(true);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(true);
|
||||
updateParticipants();
|
||||
// Unsubscribe from any remote screen share tracks that auto-subscribed during connect
|
||||
newRoom.remoteParticipants.forEach((rp) => {
|
||||
rp.trackPublications.forEach((pub) => {
|
||||
if ((pub.source === Track.Source.ScreenShare || pub.source === Track.Source.ScreenShareAudio) &&
|
||||
pub.isSubscribed) {
|
||||
pub.setSubscribed(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
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)
|
||||
setConnectionError('Failed to connect');
|
||||
}
|
||||
finally {
|
||||
if (gen === _connectGeneration)
|
||||
setIsConnecting(false);
|
||||
}
|
||||
}, [updateParticipants, handleDataReceived]);
|
||||
const disconnect = useCallback(async () => {
|
||||
_connectGeneration++;
|
||||
connectedChannelRef.current = null;
|
||||
setConnectedChannelId(null);
|
||||
if (roomRef.current) {
|
||||
await roomRef.current.disconnect();
|
||||
roomRef.current = null;
|
||||
_activeRoom = null;
|
||||
setRoom(null);
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
setConnectionState(ConnectionState.Disconnected);
|
||||
setParticipants([]);
|
||||
useVoiceStore.getState().setIsLiveKitConnected(false);
|
||||
}
|
||||
}, []);
|
||||
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 });
|
||||
setTimeout(() => { if (roomRef.current)
|
||||
applyOverdriveHammer(roomRef.current, Track.Source.Camera, preset); }, 2000);
|
||||
}
|
||||
else {
|
||||
await roomRef.current.localParticipant.setCameraEnabled(false);
|
||||
}
|
||||
updateParticipants();
|
||||
}
|
||||
}, [isCameraOn, videoQuality, updateParticipants]);
|
||||
const toggleScreenShare = useCallback(async () => {
|
||||
if (roomRef.current) {
|
||||
if (!isScreenSharing) {
|
||||
const preset = QUALITY_MAP[videoQuality] || AUTO_PRESET;
|
||||
const track = await roomRef.current.localParticipant.setScreenShareEnabled(true, {
|
||||
audio: true,
|
||||
resolution: VideoPresets.h360.resolution,
|
||||
// @ts-ignore
|
||||
frameRate: 30,
|
||||
}, {
|
||||
videoCodec: 'h264', videoEncoding: VideoPresets.h360.encoding, simulcast: false, priority: 'very-high'
|
||||
});
|
||||
if (track) {
|
||||
setTimeout(async () => {
|
||||
if (roomRef.current && isScreenSharing) {
|
||||
const screenPub = roomRef.current.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
|
||||
if (screenPub?.track?.mediaStreamTrack) {
|
||||
await screenPub.track.mediaStreamTrack.applyConstraints({
|
||||
width: { ideal: preset.resolution.width },
|
||||
height: { ideal: preset.resolution.height },
|
||||
frameRate: { ideal: preset.encoding.maxFramerate, min: 30 }
|
||||
});
|
||||
await applyOverdriveHammer(roomRef.current, Track.Source.ScreenShare, preset);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
setTimeout(() => applyOverdriveHammer(roomRef.current, Track.Source.ScreenShare, preset), 5000);
|
||||
}
|
||||
}
|
||||
else {
|
||||
await roomRef.current.localParticipant.setScreenShareEnabled(false);
|
||||
}
|
||||
updateParticipants();
|
||||
}
|
||||
}, [isScreenSharing, videoQuality, updateParticipants]);
|
||||
useEffect(() => {
|
||||
updateParticipants();
|
||||
}, [voiceUserStates, isMuted, isDeafened, updateParticipants]);
|
||||
useEffect(() => {
|
||||
if (!room)
|
||||
return;
|
||||
const preset = QUALITY_MAP[videoQuality] || AUTO_PRESET;
|
||||
const updateActiveTracks = async () => {
|
||||
if (isScreenSharing) {
|
||||
const screenPub = room.localParticipant.getTrackPublications().find(p => p.source === Track.Source.ScreenShare);
|
||||
if (screenPub?.videoTrack) {
|
||||
const mediaTrack = screenPub.videoTrack.mediaStreamTrack;
|
||||
if (mediaTrack) {
|
||||
await mediaTrack.applyConstraints({ width: { ideal: preset.resolution.width }, height: { ideal: preset.resolution.height }, frameRate: { ideal: preset.encoding.maxFramerate } });
|
||||
}
|
||||
await applyOverdriveHammer(room, Track.Source.ScreenShare, preset);
|
||||
}
|
||||
}
|
||||
if (isCameraOn) {
|
||||
await applyOverdriveHammer(room, Track.Source.Camera, preset);
|
||||
}
|
||||
};
|
||||
updateActiveTracks().catch(() => { });
|
||||
}, [room, videoQuality, isScreenSharing, isCameraOn]);
|
||||
useEffect(() => {
|
||||
if (!room)
|
||||
return;
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const engine = room.engine;
|
||||
const pc = engine?.pcManager?.publisher?.pc || engine?.publisher?.pc || engine?.pc || room.pc;
|
||||
if (!pc)
|
||||
return;
|
||||
const stats = await pc.getStats();
|
||||
stats.forEach((report) => {
|
||||
if (report.type === 'outbound-rtp' && report.kind === 'video' && report.frameWidth > 0) {
|
||||
const fps = Math.round(report.framesPerSecond || 0);
|
||||
const key = `_lastBytes_${report.ssrc}`;
|
||||
const lastBytes = window[key] || report.bytesSent;
|
||||
const bitrate = (((report.bytesSent - lastBytes) * 8) / 5000 / 1000).toFixed(2);
|
||||
window[key] = report.bytesSent;
|
||||
console.log(`[Soft-Launch Diagnostic] ${report.frameWidth}x${report.frameHeight} @ ${fps} FPS (~${bitrate} Mbps) | ${report.qualityLimitationReason}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (err) { }
|
||||
}, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [room]);
|
||||
useEffect(() => {
|
||||
return () => { _connectGeneration++; if (roomRef.current) {
|
||||
roomRef.current.disconnect();
|
||||
roomRef.current = null;
|
||||
_activeRoom = null;
|
||||
} };
|
||||
}, []);
|
||||
return { room, participants, isConnected, isConnecting, connectionState, connectedChannelId, connectionError, connect, connectDm, disconnect, toggleMic, toggleCamera, toggleScreenShare };
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useServerStore } from '../stores/serverStore';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useVoiceStore } from '../stores/voiceStore';
|
||||
import { useSocialStore } from '../stores/socialStore';
|
||||
let globalWs = null;
|
||||
let reconnectAttempts = 0;
|
||||
let reconnectTimer;
|
||||
let currentToken = null;
|
||||
let isInitialized = false;
|
||||
function handleEvent(event) {
|
||||
const { setUser } = useAuthStore.getState();
|
||||
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useServerStore.getState();
|
||||
const { addMessage, addRealtimeMessage, updateMessage, removeMessage, setTyping, onReactionAdded, onReactionRemoved } = useChatStore.getState();
|
||||
const { addVoiceUser, removeVoiceUser, clearAllVoiceUsers, setVoiceUsers, setVoiceUserStatus, clearVoiceUserStatus } = useVoiceStore.getState();
|
||||
switch (event.type) {
|
||||
case 'ready':
|
||||
setUser(event.user);
|
||||
populateFromReady(event.servers, event.folders, event.dmChannels);
|
||||
if (currentServerId) {
|
||||
loadServerDetail(currentServerId);
|
||||
}
|
||||
// Only force-reload the current channel on reconnect; other channels keep their cache
|
||||
{
|
||||
const { loadMessages: reloadMessages, currentChannelId, setReadStates } = useChatStore.getState();
|
||||
if (currentChannelId) {
|
||||
reloadMessages(currentChannelId, true);
|
||||
}
|
||||
// Initialize unread tracking from ready payload
|
||||
const { channelLastMessageIds } = useServerStore.getState();
|
||||
if (event.readStates) {
|
||||
setReadStates(event.readStates, channelLastMessageIds);
|
||||
}
|
||||
}
|
||||
// Clear stale voice state, then populate from server truth
|
||||
clearAllVoiceUsers();
|
||||
if (event.voiceStates) {
|
||||
for (const [channelId, userIds] of Object.entries(event.voiceStates)) {
|
||||
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':
|
||||
addRealtimeMessage(event.message.channelId, event.message);
|
||||
{
|
||||
const { currentChannelId, markChannelUnread } = useChatStore.getState();
|
||||
if (event.message.channelId !== currentChannelId) {
|
||||
markChannelUnread(event.message.channelId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'message_updated':
|
||||
updateMessage(event.message);
|
||||
break;
|
||||
case 'message_deleted':
|
||||
removeMessage(event.messageId, event.channelId);
|
||||
break;
|
||||
case 'typing':
|
||||
setTyping(event.channelId, event.userId, event.username);
|
||||
break;
|
||||
case 'presence_update':
|
||||
updateMemberPresence(event.userId, event.status);
|
||||
useSocialStore.getState().updateFriendPresence(event.userId, event.status);
|
||||
break;
|
||||
case 'voice_state_update':
|
||||
if (event.action === 'join') {
|
||||
addVoiceUser(event.channelId, event.userId);
|
||||
}
|
||||
else {
|
||||
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;
|
||||
case 'member_left':
|
||||
removeMember(event.userId);
|
||||
break;
|
||||
case 'dm_message_created': {
|
||||
addRealtimeMessage(event.message.dmChannelId, event.message);
|
||||
// If DM channel is unknown (first-ever message safety net), add a minimal one
|
||||
const { dmChannels: currentDmChannels, setDmChannels: setDms, addDmChannel: addDmCh } = useServerStore.getState();
|
||||
const knownDm = currentDmChannels.find(dm => dm.id === event.message.dmChannelId);
|
||||
if (!knownDm) {
|
||||
// Construct a minimal DmChannel from the message so the sidebar shows it
|
||||
addDmCh({
|
||||
id: event.message.dmChannelId,
|
||||
createdAt: event.message.createdAt,
|
||||
members: event.message.user ? [event.message.user] : [],
|
||||
lastMessage: event.message,
|
||||
});
|
||||
} else {
|
||||
// Update lastMessage on the DM channel so the sidebar sorts correctly
|
||||
const updatedDms = currentDmChannels.map(dm => dm.id === event.message.dmChannelId
|
||||
? { ...dm, lastMessage: event.message }
|
||||
: dm);
|
||||
// Re-sort by most recent message
|
||||
updatedDms.sort((a, b) => {
|
||||
const aTime = a.lastMessage?.createdAt ?? a.createdAt;
|
||||
const bTime = b.lastMessage?.createdAt ?? b.createdAt;
|
||||
return bTime - aTime;
|
||||
});
|
||||
setDms(updatedDms);
|
||||
}
|
||||
// Mark DM as unread if not currently viewing it
|
||||
{
|
||||
const { currentChannelId, markChannelUnread } = useChatStore.getState();
|
||||
if (event.message.dmChannelId !== currentChannelId) {
|
||||
markChannelUnread(event.message.dmChannelId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'dm_message_updated':
|
||||
updateMessage(event.message);
|
||||
break;
|
||||
case 'dm_message_deleted':
|
||||
removeMessage(event.messageId, event.dmChannelId);
|
||||
break;
|
||||
case 'dm_typing':
|
||||
setTyping(event.dmChannelId, event.userId, event.username);
|
||||
break;
|
||||
case 'reaction_added':
|
||||
onReactionAdded(event.messageId, event.reaction);
|
||||
break;
|
||||
case 'reaction_removed':
|
||||
onReactionRemoved(event.messageId, event.userId, event.emoji);
|
||||
break;
|
||||
case 'friend_request_received': {
|
||||
const { addIncomingRequest } = useSocialStore.getState();
|
||||
addIncomingRequest(event.request);
|
||||
break;
|
||||
}
|
||||
case 'friend_request_accepted': {
|
||||
const { addFriendFromAccepted } = useSocialStore.getState();
|
||||
addFriendFromAccepted(event.friend, event.requestId);
|
||||
break;
|
||||
}
|
||||
case 'channel_ack': {
|
||||
const { onChannelAck } = useChatStore.getState();
|
||||
onChannelAck(event.channelId, event.messageId);
|
||||
break;
|
||||
}
|
||||
case 'dm_call_incoming': {
|
||||
const { setIncomingCall } = useVoiceStore.getState();
|
||||
setIncomingCall({
|
||||
dmChannelId: event.dmChannelId,
|
||||
callerId: event.callerId,
|
||||
callerName: event.callerName,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'dm_call_accepted': {
|
||||
const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState();
|
||||
setIncomingCall(null);
|
||||
setOutgoingCall(null);
|
||||
setActiveDmCall({ dmChannelId: event.dmChannelId });
|
||||
break;
|
||||
}
|
||||
case 'dm_call_rejected': {
|
||||
const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState();
|
||||
setIncomingCall(null);
|
||||
setOutgoingCall(null);
|
||||
setActiveDmCall(null);
|
||||
break;
|
||||
}
|
||||
case 'dm_call_ended': {
|
||||
const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState();
|
||||
setIncomingCall(null);
|
||||
setOutgoingCall(null);
|
||||
setActiveDmCall(null);
|
||||
break;
|
||||
}
|
||||
case 'dm_channel_created':
|
||||
addDmChannel(event.dmChannel);
|
||||
break;
|
||||
case 'dm_channel_closed':
|
||||
removeDmChannel(event.dmChannelId);
|
||||
break;
|
||||
case 'friend_removed': {
|
||||
const { removeFriendLocally } = useSocialStore.getState();
|
||||
removeFriendLocally(event.userId);
|
||||
break;
|
||||
}
|
||||
case 'channel_created': {
|
||||
const { currentServerId: curServerId, channels: curChannels, setChannels } = useServerStore.getState();
|
||||
if (event.serverId === curServerId) {
|
||||
if (!curChannels.find(c => c.id === event.channel.id)) {
|
||||
setChannels([...curChannels, event.channel].sort((a, b) => a.position - b.position));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'channel_updated': {
|
||||
const { currentServerId: curServerId2, channels: curChannels2, setChannels: setChannels2 } = useServerStore.getState();
|
||||
if (event.serverId === curServerId2) {
|
||||
setChannels2(curChannels2.map(c => c.id === event.channel.id ? event.channel : c).sort((a, b) => a.position - b.position));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'channel_deleted': {
|
||||
const { currentServerId: curServerId3, channels: curChannels3, setChannels: setChannels3 } = useServerStore.getState();
|
||||
if (event.serverId === curServerId3) {
|
||||
setChannels3(curChannels3.filter(c => c.id !== event.channelId));
|
||||
}
|
||||
{
|
||||
const { currentChannelId } = useChatStore.getState();
|
||||
if (currentChannelId === event.channelId) {
|
||||
const { channels: remainingChannels } = useServerStore.getState();
|
||||
const firstText = remainingChannels.find(c => c.type === 'text');
|
||||
if (firstText) {
|
||||
useChatStore.getState().setCurrentChannel(firstText.id);
|
||||
} else {
|
||||
useChatStore.getState().setCurrentChannel(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'server_updated': {
|
||||
const { servers: currentServers, setServers } = useServerStore.getState();
|
||||
setServers(currentServers.map(s => s.id === event.server.id ? { ...s, ...event.server } : s));
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
console.error('WebSocket error:', event.message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
function connect() {
|
||||
if (!currentToken)
|
||||
return;
|
||||
if (globalWs && (globalWs.readyState === WebSocket.OPEN || globalWs.readyState === WebSocket.CONNECTING)) {
|
||||
return;
|
||||
}
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/ws`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
globalWs = ws;
|
||||
ws.onopen = () => {
|
||||
reconnectAttempts = 0;
|
||||
ws.send(JSON.stringify({ type: 'auth', token: currentToken }));
|
||||
};
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const event = JSON.parse(e.data);
|
||||
handleEvent(event);
|
||||
}
|
||||
catch {
|
||||
console.error('Failed to parse WebSocket message');
|
||||
}
|
||||
};
|
||||
ws.onclose = () => {
|
||||
globalWs = null;
|
||||
if (currentToken) {
|
||||
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
|
||||
reconnectAttempts++;
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
}
|
||||
};
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
}
|
||||
function disconnect() {
|
||||
currentToken = null;
|
||||
isInitialized = false;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = undefined;
|
||||
}
|
||||
if (globalWs) {
|
||||
globalWs.close();
|
||||
globalWs = null;
|
||||
}
|
||||
}
|
||||
/** Send an event over the WebSocket. Can be used outside of React components. */
|
||||
export function wsSend(event) {
|
||||
if (globalWs && globalWs.readyState === WebSocket.OPEN) {
|
||||
globalWs.send(JSON.stringify(event));
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Hook to initialize the WebSocket connection. Should only be called ONCE
|
||||
* from the top-level layout component (AppLayout). Other components should
|
||||
* use the exported `wsSend` function directly.
|
||||
*/
|
||||
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;
|
||||
isInitialized = true;
|
||||
connect();
|
||||
}
|
||||
else if (!token && isInitialized) {
|
||||
disconnect();
|
||||
}
|
||||
prevToken.current = token;
|
||||
}, [token]);
|
||||
useEffect(() => {
|
||||
const checkStatus = setInterval(() => {
|
||||
setIsConnected(!!globalWs && globalWs.readyState === WebSocket.OPEN);
|
||||
}, 500);
|
||||
return () => {
|
||||
clearInterval(checkStatus);
|
||||
disconnect();
|
||||
};
|
||||
}, []);
|
||||
return { send: wsSend, isConnected };
|
||||
}
|
||||
Reference in New Issue
Block a user