fix: remove stream auto-focus and persist audio across navigation
Streams no longer auto-focus into large view when they start — tiles stay in the equal-size grid until manually clicked. Audio playback is moved out of VoiceUser/StreamTile into a new GlobalAudioRenderer component rendered in AppLayout so it survives channel navigation.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
||||
import React from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useAudioTrackPlayer } from '../../hooks/useAudioTrackPlayer';
|
||||
/**
|
||||
* Renders an invisible <audio> element for a single audio track.
|
||||
* Uses the shared useAudioTrackPlayer hook for the hybrid native/Web Audio pipeline.
|
||||
*/
|
||||
function AudioTrackElement({ track, globalVolume, perSourceVolume, isDeafened, isMuted, attenuate, someoneIsSpeaking, attenuationEnabled, attenuationStrength, }) {
|
||||
const globalScale = globalVolume / 100;
|
||||
const sourceScale = perSourceVolume / 100;
|
||||
let finalVolume = globalScale * sourceScale;
|
||||
// Stream attenuation: duck when someone is speaking
|
||||
if (attenuate && attenuationEnabled && someoneIsSpeaking) {
|
||||
finalVolume *= 1 - attenuationStrength / 100;
|
||||
}
|
||||
const shouldMute = isDeafened || isMuted;
|
||||
const audioRef = useAudioTrackPlayer({
|
||||
track,
|
||||
volume: finalVolume,
|
||||
muted: shouldMute,
|
||||
});
|
||||
return _jsx("audio", { ref: audioRef, autoPlay: true, playsInline: true });
|
||||
}
|
||||
/**
|
||||
* Always-mounted component that renders invisible <audio> elements
|
||||
* for every remote participant's mic and screen audio tracks.
|
||||
*
|
||||
* Rendered in AppLayout alongside PictureInPicture and SoundController.
|
||||
* Never unmounts during navigation, so audio persists even when
|
||||
* VoiceGrid / VoiceUser / StreamTile are not rendered.
|
||||
*/
|
||||
export function GlobalAudioRenderer() {
|
||||
const participants = useVoiceStore((s) => s.participants);
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const outputVolume = useVoiceStore((s) => s.outputVolume);
|
||||
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
||||
const streamVolumes = useVoiceStore((s) => s.streamVolumes);
|
||||
const streamMutes = useVoiceStore((s) => s.streamMutes);
|
||||
const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled);
|
||||
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
|
||||
// Determine if someone is currently speaking (for stream attenuation)
|
||||
const someoneIsSpeaking = participants.some((p) => !p.isLocal && p.isSpeaking);
|
||||
// Only render audio for remote participants
|
||||
const remoteParticipants = participants.filter((p) => !p.isLocal);
|
||||
return (_jsx(_Fragment, { children: remoteParticipants.map((p) => {
|
||||
const micVolume = participantVolumes.get(p.userId) ?? 100;
|
||||
const streamVol = streamVolumes.get(p.userId) ?? 100;
|
||||
const isStreamMuted = streamMutes.get(p.userId) ?? false;
|
||||
return (_jsxs(React.Fragment, { children: [p.audioTrack && (_jsx(AudioTrackElement, { track: p.audioTrack, globalVolume: outputVolume, perSourceVolume: micVolume, isDeafened: isDeafened, isMuted: false, attenuate: false, someoneIsSpeaking: false, attenuationEnabled: false, attenuationStrength: 0 })), p.screenAudioTrack && (_jsx(AudioTrackElement, { track: p.screenAudioTrack, globalVolume: outputVolume, perSourceVolume: streamVol, isDeafened: isDeafened, isMuted: isStreamMuted, attenuate: true, someoneIsSpeaking: someoneIsSpeaking, attenuationEnabled: streamAttenuationEnabled, attenuationStrength: streamAttenuationStrength }))] }, p.identity));
|
||||
}) }));
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import React from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useAudioTrackPlayer } from '../../hooks/useAudioTrackPlayer';
|
||||
import type { ParticipantInfo } from '../../hooks/useLiveKit';
|
||||
|
||||
/**
|
||||
* Renders an invisible <audio> element for a single audio track.
|
||||
* Uses the shared useAudioTrackPlayer hook for the hybrid native/Web Audio pipeline.
|
||||
*/
|
||||
function AudioTrackElement({
|
||||
track,
|
||||
globalVolume,
|
||||
perSourceVolume,
|
||||
isDeafened,
|
||||
isMuted,
|
||||
attenuate,
|
||||
someoneIsSpeaking,
|
||||
attenuationEnabled,
|
||||
attenuationStrength,
|
||||
}: {
|
||||
track: MediaStreamTrack | null;
|
||||
globalVolume: number;
|
||||
perSourceVolume: number;
|
||||
isDeafened: boolean;
|
||||
isMuted: boolean;
|
||||
attenuate: boolean;
|
||||
someoneIsSpeaking: boolean;
|
||||
attenuationEnabled: boolean;
|
||||
attenuationStrength: number;
|
||||
}) {
|
||||
const globalScale = globalVolume / 100;
|
||||
const sourceScale = perSourceVolume / 100;
|
||||
let finalVolume = globalScale * sourceScale;
|
||||
|
||||
// Stream attenuation: duck when someone is speaking
|
||||
if (attenuate && attenuationEnabled && someoneIsSpeaking) {
|
||||
finalVolume *= 1 - attenuationStrength / 100;
|
||||
}
|
||||
|
||||
const shouldMute = isDeafened || isMuted;
|
||||
|
||||
const audioRef = useAudioTrackPlayer({
|
||||
track,
|
||||
volume: finalVolume,
|
||||
muted: shouldMute,
|
||||
});
|
||||
|
||||
return <audio ref={audioRef} autoPlay playsInline />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Always-mounted component that renders invisible <audio> elements
|
||||
* for every remote participant's mic and screen audio tracks.
|
||||
*
|
||||
* Rendered in AppLayout alongside PictureInPicture and SoundController.
|
||||
* Never unmounts during navigation, so audio persists even when
|
||||
* VoiceGrid / VoiceUser / StreamTile are not rendered.
|
||||
*/
|
||||
export function GlobalAudioRenderer() {
|
||||
const participants = useVoiceStore((s) => s.participants);
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const outputVolume = useVoiceStore((s) => s.outputVolume);
|
||||
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
||||
const streamVolumes = useVoiceStore((s) => s.streamVolumes);
|
||||
const streamMutes = useVoiceStore((s) => s.streamMutes);
|
||||
const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled);
|
||||
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
|
||||
|
||||
// Determine if someone is currently speaking (for stream attenuation)
|
||||
const someoneIsSpeaking = participants.some((p) => !p.isLocal && p.isSpeaking);
|
||||
|
||||
// Only render audio for remote participants
|
||||
const remoteParticipants = participants.filter((p) => !p.isLocal);
|
||||
|
||||
return (
|
||||
<>
|
||||
{remoteParticipants.map((p: ParticipantInfo) => {
|
||||
const micVolume = participantVolumes.get(p.userId) ?? 100;
|
||||
const streamVol = streamVolumes.get(p.userId) ?? 100;
|
||||
const isStreamMuted = streamMutes.get(p.userId) ?? false;
|
||||
|
||||
return (
|
||||
<React.Fragment key={p.identity}>
|
||||
{/* Mic audio */}
|
||||
{p.audioTrack && (
|
||||
<AudioTrackElement
|
||||
track={p.audioTrack}
|
||||
globalVolume={outputVolume}
|
||||
perSourceVolume={micVolume}
|
||||
isDeafened={isDeafened}
|
||||
isMuted={false}
|
||||
attenuate={false}
|
||||
someoneIsSpeaking={false}
|
||||
attenuationEnabled={false}
|
||||
attenuationStrength={0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Screen share audio */}
|
||||
{p.screenAudioTrack && (
|
||||
<AudioTrackElement
|
||||
track={p.screenAudioTrack}
|
||||
globalVolume={outputVolume}
|
||||
perSourceVolume={streamVol}
|
||||
isDeafened={isDeafened}
|
||||
isMuted={isStreamMuted}
|
||||
attenuate={true}
|
||||
someoneIsSpeaking={someoneIsSpeaking}
|
||||
attenuationEnabled={streamAttenuationEnabled}
|
||||
attenuationStrength={streamAttenuationStrength}
|
||||
/>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,20 +2,15 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { AudioManager } from '../../audio/AudioManager';
|
||||
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
|
||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||
export function StreamTile({ tile, large }) {
|
||||
const videoRef = useRef(null);
|
||||
const screenAudioRef = useRef(null);
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const outputVolume = useVoiceStore((s) => s.outputVolume);
|
||||
const streamVolumes = useVoiceStore((s) => s.streamVolumes);
|
||||
const streamMutes = useVoiceStore((s) => s.streamMutes);
|
||||
const watchingStreams = useVoiceStore((s) => s.watchingStreams);
|
||||
const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled);
|
||||
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
|
||||
const participants = useVoiceStore((s) => s.participants);
|
||||
const { participant } = tile;
|
||||
const isLocal = participant.isLocal;
|
||||
const userId = participant.userId;
|
||||
@@ -28,90 +23,6 @@ export function StreamTile({ tile, large }) {
|
||||
// Context menu state
|
||||
const [contextMenu, setContextMenu] = useState(null);
|
||||
const [qualityPopoverOpen, setQualityPopoverOpen] = useState(false);
|
||||
// --- AUDIO PIPELINE ---
|
||||
const screenBoostGainRef = useRef(null);
|
||||
const screenBoostSourceRef = useRef(null);
|
||||
// Track attachment
|
||||
useEffect(() => {
|
||||
const audioEl = screenAudioRef.current;
|
||||
if (isLocal || !audioEl || !tile.screenAudioTrack) {
|
||||
if (audioEl)
|
||||
audioEl.srcObject = null;
|
||||
return;
|
||||
}
|
||||
const stream = new MediaStream([tile.screenAudioTrack]);
|
||||
if (audioEl.srcObject?.id !== stream.id) {
|
||||
audioEl.srcObject = stream;
|
||||
audioEl.play().catch(() => { });
|
||||
}
|
||||
}, [tile.screenAudioTrack, isLocal]);
|
||||
// Volume management with stream attenuation
|
||||
useEffect(() => {
|
||||
const audioEl = screenAudioRef.current;
|
||||
if (isLocal || !audioEl || !tile.screenAudioTrack)
|
||||
return;
|
||||
const globalScale = outputVolume / 100;
|
||||
const userScale = streamVolume / 100;
|
||||
let finalVolume = globalScale * userScale;
|
||||
if (isDeafened || isStreamMuted) {
|
||||
audioEl.muted = true;
|
||||
return;
|
||||
}
|
||||
// Stream attenuation: duck when someone is speaking
|
||||
if (streamAttenuationEnabled) {
|
||||
const someoneIsSpeaking = participants.some((p) => !p.isLocal && p.isSpeaking);
|
||||
if (someoneIsSpeaking) {
|
||||
finalVolume *= 1 - streamAttenuationStrength / 100;
|
||||
}
|
||||
}
|
||||
const audioManager = AudioManager.getInstance();
|
||||
const ctx = audioManager.getContext();
|
||||
const isBoosting = finalVolume > 1.0;
|
||||
const isContextReady = ctx && ctx.state === 'running';
|
||||
if (isBoosting && isContextReady) {
|
||||
if (!screenBoostGainRef.current && ctx) {
|
||||
const gain = ctx.createGain();
|
||||
const source = ctx.createMediaStreamSource(new MediaStream([tile.screenAudioTrack]));
|
||||
source.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
screenBoostGainRef.current = gain;
|
||||
screenBoostSourceRef.current = source;
|
||||
}
|
||||
if (screenBoostGainRef.current && ctx) {
|
||||
screenBoostGainRef.current.gain.setTargetAtTime(finalVolume, ctx.currentTime, 0.01);
|
||||
}
|
||||
audioEl.muted = true;
|
||||
}
|
||||
else {
|
||||
if (screenBoostSourceRef.current) {
|
||||
screenBoostSourceRef.current.disconnect();
|
||||
screenBoostSourceRef.current = null;
|
||||
screenBoostGainRef.current = null;
|
||||
}
|
||||
audioEl.muted = false;
|
||||
audioEl.volume = Math.min(finalVolume, 1.0);
|
||||
if (audioEl.paused) {
|
||||
audioEl.play().catch(() => { });
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (screenBoostSourceRef.current) {
|
||||
screenBoostSourceRef.current.disconnect();
|
||||
screenBoostSourceRef.current = null;
|
||||
screenBoostGainRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
outputVolume,
|
||||
streamVolume,
|
||||
isStreamMuted,
|
||||
isDeafened,
|
||||
isLocal,
|
||||
tile.screenAudioTrack,
|
||||
streamAttenuationEnabled,
|
||||
streamAttenuationStrength,
|
||||
participants,
|
||||
]);
|
||||
// --- VIDEO ---
|
||||
useEffect(() => {
|
||||
const videoEl = videoRef.current;
|
||||
@@ -198,7 +109,7 @@ export function StreamTile({ tile, large }) {
|
||||
const setAttenuationEnabled = useVoiceStore((s) => s.setStreamAttenuationEnabled);
|
||||
const setAttenuationStrength = useVoiceStore((s) => s.setStreamAttenuationStrength);
|
||||
const hasVideo = liveScreenTrack !== null;
|
||||
return (_jsxs("div", { className: `relative bg-[#111214] rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ring-1 ring-white/[0.06] hover:ring-white/10 ${large ? 'h-full w-full' : 'h-full aspect-video'}`, onContextMenu: handleContextMenu, children: [!isLocal && _jsx("audio", { ref: screenAudioRef, autoPlay: true, playsInline: true }), hasVideo && isWatching ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: isLocal, className: "w-full h-full object-contain bg-black" })) : (_jsxs("div", { className: "w-full h-full flex flex-col items-center justify-center gap-3 bg-[#1e1f22]", children: [_jsx("div", { className: "relative", children: _jsx(Avatar, { src: null, name: participant.username, size: large ? 80 : 48 }) }), _jsxs("div", { className: "text-center px-4", children: [_jsxs("p", { className: "text-discord-text-primary text-sm font-semibold", children: [participant.username, " is streaming"] }), !isLocal && (_jsx("button", { onClick: handleWatch, className: "mt-2 px-4 py-1.5 bg-discord-blurple hover:bg-discord-blurple/80 rounded text-white text-xs font-semibold transition-colors", children: "Watch Stream" }))] })] })), _jsx("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", children: "LIVE" }), qualityBadge && hasVideo && (_jsx("div", { className: "absolute top-2 right-2 px-1.5 py-0.5 bg-black/60 rounded text-[10px] font-bold text-white/70 uppercase tracking-wide", children: qualityBadge })), _jsx("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", children: _jsxs("div", { className: "flex items-center gap-1.5 min-w-0", children: [_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-white/70 flex-shrink-0", children: _jsx("path", { d: "M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7v2H8v2h8v-2h-2v-2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" }) }), _jsx("span", { className: `font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`, children: participant.username }), isLocal && (_jsx("span", { className: "text-[10px] text-white/40 font-medium", children: "(you)" }))] }) }), contextMenu && (_jsx("div", { className: "fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-2 min-w-[220px] border border-white/[0.06]", style: { left: contextMenu.x, top: contextMenu.y }, onClick: (e) => e.stopPropagation(), children: isLocal ? (
|
||||
return (_jsxs("div", { className: `relative bg-[#111214] rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ring-1 ring-white/[0.06] hover:ring-white/10 ${large ? 'h-full w-full' : 'h-full aspect-video'}`, onContextMenu: handleContextMenu, children: [hasVideo && isWatching ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: isLocal, className: "w-full h-full object-contain bg-black" })) : (_jsxs("div", { className: "w-full h-full flex flex-col items-center justify-center gap-3 bg-[#1e1f22]", children: [_jsx("div", { className: "relative", children: _jsx(Avatar, { src: null, name: participant.username, size: large ? 80 : 48 }) }), _jsxs("div", { className: "text-center px-4", children: [_jsxs("p", { className: "text-discord-text-primary text-sm font-semibold", children: [participant.username, " is streaming"] }), !isLocal && (_jsx("button", { onClick: handleWatch, className: "mt-2 px-4 py-1.5 bg-discord-blurple hover:bg-discord-blurple/80 rounded text-white text-xs font-semibold transition-colors", children: "Watch Stream" }))] })] })), _jsx("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", children: "LIVE" }), qualityBadge && hasVideo && (_jsx("div", { className: "absolute top-2 right-2 px-1.5 py-0.5 bg-black/60 rounded text-[10px] font-bold text-white/70 uppercase tracking-wide", children: qualityBadge })), _jsx("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", children: _jsxs("div", { className: "flex items-center gap-1.5 min-w-0", children: [_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "currentColor", className: "text-white/70 flex-shrink-0", children: _jsx("path", { d: "M21 3H3c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h7v2H8v2h8v-2h-2v-2h7c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 14H3V5h18v12z" }) }), _jsx("span", { className: `font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`, children: participant.username }), isLocal && (_jsx("span", { className: "text-[10px] text-white/40 font-medium", children: "(you)" }))] }) }), contextMenu && (_jsx("div", { className: "fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-2 min-w-[220px] border border-white/[0.06]", style: { left: contextMenu.x, top: contextMenu.y }, onClick: (e) => e.stopPropagation(), children: isLocal ? (
|
||||
/* Streamer context menu (own stream) */
|
||||
_jsxs(_Fragment, { children: [_jsxs("button", { onClick: () => {
|
||||
handleStopStreaming();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { AudioManager } from '../../audio/AudioManager';
|
||||
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
|
||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||
import type { StreamTile as StreamTileType } from '../../hooks/useLiveKit';
|
||||
@@ -13,16 +12,12 @@ interface StreamTileProps {
|
||||
|
||||
export function StreamTile({ tile, large }: StreamTileProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const screenAudioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const outputVolume = useVoiceStore((s) => s.outputVolume);
|
||||
const streamVolumes = useVoiceStore((s) => s.streamVolumes);
|
||||
const streamMutes = useVoiceStore((s) => s.streamMutes);
|
||||
const watchingStreams = useVoiceStore((s) => s.watchingStreams);
|
||||
const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled);
|
||||
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
|
||||
const participants = useVoiceStore((s) => s.participants);
|
||||
|
||||
const { participant } = tile;
|
||||
const isLocal = participant.isLocal;
|
||||
@@ -41,105 +36,6 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
||||
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [qualityPopoverOpen, setQualityPopoverOpen] = useState(false);
|
||||
|
||||
// --- AUDIO PIPELINE ---
|
||||
const screenBoostGainRef = useRef<GainNode | null>(null);
|
||||
const screenBoostSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
|
||||
// Track attachment
|
||||
useEffect(() => {
|
||||
const audioEl = screenAudioRef.current;
|
||||
if (isLocal || !audioEl || !tile.screenAudioTrack) {
|
||||
if (audioEl) audioEl.srcObject = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = new MediaStream([tile.screenAudioTrack]);
|
||||
if ((audioEl.srcObject as MediaStream)?.id !== stream.id) {
|
||||
audioEl.srcObject = stream;
|
||||
audioEl.play().catch(() => {});
|
||||
}
|
||||
}, [tile.screenAudioTrack, isLocal]);
|
||||
|
||||
// Volume management with stream attenuation
|
||||
useEffect(() => {
|
||||
const audioEl = screenAudioRef.current;
|
||||
if (isLocal || !audioEl || !tile.screenAudioTrack) return;
|
||||
|
||||
const globalScale = outputVolume / 100;
|
||||
const userScale = streamVolume / 100;
|
||||
let finalVolume = globalScale * userScale;
|
||||
|
||||
if (isDeafened || isStreamMuted) {
|
||||
audioEl.muted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Stream attenuation: duck when someone is speaking
|
||||
if (streamAttenuationEnabled) {
|
||||
const someoneIsSpeaking = participants.some(
|
||||
(p) => !p.isLocal && p.isSpeaking,
|
||||
);
|
||||
if (someoneIsSpeaking) {
|
||||
finalVolume *= 1 - streamAttenuationStrength / 100;
|
||||
}
|
||||
}
|
||||
|
||||
const audioManager = AudioManager.getInstance();
|
||||
const ctx = audioManager.getContext();
|
||||
const isBoosting = finalVolume > 1.0;
|
||||
const isContextReady = ctx && ctx.state === 'running';
|
||||
|
||||
if (isBoosting && isContextReady) {
|
||||
if (!screenBoostGainRef.current && ctx) {
|
||||
const gain = ctx.createGain();
|
||||
const source = ctx.createMediaStreamSource(
|
||||
new MediaStream([tile.screenAudioTrack]),
|
||||
);
|
||||
source.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
screenBoostGainRef.current = gain;
|
||||
screenBoostSourceRef.current = source;
|
||||
}
|
||||
if (screenBoostGainRef.current && ctx) {
|
||||
screenBoostGainRef.current.gain.setTargetAtTime(
|
||||
finalVolume,
|
||||
ctx.currentTime,
|
||||
0.01,
|
||||
);
|
||||
}
|
||||
audioEl.muted = true;
|
||||
} else {
|
||||
if (screenBoostSourceRef.current) {
|
||||
screenBoostSourceRef.current.disconnect();
|
||||
screenBoostSourceRef.current = null;
|
||||
screenBoostGainRef.current = null;
|
||||
}
|
||||
audioEl.muted = false;
|
||||
audioEl.volume = Math.min(finalVolume, 1.0);
|
||||
if (audioEl.paused) {
|
||||
audioEl.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (screenBoostSourceRef.current) {
|
||||
screenBoostSourceRef.current.disconnect();
|
||||
screenBoostSourceRef.current = null;
|
||||
screenBoostGainRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
outputVolume,
|
||||
streamVolume,
|
||||
isStreamMuted,
|
||||
isDeafened,
|
||||
isLocal,
|
||||
tile.screenAudioTrack,
|
||||
streamAttenuationEnabled,
|
||||
streamAttenuationStrength,
|
||||
participants,
|
||||
]);
|
||||
|
||||
// --- VIDEO ---
|
||||
useEffect(() => {
|
||||
const videoEl = videoRef.current;
|
||||
@@ -242,9 +138,6 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
||||
}`}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* Screen share audio (remote only) */}
|
||||
{!isLocal && <audio ref={screenAudioRef} autoPlay playsInline />}
|
||||
|
||||
{hasVideo && isWatching ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { VoiceUser } from './VoiceUser';
|
||||
import { StreamTile } from './StreamTile';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
@@ -7,28 +7,17 @@ import { deriveGridTiles } from '../../hooks/useLiveKit';
|
||||
export function VoiceGrid({ participants }) {
|
||||
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
|
||||
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant);
|
||||
const prevStreamKeysRef = useRef(new Set());
|
||||
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
|
||||
// Auto-focus when a new stream tile appears
|
||||
// Unfocus if the focused stream tile no longer exists
|
||||
useEffect(() => {
|
||||
const currentStreamKeys = new Set(tiles
|
||||
.filter((t) => t.kind === 'stream' && t.screenTrack?.readyState === 'live')
|
||||
.map((t) => t.key));
|
||||
// Find newly appeared stream keys
|
||||
for (const key of currentStreamKeys) {
|
||||
if (!prevStreamKeysRef.current.has(key)) {
|
||||
// New stream tile — auto-focus it
|
||||
setFocusedParticipant(key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If the focused tile was a stream tile that no longer exists, unfocus
|
||||
if (focusedParticipantId &&
|
||||
focusedParticipantId.endsWith(':stream') &&
|
||||
!currentStreamKeys.has(focusedParticipantId)) {
|
||||
setFocusedParticipant(null);
|
||||
}
|
||||
prevStreamKeysRef.current = currentStreamKeys;
|
||||
}, [tiles, focusedParticipantId, setFocusedParticipant]);
|
||||
if (tiles.length === 0) {
|
||||
return (_jsx("div", { className: "flex-1 flex items-center justify-center", children: _jsxs("div", { className: "text-center", children: [_jsx("svg", { width: "48", height: "48", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted/40 mx-auto mb-3", children: _jsx("path", { d: "M11 5L6 9H2V15H6L11 19V5ZM15.54 8.46C16.48 9.4 17 10.67 17 12S16.48 14.6 15.54 15.54L14.12 14.12C14.69 13.55 15 12.79 15 12S14.69 10.45 14.12 9.88L15.54 8.46Z" }) }), _jsx("p", { className: "text-discord-text-muted text-sm", children: "Waiting for others to join..." })] }) }));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useRef, useMemo } from 'react';
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { VoiceUser } from './VoiceUser';
|
||||
import { StreamTile } from './StreamTile';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
@@ -12,11 +12,10 @@ interface VoiceGridProps {
|
||||
export function VoiceGrid({ participants }: VoiceGridProps) {
|
||||
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
|
||||
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant);
|
||||
const prevStreamKeysRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
|
||||
|
||||
// Auto-focus when a new stream tile appears
|
||||
// Unfocus if the focused stream tile no longer exists
|
||||
useEffect(() => {
|
||||
const currentStreamKeys = new Set(
|
||||
tiles
|
||||
@@ -27,16 +26,6 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
|
||||
.map((t) => t.key),
|
||||
);
|
||||
|
||||
// Find newly appeared stream keys
|
||||
for (const key of currentStreamKeys) {
|
||||
if (!prevStreamKeysRef.current.has(key)) {
|
||||
// New stream tile — auto-focus it
|
||||
setFocusedParticipant(key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If the focused tile was a stream tile that no longer exists, unfocus
|
||||
if (
|
||||
focusedParticipantId &&
|
||||
focusedParticipantId.endsWith(':stream') &&
|
||||
@@ -44,8 +33,6 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
|
||||
) {
|
||||
setFocusedParticipant(null);
|
||||
}
|
||||
|
||||
prevStreamKeysRef.current = currentStreamKeys;
|
||||
}, [tiles, focusedParticipantId, setFocusedParticipant]);
|
||||
|
||||
if (tiles.length === 0) {
|
||||
|
||||
@@ -2,104 +2,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { AudioManager } from '../../audio/AudioManager';
|
||||
export function VoiceUser({ tile, large }) {
|
||||
const videoRef = useRef(null);
|
||||
const audioRef = useRef(null);
|
||||
const { participant } = tile;
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const outputVolume = useVoiceStore((s) => s.outputVolume);
|
||||
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
||||
const [, forceUpdate] = useState(0);
|
||||
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
|
||||
const isLocal = participant.isLocal;
|
||||
// --- AUDIO PIPELINE: NATIVE FIRST ---
|
||||
// Refs for the optional boost pipeline
|
||||
const boostGainRef = useRef(null);
|
||||
const boostSourceRef = useRef(null);
|
||||
// 1. Basic Track Attachment (The Rock-Solid Foundation)
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
if (isLocal || !audioEl || !tile.audioTrack)
|
||||
return;
|
||||
// Direct attachment.
|
||||
const stream = new MediaStream([tile.audioTrack]);
|
||||
// Only update if changed to prevent interruptions
|
||||
if (audioEl.srcObject?.id !== stream.id) {
|
||||
audioEl.srcObject = stream;
|
||||
// Aggressive play attempt for Chrome
|
||||
const tryPlay = async () => {
|
||||
try {
|
||||
await audioEl.play();
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('[Audio] Autoplay blocked, retrying...', err);
|
||||
}
|
||||
};
|
||||
tryPlay();
|
||||
}
|
||||
}, [tile.audioTrack, isLocal]);
|
||||
// 2. Volume Management (Hybrid)
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
if (isLocal || !audioEl || !tile.audioTrack)
|
||||
return;
|
||||
const globalScale = outputVolume / 100;
|
||||
const userScale = perUserVolume / 100;
|
||||
const finalVolume = globalScale * userScale;
|
||||
if (isDeafened) {
|
||||
audioEl.muted = true;
|
||||
return;
|
||||
}
|
||||
// Logic:
|
||||
// If we are boosting (>100%) AND context is running, use Web Audio.
|
||||
// Otherwise, stick to the native element for maximum reliability.
|
||||
const audioManager = AudioManager.getInstance();
|
||||
const ctx = audioManager.getContext();
|
||||
const isBoosting = finalVolume > 1.0;
|
||||
const isContextReady = ctx && ctx.state === 'running';
|
||||
if (isBoosting && isContextReady) {
|
||||
// --- BOOST MODE (>100%) ---
|
||||
// Setup pipeline if missing
|
||||
if (!boostGainRef.current && ctx) {
|
||||
const gain = ctx.createGain();
|
||||
const source = ctx.createMediaStreamSource(new MediaStream([tile.audioTrack]));
|
||||
source.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
boostGainRef.current = gain;
|
||||
boostSourceRef.current = source;
|
||||
}
|
||||
// Apply boosted gain
|
||||
if (boostGainRef.current && ctx) {
|
||||
boostGainRef.current.gain.setTargetAtTime(finalVolume, ctx.currentTime, 0.01);
|
||||
}
|
||||
// MUTE the element so we don't double audio
|
||||
audioEl.muted = true;
|
||||
}
|
||||
else {
|
||||
// --- STANDARD MODE (0% - 100%) ---
|
||||
// Clean up boost pipeline if it exists
|
||||
if (boostSourceRef.current) {
|
||||
boostSourceRef.current.disconnect();
|
||||
boostSourceRef.current = null;
|
||||
boostGainRef.current = null;
|
||||
}
|
||||
// Use the element
|
||||
audioEl.muted = false;
|
||||
audioEl.volume = Math.min(finalVolume, 1.0);
|
||||
// Ensure it's playing (in case it was paused/blocked earlier)
|
||||
if (audioEl.paused) {
|
||||
audioEl.play().catch(() => { });
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (boostSourceRef.current) {
|
||||
boostSourceRef.current.disconnect();
|
||||
boostSourceRef.current = null;
|
||||
boostGainRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [outputVolume, perUserVolume, isDeafened, isLocal, tile.audioTrack]);
|
||||
// --- VIDEO & UI ---
|
||||
const activeVideoTrack = tile.videoTrack;
|
||||
const hasVideo = activeVideoTrack !== null;
|
||||
@@ -141,5 +51,5 @@ export function VoiceUser({ tile, large }) {
|
||||
}, [volumeMenu]);
|
||||
return (_jsxs("div", { 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 w-full' : 'h-full aspect-video'}`, onContextMenu: handleContextMenu, children: [!isLocal && _jsx("audio", { ref: audioRef, autoPlay: true, playsInline: true }), hasVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: isLocal, className: `w-full h-full ${large ? 'object-contain bg-black' : 'object-cover'}` })) : (_jsx("div", { className: "w-full h-full flex flex-col items-center justify-center gap-3 bg-[#1e1f22]", children: _jsxs("div", { className: "relative", children: [_jsx(Avatar, { src: null, name: participant.username, size: large ? 100 : 64 }), participant.isSpeaking && (_jsx("div", { className: "absolute -inset-1.5 rounded-full ring-[3px] ring-discord-green animate-pulse" }))] }) })), _jsx("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", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-1.5 min-w-0", children: [_jsx("span", { className: `font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`, children: participant.username }), isLocal && (_jsx("span", { className: "text-[10px] text-white/40 font-medium", children: "(you)" }))] }), _jsxs("div", { className: "flex items-center gap-1 flex-shrink-0", children: [participant.isMuted && (_jsx("div", { className: "w-5 h-5 bg-discord-red/90 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: [_jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) })), (isLocal ? isDeafened : participant.isDeafened) && (_jsx("div", { className: "w-5 h-5 bg-discord-red/90 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: [_jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) }))] })] }) }), volumeMenu && !isLocal && (_jsxs("div", { className: "fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-3 min-w-[200px] border border-white/[0.06]", style: { left: volumeMenu.x, top: volumeMenu.y }, onClick: (e) => e.stopPropagation(), children: [_jsx("div", { className: "text-xs text-discord-text-muted mb-2 font-medium uppercase tracking-wider", children: "User Volume" }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M3 9v6h4l5 5V4L7 9H3z" }) }), _jsx("input", { type: "range", min: "0", max: "200", value: perUserVolume, onChange: (e) => setParticipantVolume(participant.userId, parseInt(e.target.value)), className: "flex-1 accent-discord-blurple h-1" }), _jsxs("span", { className: "text-xs text-discord-text-secondary min-w-[32px] text-right", children: [perUserVolume, "%"] })] })] }))] }));
|
||||
: 'ring-1 ring-white/[0.06] hover:ring-white/10'} ${large ? 'h-full w-full' : 'h-full aspect-video'}`, onContextMenu: handleContextMenu, children: [hasVideo ? (_jsx("video", { ref: videoRef, autoPlay: true, playsInline: true, muted: isLocal, className: `w-full h-full ${large ? 'object-contain bg-black' : 'object-cover'}` })) : (_jsx("div", { className: "w-full h-full flex flex-col items-center justify-center gap-3 bg-[#1e1f22]", children: _jsxs("div", { className: "relative", children: [_jsx(Avatar, { src: null, name: participant.username, size: large ? 100 : 64 }), participant.isSpeaking && (_jsx("div", { className: "absolute -inset-1.5 rounded-full ring-[3px] ring-discord-green animate-pulse" }))] }) })), _jsx("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", children: _jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-1.5 min-w-0", children: [_jsx("span", { className: `font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`, children: participant.username }), isLocal && (_jsx("span", { className: "text-[10px] text-white/40 font-medium", children: "(you)" }))] }), _jsxs("div", { className: "flex items-center gap-1 flex-shrink-0", children: [participant.isMuted && (_jsx("div", { className: "w-5 h-5 bg-discord-red/90 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: [_jsx("path", { d: "M12 2C10.9 2 10 2.9 10 4V12C10 13.1 10.9 14 12 14C13.1 14 14 13.1 14 12V4C14 2.9 13.1 2 12 2Z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) })), (isLocal ? isDeafened : participant.isDeafened) && (_jsx("div", { className: "w-5 h-5 bg-discord-red/90 rounded-full flex items-center justify-center", children: _jsxs("svg", { width: "12", height: "12", viewBox: "0 0 24 24", fill: "white", children: [_jsx("path", { d: "M12 3c-4.97 0-9 4.03-9 9v7c0 1.1.9 2 2 2h2v-7H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-2v7h2c1.1 0 2-.9 2-2v-7c0-4.97-4.03-9-9-9z" }), _jsx("line", { x1: "3", y1: "3", x2: "21", y2: "21", stroke: "white", strokeWidth: "2" })] }) }))] })] }) }), volumeMenu && !isLocal && (_jsxs("div", { className: "fixed z-[60] bg-[#111214] rounded-lg shadow-2xl p-3 min-w-[200px] border border-white/[0.06]", style: { left: volumeMenu.x, top: volumeMenu.y }, onClick: (e) => e.stopPropagation(), children: [_jsx("div", { className: "text-xs text-discord-text-muted mb-2 font-medium uppercase tracking-wider", children: "User Volume" }), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "currentColor", className: "text-discord-text-muted flex-shrink-0", children: _jsx("path", { d: "M3 9v6h4l5 5V4L7 9H3z" }) }), _jsx("input", { type: "range", min: "0", max: "200", value: perUserVolume, onChange: (e) => setParticipantVolume(participant.userId, parseInt(e.target.value)), className: "flex-1 accent-discord-blurple h-1" }), _jsxs("span", { className: "text-xs text-discord-text-secondary min-w-[32px] text-right", children: [perUserVolume, "%"] })] })] }))] }));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { AudioManager } from '../../audio/AudioManager';
|
||||
import type { UserTile } from '../../hooks/useLiveKit';
|
||||
|
||||
interface VoiceUserProps {
|
||||
@@ -11,11 +10,9 @@ interface VoiceUserProps {
|
||||
|
||||
export function VoiceUser({ tile, large }: VoiceUserProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
const { participant } = tile;
|
||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||
const outputVolume = useVoiceStore((s) => s.outputVolume);
|
||||
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
||||
|
||||
const [, forceUpdate] = useState(0);
|
||||
@@ -23,114 +20,6 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
||||
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
|
||||
const isLocal = participant.isLocal;
|
||||
|
||||
// --- AUDIO PIPELINE: NATIVE FIRST ---
|
||||
|
||||
// Refs for the optional boost pipeline
|
||||
const boostGainRef = useRef<GainNode | null>(null);
|
||||
const boostSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
|
||||
// 1. Basic Track Attachment (The Rock-Solid Foundation)
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
if (isLocal || !audioEl || !tile.audioTrack) return;
|
||||
|
||||
// Direct attachment.
|
||||
const stream = new MediaStream([tile.audioTrack]);
|
||||
|
||||
// Only update if changed to prevent interruptions
|
||||
if ((audioEl.srcObject as MediaStream)?.id !== stream.id) {
|
||||
audioEl.srcObject = stream;
|
||||
|
||||
// Aggressive play attempt for Chrome
|
||||
const tryPlay = async () => {
|
||||
try {
|
||||
await audioEl.play();
|
||||
} catch (err) {
|
||||
console.warn('[Audio] Autoplay blocked, retrying...', err);
|
||||
}
|
||||
};
|
||||
tryPlay();
|
||||
}
|
||||
}, [tile.audioTrack, isLocal]);
|
||||
|
||||
// 2. Volume Management (Hybrid)
|
||||
useEffect(() => {
|
||||
const audioEl = audioRef.current;
|
||||
if (isLocal || !audioEl || !tile.audioTrack) return;
|
||||
|
||||
const globalScale = outputVolume / 100;
|
||||
const userScale = perUserVolume / 100;
|
||||
const finalVolume = globalScale * userScale;
|
||||
|
||||
if (isDeafened) {
|
||||
audioEl.muted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Logic:
|
||||
// If we are boosting (>100%) AND context is running, use Web Audio.
|
||||
// Otherwise, stick to the native element for maximum reliability.
|
||||
|
||||
const audioManager = AudioManager.getInstance();
|
||||
const ctx = audioManager.getContext();
|
||||
const isBoosting = finalVolume > 1.0;
|
||||
const isContextReady = ctx && ctx.state === 'running';
|
||||
|
||||
if (isBoosting && isContextReady) {
|
||||
// --- BOOST MODE (>100%) ---
|
||||
// Setup pipeline if missing
|
||||
if (!boostGainRef.current && ctx) {
|
||||
const gain = ctx.createGain();
|
||||
const source = ctx.createMediaStreamSource(
|
||||
new MediaStream([tile.audioTrack]),
|
||||
);
|
||||
|
||||
source.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
|
||||
boostGainRef.current = gain;
|
||||
boostSourceRef.current = source;
|
||||
}
|
||||
|
||||
// Apply boosted gain
|
||||
if (boostGainRef.current && ctx) {
|
||||
boostGainRef.current.gain.setTargetAtTime(
|
||||
finalVolume,
|
||||
ctx.currentTime,
|
||||
0.01,
|
||||
);
|
||||
}
|
||||
|
||||
// MUTE the element so we don't double audio
|
||||
audioEl.muted = true;
|
||||
} else {
|
||||
// --- STANDARD MODE (0% - 100%) ---
|
||||
// Clean up boost pipeline if it exists
|
||||
if (boostSourceRef.current) {
|
||||
boostSourceRef.current.disconnect();
|
||||
boostSourceRef.current = null;
|
||||
boostGainRef.current = null;
|
||||
}
|
||||
|
||||
// Use the element
|
||||
audioEl.muted = false;
|
||||
audioEl.volume = Math.min(finalVolume, 1.0);
|
||||
|
||||
// Ensure it's playing (in case it was paused/blocked earlier)
|
||||
if (audioEl.paused) {
|
||||
audioEl.play().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (boostSourceRef.current) {
|
||||
boostSourceRef.current.disconnect();
|
||||
boostSourceRef.current = null;
|
||||
boostGainRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [outputVolume, perUserVolume, isDeafened, isLocal, tile.audioTrack]);
|
||||
|
||||
// --- VIDEO & UI ---
|
||||
|
||||
const activeVideoTrack = tile.videoTrack;
|
||||
@@ -187,13 +76,6 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
||||
} ${large ? 'h-full w-full' : 'h-full aspect-video'}`}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/*
|
||||
Native Audio Element
|
||||
- AutoPlay is critical
|
||||
- PlaysInline is critical for mobile
|
||||
*/}
|
||||
{!isLocal && <audio ref={audioRef} autoPlay playsInline />}
|
||||
|
||||
{hasVideo ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
|
||||
Reference in New Issue
Block a user