Fix double audio, Chrome reload silence, and restore input gain functionality
This commit is contained in:
@@ -131,13 +131,6 @@ export function VoiceControlBar() {
|
||||
};
|
||||
|
||||
const handleFullscreen = () => {
|
||||
if (!voiceFullscreen) {
|
||||
document.documentElement.requestFullscreen?.().catch(() => {});
|
||||
} else {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
}
|
||||
toggleVoiceFullscreen();
|
||||
};
|
||||
|
||||
|
||||
@@ -56,15 +56,15 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
|
||||
? participants.find((p) => p.identity === focusedParticipantId)
|
||||
: null;
|
||||
|
||||
// Focus mode: one large tile + sidebar strip
|
||||
// Focus mode: one large tile + bottom strip
|
||||
if (focusedParticipant) {
|
||||
const otherParticipants = participants.filter(
|
||||
(p) => p.identity !== focusedParticipantId,
|
||||
);
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<div className="flex-1 flex flex-col overflow-hidden relative">
|
||||
{/* Main focused view */}
|
||||
<div className="flex-1 p-2 relative">
|
||||
<div className="flex-1 p-2 min-h-0">
|
||||
<VoiceUser participant={focusedParticipant} large />
|
||||
{/* Back to grid button */}
|
||||
<button
|
||||
@@ -79,14 +79,14 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Side strip of other participants */}
|
||||
{/* Bottom strip of other participants */}
|
||||
{otherParticipants.length > 0 && (
|
||||
<div className="w-[200px] flex-shrink-0 overflow-y-auto p-2 space-y-2 bg-[#111214]/50">
|
||||
<div className="h-[120px] flex-shrink-0 flex items-center justify-center gap-2 p-2 bg-[#111214]/50 overflow-x-auto no-scrollbar">
|
||||
{otherParticipants.map((p) => (
|
||||
<div
|
||||
key={p.identity}
|
||||
onClick={() => setFocusedParticipant(p.identity)}
|
||||
className="cursor-pointer hover:opacity-80 transition-opacity"
|
||||
className="h-full aspect-video flex-shrink-0 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<VoiceUser participant={p} />
|
||||
</div>
|
||||
@@ -107,13 +107,13 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="flex-1 p-3 overflow-auto flex items-center">
|
||||
<div className={`grid ${gridClass} gap-2 w-full`}>
|
||||
<div className="flex-1 p-3 overflow-auto flex items-center min-h-0">
|
||||
<div className={`grid ${gridClass} gap-2 w-full max-h-full`}>
|
||||
{participants.map((p) => (
|
||||
<div
|
||||
key={p.identity}
|
||||
onClick={() => setFocusedParticipant(p.identity)}
|
||||
className="cursor-pointer hover:opacity-90 transition-opacity"
|
||||
className="cursor-pointer hover:opacity-90 transition-opacity h-full"
|
||||
>
|
||||
<VoiceUser participant={p} />
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { getSharedAudioCtx } from '../../hooks/useLiveKit';
|
||||
import type { ParticipantInfo } from '../../hooks/useLiveKit';
|
||||
|
||||
interface VoiceUserProps {
|
||||
@@ -19,14 +20,92 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
||||
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
|
||||
const isLocal = participant.isLocal;
|
||||
|
||||
// Determine active video track — prioritize screen share, check both enabled flag and readyState
|
||||
const [ctxState, setCtxState] = useState<AudioContextState>('suspended');
|
||||
|
||||
// Monitor AudioContext state
|
||||
useEffect(() => {
|
||||
const ctx = getSharedAudioCtx();
|
||||
if (!ctx) return;
|
||||
setCtxState(ctx.state);
|
||||
const handler = () => setCtxState(ctx.state);
|
||||
ctx.addEventListener('statechange', handler);
|
||||
return () => ctx.removeEventListener('statechange', handler);
|
||||
}, []);
|
||||
|
||||
// Web Audio for volume boost (> 100%)
|
||||
const gainNodeRef = useRef<GainNode | null>(null);
|
||||
const sourceNodeRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
|
||||
// Setup Web Audio graph
|
||||
useEffect(() => {
|
||||
if (isLocal || !participant.audioTrack) return;
|
||||
|
||||
const ctx = getSharedAudioCtx();
|
||||
if (!ctx) return;
|
||||
|
||||
if (!gainNodeRef.current) {
|
||||
gainNodeRef.current = ctx.createGain();
|
||||
gainNodeRef.current.connect(ctx.destination);
|
||||
}
|
||||
|
||||
const gainNode = gainNodeRef.current!;
|
||||
|
||||
if (sourceNodeRef.current) {
|
||||
sourceNodeRef.current.disconnect();
|
||||
}
|
||||
|
||||
const stream = new MediaStream([participant.audioTrack]);
|
||||
sourceNodeRef.current = ctx.createMediaStreamSource(stream);
|
||||
sourceNodeRef.current.connect(gainNode);
|
||||
|
||||
return () => {
|
||||
sourceNodeRef.current?.disconnect();
|
||||
};
|
||||
}, [participant.audioTrack, isLocal]);
|
||||
|
||||
// Apply volume - STRICT DUAL PATH PREVENTION
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
const ctx = getSharedAudioCtx();
|
||||
|
||||
if (isLocal || !audioEl || !ctx) return;
|
||||
|
||||
const perUserScaled = perUserVolume / 100;
|
||||
const globalScaled = outputVolume / 100;
|
||||
const combined = perUserScaled * globalScaled;
|
||||
|
||||
if (isDeafened) {
|
||||
if (gainNodeRef.current) gainNodeRef.current.gain.setTargetAtTime(0, ctx.currentTime, 0.01);
|
||||
audioEl.volume = 0;
|
||||
audioEl.muted = true;
|
||||
} else {
|
||||
// Chrome/Safari Autoplay logic:
|
||||
// If Context is Running: Use Web Audio (allows > 100% boost), Mute <audio>
|
||||
// If Context is Blocked: Use <audio> (max 100%), Mute Web Audio
|
||||
if (ctxState === 'running' && gainNodeRef.current) {
|
||||
audioEl.muted = true; // Stop standard playback
|
||||
gainNodeRef.current.gain.setTargetAtTime(combined, ctx.currentTime, 0.01);
|
||||
} else {
|
||||
if (gainNodeRef.current) {
|
||||
gainNodeRef.current.gain.setTargetAtTime(0, ctx.currentTime, 0.01);
|
||||
}
|
||||
audioEl.muted = false; // Fallback to standard
|
||||
audioEl.volume = Math.min(combined, 1);
|
||||
audioEl.play().catch(() => {
|
||||
// Truly blocked by browser
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [isDeafened, outputVolume, perUserVolume, ctxState, isLocal]);
|
||||
|
||||
// Determine active video track
|
||||
const liveScreen = participant.isScreenSharing && participant.screenTrack?.readyState === 'live' ? participant.screenTrack : null;
|
||||
const liveCamera = participant.isCameraOn && participant.videoTrack?.readyState === 'live' ? participant.videoTrack : null;
|
||||
const activeVideoTrack = liveScreen ?? liveCamera;
|
||||
const hasVideo = activeVideoTrack !== null;
|
||||
const isScreenShare = liveScreen !== null;
|
||||
|
||||
// Listen for track 'ended' events to force re-render when a stream stops
|
||||
// Listen for track 'ended' events
|
||||
useEffect(() => {
|
||||
const tracks = [participant.videoTrack, participant.screenTrack].filter(
|
||||
(t): t is MediaStreamTrack => t !== null,
|
||||
@@ -53,22 +132,9 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
||||
const audioEl = audioRef.current;
|
||||
if (!audioEl || !participant.audioTrack) return;
|
||||
audioEl.srcObject = new MediaStream([participant.audioTrack]);
|
||||
// Note: play() and muted state are handled by the volume effect above
|
||||
}, [participant.audioTrack]);
|
||||
|
||||
// Apply volume: combine outputVolume and per-participant volume, or mute if deafened
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
if (!audioEl) return;
|
||||
if (isDeafened) {
|
||||
audioEl.volume = 0;
|
||||
audioEl.muted = true;
|
||||
} else {
|
||||
const combined = (outputVolume / 100) * (perUserVolume / 100);
|
||||
audioEl.volume = Math.min(Math.max(combined, 0), 1);
|
||||
audioEl.muted = false;
|
||||
}
|
||||
}, [isDeafened, outputVolume, perUserVolume]);
|
||||
|
||||
// Volume context menu
|
||||
const [volumeMenu, setVolumeMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const setParticipantVolume = useVoiceStore((s) => s.setParticipantVolume);
|
||||
@@ -77,6 +143,10 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
||||
(e: React.MouseEvent) => {
|
||||
if (isLocal) return;
|
||||
e.preventDefault();
|
||||
const ctx = getSharedAudioCtx();
|
||||
if (ctx && ctx.state === 'suspended') {
|
||||
ctx.resume();
|
||||
}
|
||||
setVolumeMenu({ x: e.clientX, y: e.clientY });
|
||||
},
|
||||
[isLocal],
|
||||
@@ -89,17 +159,23 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
||||
return () => window.removeEventListener('click', close);
|
||||
}, [volumeMenu]);
|
||||
|
||||
const handleInteraction = useCallback(() => {
|
||||
const ctx = getSharedAudioCtx();
|
||||
if (ctx && ctx.state === 'suspended') {
|
||||
ctx.resume().catch(console.error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={handleInteraction}
|
||||
className={`relative bg-[#111214] rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ${
|
||||
participant.isSpeaking
|
||||
? 'ring-[3px] ring-discord-green shadow-[0_0_12px_rgba(35,165,90,0.25)]'
|
||||
: 'ring-1 ring-white/[0.06] hover:ring-white/10'
|
||||
} ${large ? 'h-full' : ''}`}
|
||||
style={large ? undefined : { aspectRatio: '16/9', minHeight: '140px' }}
|
||||
} ${large ? 'h-full w-full' : 'h-full aspect-video'}`}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* Audio element for remote participants */}
|
||||
{!isLocal && <audio ref={audioRef} autoPlay />}
|
||||
|
||||
{hasVideo ? (
|
||||
@@ -125,14 +201,12 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* LIVE badge for screen shares */}
|
||||
{isScreenShare && hasVideo && (
|
||||
<div className="absolute top-2 left-2 px-1.5 py-0.5 bg-discord-red rounded text-[11px] font-bold text-white uppercase tracking-wide">
|
||||
LIVE
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 via-black/30 to-transparent">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
@@ -173,7 +247,6 @@ export function VoiceUser({ participant, large }: VoiceUserProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-participant volume menu (right-click) */}
|
||||
{volumeMenu && !isLocal && (
|
||||
<div
|
||||
className="fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-3 min-w-[200px] border border-white/[0.06]"
|
||||
|
||||
Reference in New Issue
Block a user