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:
Jannis Braun
2026-02-20 04:27:18 +01:00
parent 1aa40cca15
commit 44018aa588
12 changed files with 390 additions and 435 deletions
@@ -16,6 +16,7 @@ import { NewDmModal } from '../modals/NewDmModal';
import { IncomingCallModal } from '../voice/IncomingCallModal';
import { PictureInPicture } from '../voice/PictureInPicture';
import { SoundController } from '../voice/SoundController';
import { GlobalAudioRenderer } from '../voice/GlobalAudioRenderer';
import { UserProfilePopout } from '../ui/UserProfilePopout';
import { useAuth } from '../../hooks/useAuth';
import { useWebSocket } from '../../hooks/useWebSocket';
@@ -166,5 +167,5 @@ export function AppLayout() {
if (isLoading || !user) {
return (_jsx("div", { className: "h-screen flex items-center justify-center bg-discord-bg-primary", children: _jsxs("div", { className: "text-center", children: [_jsxs("svg", { className: "animate-spin w-10 h-10 text-discord-blurple mx-auto mb-4", viewBox: "0 0 24 24", fill: "none", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] }), _jsx("p", { className: "text-discord-text-muted", children: "Loading Opencord..." })] }) }));
}
return (_jsxs("div", { className: "h-screen flex bg-discord-bg-tertiary overflow-hidden", children: [_jsxs("div", { className: `${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : 'flex h-full'}`, children: [_jsx(ServerSidebar, {}), _jsx(ChannelSidebar, {})] }), _jsxs("div", { className: "flex-1 flex min-w-0 bg-discord-bg-primary relative", children: [_jsx(MainContent, {}), serverId === '@me' ? (_jsx("div", { className: "w-[358px] bg-discord-bg-secondary flex-shrink-0 hidden xl:flex flex-col", children: _jsxs("div", { className: "p-4", children: [_jsx("h3", { className: "text-[20px] font-bold text-discord-text-header mb-4", children: "Active Now" }), _jsxs("div", { className: "text-center py-8", children: [_jsx("div", { className: "text-[16px] font-bold text-discord-text-header mb-1 text-center", children: "It's quiet for now..." }), _jsx("div", { className: "text-[14px] text-discord-text-muted text-center max-w-[200px] mx-auto", children: "When a friend starts an activity\u2014like playing a game or hanging out on voice\u2014we\u2019ll show it here!" })] })] }) })) : (_jsx(MemberSidebar, {}))] }), _jsx(CreateServerModal, {}), _jsx(JoinServerModal, {}), _jsx(CreateChannelModal, {}), _jsx(InviteModal, {}), _jsx(UserSettingsModal, {}), _jsx(ServerSettingsModal, {}), _jsx(NewDmModal, {}), _jsx(IncomingCallModal, {}), _jsx(ImagePreview, {}), _jsx(PictureInPicture, {}), _jsx(SoundController, {}), userProfilePopout.user && userProfilePopout.position && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-[45]", onClick: closeUserProfile }), _jsx(UserProfilePopout, { user: userProfilePopout.user, onClose: closeUserProfile, position: userProfilePopout.position })] }))] }));
return (_jsxs("div", { className: "h-screen flex bg-discord-bg-tertiary overflow-hidden", children: [_jsxs("div", { className: `${isMobile ? `fixed z-40 h-full transition-transform ${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}` : 'flex h-full'}`, children: [_jsx(ServerSidebar, {}), _jsx(ChannelSidebar, {})] }), _jsxs("div", { className: "flex-1 flex min-w-0 bg-discord-bg-primary relative", children: [_jsx(MainContent, {}), serverId === '@me' ? (_jsx("div", { className: "w-[358px] bg-discord-bg-secondary flex-shrink-0 hidden xl:flex flex-col", children: _jsxs("div", { className: "p-4", children: [_jsx("h3", { className: "text-[20px] font-bold text-discord-text-header mb-4", children: "Active Now" }), _jsxs("div", { className: "text-center py-8", children: [_jsx("div", { className: "text-[16px] font-bold text-discord-text-header mb-1 text-center", children: "It's quiet for now..." }), _jsx("div", { className: "text-[14px] text-discord-text-muted text-center max-w-[200px] mx-auto", children: "When a friend starts an activity\u2014like playing a game or hanging out on voice\u2014we\u2019ll show it here!" })] })] }) })) : (_jsx(MemberSidebar, {}))] }), _jsx(CreateServerModal, {}), _jsx(JoinServerModal, {}), _jsx(CreateChannelModal, {}), _jsx(InviteModal, {}), _jsx(UserSettingsModal, {}), _jsx(ServerSettingsModal, {}), _jsx(NewDmModal, {}), _jsx(IncomingCallModal, {}), _jsx(ImagePreview, {}), _jsx(PictureInPicture, {}), _jsx(SoundController, {}), _jsx(GlobalAudioRenderer, {}), userProfilePopout.user && userProfilePopout.position && (_jsxs(_Fragment, { children: [_jsx("div", { className: "fixed inset-0 z-[45]", onClick: closeUserProfile }), _jsx(UserProfilePopout, { user: userProfilePopout.user, onClose: closeUserProfile, position: userProfilePopout.position })] }))] }));
}
@@ -16,6 +16,7 @@ import { NewDmModal } from '../modals/NewDmModal';
import { IncomingCallModal } from '../voice/IncomingCallModal';
import { PictureInPicture } from '../voice/PictureInPicture';
import { SoundController } from '../voice/SoundController';
import { GlobalAudioRenderer } from '../voice/GlobalAudioRenderer';
import { UserProfilePopout } from '../ui/UserProfilePopout';
import { useAuth } from '../../hooks/useAuth';
import { useWebSocket } from '../../hooks/useWebSocket';
@@ -240,6 +241,7 @@ export function AppLayout() {
<ImagePreview />
<PictureInPicture />
<SoundController />
<GlobalAudioRenderer />
{/* User Profile Popout */}
{userProfilePopout.user && userProfilePopout.position && (
@@ -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}
+2 -13
View File
@@ -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) {
+1 -91
View File
@@ -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}
@@ -0,0 +1,95 @@
import { useRef, useEffect } from 'react';
import { AudioManager } from '../audio/AudioManager';
/**
* Shared hybrid audio pipeline hook.
*
* Manages an <audio> element ref with two modes:
* - Standard mode (volume <= 1.0): uses native HTMLAudioElement volume
* - Boost mode (volume > 1.0): routes through Web Audio GainNode for amplification
*
* The caller must render: <audio ref={audioRef} autoPlay playsInline />
*/
export function useAudioTrackPlayer(opts) {
const audioRef = useRef(null);
const boostGainRef = useRef(null);
const boostSourceRef = useRef(null);
const { track, volume, muted } = opts;
// Effect 1: Track attachment
useEffect(() => {
const audioEl = audioRef.current;
if (!audioEl || !track) {
if (audioEl)
audioEl.srcObject = null;
return;
}
const stream = new MediaStream([track]);
// Only update if the stream actually changed to prevent interruptions
if (audioEl.srcObject?.id !== stream.id) {
audioEl.srcObject = stream;
const tryPlay = async () => {
try {
await audioEl.play();
}
catch (err) {
console.warn('[Audio] Autoplay blocked, retrying...', err);
}
};
tryPlay();
}
}, [track]);
// Effect 2: Volume management (hybrid native/Web Audio)
useEffect(() => {
const audioEl = audioRef.current;
if (!audioEl || !track)
return;
if (muted) {
audioEl.muted = true;
return;
}
const audioManager = AudioManager.getInstance();
const ctx = audioManager.getContext();
const isBoosting = volume > 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([track]));
source.connect(gain);
gain.connect(ctx.destination);
boostGainRef.current = gain;
boostSourceRef.current = source;
}
// Apply boosted gain
if (boostGainRef.current && ctx) {
boostGainRef.current.gain.setTargetAtTime(volume, 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;
}
audioEl.muted = false;
audioEl.volume = Math.min(volume, 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;
}
};
}, [volume, muted, track]);
return audioRef;
}
@@ -0,0 +1,115 @@
import { useRef, useEffect } from 'react';
import { AudioManager } from '../audio/AudioManager';
interface UseAudioTrackPlayerOptions {
track: MediaStreamTrack | null;
volume: number; // final computed value, 0.0 2.0+
muted: boolean; // deafen, stream mute, etc.
}
/**
* Shared hybrid audio pipeline hook.
*
* Manages an <audio> element ref with two modes:
* - Standard mode (volume <= 1.0): uses native HTMLAudioElement volume
* - Boost mode (volume > 1.0): routes through Web Audio GainNode for amplification
*
* The caller must render: <audio ref={audioRef} autoPlay playsInline />
*/
export function useAudioTrackPlayer(
opts: UseAudioTrackPlayerOptions,
): React.RefObject<HTMLAudioElement> {
const audioRef = useRef<HTMLAudioElement | null>(null);
const boostGainRef = useRef<GainNode | null>(null);
const boostSourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
const { track, volume, muted } = opts;
// Effect 1: Track attachment
useEffect(() => {
const audioEl = audioRef.current;
if (!audioEl || !track) {
if (audioEl) audioEl.srcObject = null;
return;
}
const stream = new MediaStream([track]);
// Only update if the stream actually changed to prevent interruptions
if ((audioEl.srcObject as MediaStream)?.id !== stream.id) {
audioEl.srcObject = stream;
const tryPlay = async () => {
try {
await audioEl.play();
} catch (err) {
console.warn('[Audio] Autoplay blocked, retrying...', err);
}
};
tryPlay();
}
}, [track]);
// Effect 2: Volume management (hybrid native/Web Audio)
useEffect(() => {
const audioEl = audioRef.current;
if (!audioEl || !track) return;
if (muted) {
audioEl.muted = true;
return;
}
const audioManager = AudioManager.getInstance();
const ctx = audioManager.getContext();
const isBoosting = volume > 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([track]));
source.connect(gain);
gain.connect(ctx.destination);
boostGainRef.current = gain;
boostSourceRef.current = source;
}
// Apply boosted gain
if (boostGainRef.current && ctx) {
boostGainRef.current.gain.setTargetAtTime(volume, 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;
}
audioEl.muted = false;
audioEl.volume = Math.min(volume, 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;
}
};
}, [volume, muted, track]);
return audioRef;
}