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:
@@ -16,6 +16,7 @@ import { NewDmModal } from '../modals/NewDmModal';
|
|||||||
import { IncomingCallModal } from '../voice/IncomingCallModal';
|
import { IncomingCallModal } from '../voice/IncomingCallModal';
|
||||||
import { PictureInPicture } from '../voice/PictureInPicture';
|
import { PictureInPicture } from '../voice/PictureInPicture';
|
||||||
import { SoundController } from '../voice/SoundController';
|
import { SoundController } from '../voice/SoundController';
|
||||||
|
import { GlobalAudioRenderer } from '../voice/GlobalAudioRenderer';
|
||||||
import { UserProfilePopout } from '../ui/UserProfilePopout';
|
import { UserProfilePopout } from '../ui/UserProfilePopout';
|
||||||
import { useAuth } from '../../hooks/useAuth';
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||||
@@ -166,5 +167,5 @@ export function AppLayout() {
|
|||||||
if (isLoading || !user) {
|
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 (_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 { IncomingCallModal } from '../voice/IncomingCallModal';
|
||||||
import { PictureInPicture } from '../voice/PictureInPicture';
|
import { PictureInPicture } from '../voice/PictureInPicture';
|
||||||
import { SoundController } from '../voice/SoundController';
|
import { SoundController } from '../voice/SoundController';
|
||||||
|
import { GlobalAudioRenderer } from '../voice/GlobalAudioRenderer';
|
||||||
import { UserProfilePopout } from '../ui/UserProfilePopout';
|
import { UserProfilePopout } from '../ui/UserProfilePopout';
|
||||||
import { useAuth } from '../../hooks/useAuth';
|
import { useAuth } from '../../hooks/useAuth';
|
||||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||||
@@ -240,6 +241,7 @@ export function AppLayout() {
|
|||||||
<ImagePreview />
|
<ImagePreview />
|
||||||
<PictureInPicture />
|
<PictureInPicture />
|
||||||
<SoundController />
|
<SoundController />
|
||||||
|
<GlobalAudioRenderer />
|
||||||
|
|
||||||
{/* User Profile Popout */}
|
{/* User Profile Popout */}
|
||||||
{userProfilePopout.user && userProfilePopout.position && (
|
{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 { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { AudioManager } from '../../audio/AudioManager';
|
|
||||||
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
|
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
|
||||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||||
export function StreamTile({ tile, large }) {
|
export function StreamTile({ tile, large }) {
|
||||||
const videoRef = useRef(null);
|
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 streamVolumes = useVoiceStore((s) => s.streamVolumes);
|
||||||
const streamMutes = useVoiceStore((s) => s.streamMutes);
|
const streamMutes = useVoiceStore((s) => s.streamMutes);
|
||||||
const watchingStreams = useVoiceStore((s) => s.watchingStreams);
|
const watchingStreams = useVoiceStore((s) => s.watchingStreams);
|
||||||
const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled);
|
const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled);
|
||||||
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
|
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
|
||||||
const participants = useVoiceStore((s) => s.participants);
|
|
||||||
const { participant } = tile;
|
const { participant } = tile;
|
||||||
const isLocal = participant.isLocal;
|
const isLocal = participant.isLocal;
|
||||||
const userId = participant.userId;
|
const userId = participant.userId;
|
||||||
@@ -28,90 +23,6 @@ export function StreamTile({ tile, large }) {
|
|||||||
// Context menu state
|
// Context menu state
|
||||||
const [contextMenu, setContextMenu] = useState(null);
|
const [contextMenu, setContextMenu] = useState(null);
|
||||||
const [qualityPopoverOpen, setQualityPopoverOpen] = useState(false);
|
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 ---
|
// --- VIDEO ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const videoEl = videoRef.current;
|
const videoEl = videoRef.current;
|
||||||
@@ -198,7 +109,7 @@ export function StreamTile({ tile, large }) {
|
|||||||
const setAttenuationEnabled = useVoiceStore((s) => s.setStreamAttenuationEnabled);
|
const setAttenuationEnabled = useVoiceStore((s) => s.setStreamAttenuationEnabled);
|
||||||
const setAttenuationStrength = useVoiceStore((s) => s.setStreamAttenuationStrength);
|
const setAttenuationStrength = useVoiceStore((s) => s.setStreamAttenuationStrength);
|
||||||
const hasVideo = liveScreenTrack !== null;
|
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) */
|
/* Streamer context menu (own stream) */
|
||||||
_jsxs(_Fragment, { children: [_jsxs("button", { onClick: () => {
|
_jsxs(_Fragment, { children: [_jsxs("button", { onClick: () => {
|
||||||
handleStopStreaming();
|
handleStopStreaming();
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { AudioManager } from '../../audio/AudioManager';
|
|
||||||
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
|
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
|
||||||
import { VideoQualityPopover } from './VideoQualityPopover';
|
import { VideoQualityPopover } from './VideoQualityPopover';
|
||||||
import type { StreamTile as StreamTileType } from '../../hooks/useLiveKit';
|
import type { StreamTile as StreamTileType } from '../../hooks/useLiveKit';
|
||||||
@@ -13,16 +12,12 @@ interface StreamTileProps {
|
|||||||
|
|
||||||
export function StreamTile({ tile, large }: StreamTileProps) {
|
export function StreamTile({ tile, large }: StreamTileProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
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 streamVolumes = useVoiceStore((s) => s.streamVolumes);
|
||||||
const streamMutes = useVoiceStore((s) => s.streamMutes);
|
const streamMutes = useVoiceStore((s) => s.streamMutes);
|
||||||
const watchingStreams = useVoiceStore((s) => s.watchingStreams);
|
const watchingStreams = useVoiceStore((s) => s.watchingStreams);
|
||||||
const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled);
|
const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled);
|
||||||
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
|
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
|
||||||
const participants = useVoiceStore((s) => s.participants);
|
|
||||||
|
|
||||||
const { participant } = tile;
|
const { participant } = tile;
|
||||||
const isLocal = participant.isLocal;
|
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 [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
|
||||||
const [qualityPopoverOpen, setQualityPopoverOpen] = useState(false);
|
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 ---
|
// --- VIDEO ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const videoEl = videoRef.current;
|
const videoEl = videoRef.current;
|
||||||
@@ -242,9 +138,6 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
|||||||
}`}
|
}`}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
>
|
>
|
||||||
{/* Screen share audio (remote only) */}
|
|
||||||
{!isLocal && <audio ref={screenAudioRef} autoPlay playsInline />}
|
|
||||||
|
|
||||||
{hasVideo && isWatching ? (
|
{hasVideo && isWatching ? (
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
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 { VoiceUser } from './VoiceUser';
|
||||||
import { StreamTile } from './StreamTile';
|
import { StreamTile } from './StreamTile';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
@@ -7,28 +7,17 @@ import { deriveGridTiles } from '../../hooks/useLiveKit';
|
|||||||
export function VoiceGrid({ participants }) {
|
export function VoiceGrid({ participants }) {
|
||||||
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
|
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
|
||||||
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant);
|
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant);
|
||||||
const prevStreamKeysRef = useRef(new Set());
|
|
||||||
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
|
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
|
||||||
// Auto-focus when a new stream tile appears
|
// Unfocus if the focused stream tile no longer exists
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentStreamKeys = new Set(tiles
|
const currentStreamKeys = new Set(tiles
|
||||||
.filter((t) => t.kind === 'stream' && t.screenTrack?.readyState === 'live')
|
.filter((t) => t.kind === 'stream' && t.screenTrack?.readyState === 'live')
|
||||||
.map((t) => t.key));
|
.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 &&
|
if (focusedParticipantId &&
|
||||||
focusedParticipantId.endsWith(':stream') &&
|
focusedParticipantId.endsWith(':stream') &&
|
||||||
!currentStreamKeys.has(focusedParticipantId)) {
|
!currentStreamKeys.has(focusedParticipantId)) {
|
||||||
setFocusedParticipant(null);
|
setFocusedParticipant(null);
|
||||||
}
|
}
|
||||||
prevStreamKeysRef.current = currentStreamKeys;
|
|
||||||
}, [tiles, focusedParticipantId, setFocusedParticipant]);
|
}, [tiles, focusedParticipantId, setFocusedParticipant]);
|
||||||
if (tiles.length === 0) {
|
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..." })] }) }));
|
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 { VoiceUser } from './VoiceUser';
|
||||||
import { StreamTile } from './StreamTile';
|
import { StreamTile } from './StreamTile';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
@@ -12,11 +12,10 @@ interface VoiceGridProps {
|
|||||||
export function VoiceGrid({ participants }: VoiceGridProps) {
|
export function VoiceGrid({ participants }: VoiceGridProps) {
|
||||||
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
|
const focusedParticipantId = useVoiceStore((s) => s.focusedParticipantId);
|
||||||
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant);
|
const setFocusedParticipant = useVoiceStore((s) => s.setFocusedParticipant);
|
||||||
const prevStreamKeysRef = useRef<Set<string>>(new Set());
|
|
||||||
|
|
||||||
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
|
const tiles = useMemo(() => deriveGridTiles(participants), [participants]);
|
||||||
|
|
||||||
// Auto-focus when a new stream tile appears
|
// Unfocus if the focused stream tile no longer exists
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const currentStreamKeys = new Set(
|
const currentStreamKeys = new Set(
|
||||||
tiles
|
tiles
|
||||||
@@ -27,16 +26,6 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
|
|||||||
.map((t) => t.key),
|
.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 (
|
if (
|
||||||
focusedParticipantId &&
|
focusedParticipantId &&
|
||||||
focusedParticipantId.endsWith(':stream') &&
|
focusedParticipantId.endsWith(':stream') &&
|
||||||
@@ -44,8 +33,6 @@ export function VoiceGrid({ participants }: VoiceGridProps) {
|
|||||||
) {
|
) {
|
||||||
setFocusedParticipant(null);
|
setFocusedParticipant(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
prevStreamKeysRef.current = currentStreamKeys;
|
|
||||||
}, [tiles, focusedParticipantId, setFocusedParticipant]);
|
}, [tiles, focusedParticipantId, setFocusedParticipant]);
|
||||||
|
|
||||||
if (tiles.length === 0) {
|
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 { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { AudioManager } from '../../audio/AudioManager';
|
|
||||||
export function VoiceUser({ tile, large }) {
|
export function VoiceUser({ tile, large }) {
|
||||||
const videoRef = useRef(null);
|
const videoRef = useRef(null);
|
||||||
const audioRef = useRef(null);
|
|
||||||
const { participant } = tile;
|
const { participant } = tile;
|
||||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||||
const outputVolume = useVoiceStore((s) => s.outputVolume);
|
|
||||||
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
||||||
const [, forceUpdate] = useState(0);
|
const [, forceUpdate] = useState(0);
|
||||||
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
|
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
|
||||||
const isLocal = participant.isLocal;
|
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 ---
|
// --- VIDEO & UI ---
|
||||||
const activeVideoTrack = tile.videoTrack;
|
const activeVideoTrack = tile.videoTrack;
|
||||||
const hasVideo = activeVideoTrack !== null;
|
const hasVideo = activeVideoTrack !== null;
|
||||||
@@ -141,5 +51,5 @@ export function VoiceUser({ tile, large }) {
|
|||||||
}, [volumeMenu]);
|
}, [volumeMenu]);
|
||||||
return (_jsxs("div", { className: `relative bg-[#111214] rounded-xl overflow-hidden flex items-center justify-center group transition-all duration-200 ${participant.isSpeaking
|
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-[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 React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { AudioManager } from '../../audio/AudioManager';
|
|
||||||
import type { UserTile } from '../../hooks/useLiveKit';
|
import type { UserTile } from '../../hooks/useLiveKit';
|
||||||
|
|
||||||
interface VoiceUserProps {
|
interface VoiceUserProps {
|
||||||
@@ -11,11 +10,9 @@ interface VoiceUserProps {
|
|||||||
|
|
||||||
export function VoiceUser({ tile, large }: VoiceUserProps) {
|
export function VoiceUser({ tile, large }: VoiceUserProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
|
||||||
|
|
||||||
const { participant } = tile;
|
const { participant } = tile;
|
||||||
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
const isDeafened = useVoiceStore((s) => s.isDeafened);
|
||||||
const outputVolume = useVoiceStore((s) => s.outputVolume);
|
|
||||||
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
|
||||||
|
|
||||||
const [, forceUpdate] = useState(0);
|
const [, forceUpdate] = useState(0);
|
||||||
@@ -23,114 +20,6 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
|||||||
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
|
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
|
||||||
const isLocal = participant.isLocal;
|
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 ---
|
// --- VIDEO & UI ---
|
||||||
|
|
||||||
const activeVideoTrack = tile.videoTrack;
|
const activeVideoTrack = tile.videoTrack;
|
||||||
@@ -187,13 +76,6 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
|||||||
} ${large ? 'h-full w-full' : 'h-full aspect-video'}`}
|
} ${large ? 'h-full w-full' : 'h-full aspect-video'}`}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
>
|
>
|
||||||
{/*
|
|
||||||
Native Audio Element
|
|
||||||
- AutoPlay is critical
|
|
||||||
- PlaysInline is critical for mobile
|
|
||||||
*/}
|
|
||||||
{!isLocal && <audio ref={audioRef} autoPlay playsInline />}
|
|
||||||
|
|
||||||
{hasVideo ? (
|
{hasVideo ? (
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user