fix(sounds): full SoundController rewrite — every file fires for the right audience
- stream_started/ended audible to all (was self-only) - stream_user_joined/left wired to the watcher data-channel diff (was misused) - effective-mute/deafen audible mid-call (was self-toggle only) - message sound DM + federation-aware mention default (was every channel) - self-stream-end suppresses per-watcher stream_user_left stack
This commit is contained in:
@@ -2,13 +2,38 @@ import { useEffect, useRef } from 'react';
|
|||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
|
import { useSpaceStore, isDmChannel, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
||||||
import { AudioManager } from '../../audio/AudioManager';
|
import { AudioManager } from '../../audio/AudioManager';
|
||||||
|
import { shouldPlayMessageSound } from '../../utils/notificationFilters';
|
||||||
|
|
||||||
/** Compute the effective sound effect gain: base volume (0.8) scaled by the user's SFX slider (0–200). */
|
/** Compute the effective sound effect gain: base volume (0.8) scaled by the user's SFX slider (0–200). */
|
||||||
function getSfxVolume(): number {
|
function getSfxVolume(): number {
|
||||||
return 0.8 * (useVoiceStore.getState().soundEffectVolume / 100);
|
return 0.8 * (useVoiceStore.getState().soundEffectVolume / 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replicates the `useLiveKit` effective-mute formula on demand. Returns whether
|
||||||
|
* the local user is currently muted/deafened by ANY mechanism (self toggle,
|
||||||
|
* moderator space-mute, or permission-mute).
|
||||||
|
*/
|
||||||
|
function computeEffectiveSelfState(state: ReturnType<typeof useVoiceStore.getState>): {
|
||||||
|
muted: boolean;
|
||||||
|
deafened: boolean;
|
||||||
|
} {
|
||||||
|
const cvId = state.currentVoiceChannelId;
|
||||||
|
if (!cvId) return { muted: state.isMuted, deafened: state.isDeafened };
|
||||||
|
const origin = getChannelOrigin(cvId);
|
||||||
|
const myId = getMyUserIdForOrigin(origin);
|
||||||
|
const spaceId = useSpaceStore.getState().channelToSpaceMap.get(cvId);
|
||||||
|
const key = spaceId && myId ? `${spaceId}:${myId}` : '';
|
||||||
|
const muted =
|
||||||
|
state.isMuted ||
|
||||||
|
state.spaceMutedUserIds.has(key) ||
|
||||||
|
state.permissionMutedUserIds.has(key);
|
||||||
|
const deafened = state.isDeafened || state.spaceDeafenedUserIds.has(key);
|
||||||
|
return { muted, deafened };
|
||||||
|
}
|
||||||
|
|
||||||
export function SoundController() {
|
export function SoundController() {
|
||||||
const audioManager = AudioManager.getInstance();
|
const audioManager = AudioManager.getInstance();
|
||||||
const currentUser = useAuthStore((s) => s.user);
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
@@ -21,60 +46,63 @@ export function SoundController() {
|
|||||||
if (currentUser?.homeUserId) myIds.add(currentUser.homeUserId);
|
if (currentUser?.homeUserId) myIds.add(currentUser.homeUserId);
|
||||||
const isSelf = (id: string) => myIds.has(id);
|
const isSelf = (id: string) => myIds.has(id);
|
||||||
|
|
||||||
// Refs to track previous states
|
|
||||||
const isInitialMount = useRef(true);
|
const isInitialMount = useRef(true);
|
||||||
const prevIsMuted = useRef<boolean>(useVoiceStore.getState().isMuted);
|
const initialState = useVoiceStore.getState();
|
||||||
const prevIsDeafened = useRef<boolean>(useVoiceStore.getState().isDeafened);
|
const initialEff = computeEffectiveSelfState(initialState);
|
||||||
const prevIsCameraOn = useRef<boolean>(useVoiceStore.getState().isCameraOn);
|
|
||||||
const prevIsScreenSharing = useRef<boolean>(useVoiceStore.getState().isScreenSharing);
|
// All previous-sample state lives in one ref so the subscriber callback updates atomically.
|
||||||
const prevIsConnected = useRef<boolean>(useVoiceStore.getState().isLiveKitConnected);
|
const prev = useRef({
|
||||||
const prevParticipantIds = useRef<Set<string>>(new Set(useVoiceStore.getState().participants.map(p => p.userId)));
|
effectiveMuted: initialEff.muted,
|
||||||
const prevScreenShareUserIds = useRef<Set<string>>(new Set(useVoiceStore.getState().participants.filter(p => p.isScreenSharing).map(p => p.userId)));
|
effectiveDeafened: initialEff.deafened,
|
||||||
|
isCameraOn: initialState.isCameraOn,
|
||||||
|
isLiveKitConnected: initialState.isLiveKitConnected,
|
||||||
|
participantIds: new Set(initialState.participants.map((p) => p.userId)),
|
||||||
|
screenShareUserIds: new Set(
|
||||||
|
initialState.participants.filter((p) => p.isScreenSharing).map((p) => p.userId),
|
||||||
|
),
|
||||||
|
selfWatchers: new Set<string>(),
|
||||||
|
});
|
||||||
|
|
||||||
const incomingCallLoop = useRef<AudioBufferSourceNode | null>(null);
|
const incomingCallLoop = useRef<AudioBufferSourceNode | null>(null);
|
||||||
const incomingCallLoading = useRef(false); // sync guard for async playSound
|
const incomingCallLoading = useRef(false);
|
||||||
const outgoingCallLoop = useRef<AudioBufferSourceNode | null>(null);
|
const outgoingCallLoop = useRef<AudioBufferSourceNode | null>(null);
|
||||||
const outgoingCallLoading = useRef(false);
|
const outgoingCallLoading = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Set initial mount flag to false after first run
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
isInitialMount.current = false;
|
isInitialMount.current = false;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
// 1. Listen to Voice State Changes
|
|
||||||
const unsubscribeVoice = useVoiceStore.subscribe((state) => {
|
const unsubscribeVoice = useVoiceStore.subscribe((state) => {
|
||||||
if (isInitialMount.current) return;
|
if (isInitialMount.current) return;
|
||||||
|
|
||||||
const sfxOpts = { volume: getSfxVolume() };
|
const sfxOpts = { volume: getSfxVolume() };
|
||||||
|
|
||||||
// Mute/Unmute
|
// -------- Effective mute / deafen --------
|
||||||
if (state.isMuted !== prevIsMuted.current) {
|
// Only play on transitions where BOTH prev and current samples were taken
|
||||||
audioManager.playSound(state.isMuted ? 'mute' : 'unmute', sfxOpts);
|
// while LK-connected. On the connect/disconnect boundary we snapshot the
|
||||||
prevIsMuted.current = state.isMuted;
|
// current effective state without firing.
|
||||||
|
const eff = computeEffectiveSelfState(state);
|
||||||
|
if (state.isLiveKitConnected && prev.current.isLiveKitConnected) {
|
||||||
|
if (eff.muted !== prev.current.effectiveMuted) {
|
||||||
|
audioManager.playSound(eff.muted ? 'mute' : 'unmute', sfxOpts);
|
||||||
}
|
}
|
||||||
|
if (eff.deafened !== prev.current.effectiveDeafened) {
|
||||||
// Deafen/Undeafen
|
audioManager.playSound(eff.deafened ? 'deafen' : 'undeafen', sfxOpts);
|
||||||
if (state.isDeafened !== prevIsDeafened.current) {
|
|
||||||
audioManager.playSound(state.isDeafened ? 'deafen' : 'undeafen', sfxOpts);
|
|
||||||
prevIsDeafened.current = state.isDeafened;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
prev.current.effectiveMuted = eff.muted;
|
||||||
|
prev.current.effectiveDeafened = eff.deafened;
|
||||||
|
|
||||||
// Camera Toggle
|
// -------- Camera Toggle (self) --------
|
||||||
if (state.isCameraOn !== prevIsCameraOn.current) {
|
if (state.isCameraOn !== prev.current.isCameraOn) {
|
||||||
audioManager.playSound(state.isCameraOn ? 'camera_on' : 'camera_off', sfxOpts);
|
audioManager.playSound(state.isCameraOn ? 'camera_on' : 'camera_off', sfxOpts);
|
||||||
prevIsCameraOn.current = state.isCameraOn;
|
prev.current.isCameraOn = state.isCameraOn;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Screen Share Toggle (Self)
|
// -------- Self connect / disconnect --------
|
||||||
if (state.isScreenSharing !== prevIsScreenSharing.current) {
|
const justDisconnected = prev.current.isLiveKitConnected && !state.isLiveKitConnected;
|
||||||
audioManager.playSound(state.isScreenSharing ? 'stream_started' : 'stream_ended', sfxOpts);
|
const justConnected = !prev.current.isLiveKitConnected && state.isLiveKitConnected;
|
||||||
prevIsScreenSharing.current = state.isScreenSharing;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Self-disconnect detection — check BEFORE participant sounds
|
|
||||||
const justDisconnected = prevIsConnected.current && !state.isLiveKitConnected;
|
|
||||||
const justConnected = !prevIsConnected.current && state.isLiveKitConnected;
|
|
||||||
|
|
||||||
if (justDisconnected) {
|
if (justDisconnected) {
|
||||||
audioManager.playSound('disconnect', sfxOpts);
|
audioManager.playSound('disconnect', sfxOpts);
|
||||||
@@ -82,53 +110,93 @@ export function SoundController() {
|
|||||||
if (justConnected) {
|
if (justConnected) {
|
||||||
audioManager.playSound('user_join', sfxOpts);
|
audioManager.playSound('user_join', sfxOpts);
|
||||||
}
|
}
|
||||||
prevIsConnected.current = state.isLiveKitConnected;
|
prev.current.isLiveKitConnected = state.isLiveKitConnected;
|
||||||
|
|
||||||
// Participant Joins/Leaves & Screen Sharing
|
// -------- Participant set diff --------
|
||||||
// CRITICAL: Skip entirely if we just disconnected or are not connected.
|
const currentParticipantIds = new Set(state.participants.map((p) => p.userId));
|
||||||
// Without this guard, hanging up plays "user_leave" for every participant
|
const currentScreenShareUserIds = new Set(
|
||||||
// (they "left" from our perspective) simultaneously with the disconnect sound.
|
state.participants.filter((p) => p.isScreenSharing).map((p) => p.userId),
|
||||||
const currentParticipantIds = new Set(state.participants.map(p => p.userId));
|
);
|
||||||
const currentScreenShareUserIds = new Set(state.participants.filter(p => p.isScreenSharing).map(p => p.userId));
|
|
||||||
|
|
||||||
if (state.isLiveKitConnected && !justDisconnected) {
|
const myStreamerId = currentUser?.id;
|
||||||
// Someone joined voice (Others only)
|
const selfIsSharing = myStreamerId ? currentScreenShareUserIds.has(myStreamerId) : false;
|
||||||
state.participants.forEach(p => {
|
const selfStreamJustStarted =
|
||||||
if (!prevParticipantIds.current.has(p.userId) && !isSelf(p.userId)) {
|
!!myStreamerId &&
|
||||||
|
currentScreenShareUserIds.has(myStreamerId) &&
|
||||||
|
!prev.current.screenShareUserIds.has(myStreamerId);
|
||||||
|
const selfStreamJustEnded =
|
||||||
|
!!myStreamerId &&
|
||||||
|
!currentScreenShareUserIds.has(myStreamerId) &&
|
||||||
|
prev.current.screenShareUserIds.has(myStreamerId);
|
||||||
|
|
||||||
|
// Suppress join/leave + stream sounds on the connect tick. On
|
||||||
|
// justConnected, prev.participantIds is the empty/initial set, so the
|
||||||
|
// naive diff would fire user_join (and possibly stream_started) once per
|
||||||
|
// pre-existing remote participant. Snapshot baseline only; sounds come
|
||||||
|
// from real future transitions.
|
||||||
|
if (state.isLiveKitConnected && !justDisconnected && !justConnected) {
|
||||||
|
// user_join (others)
|
||||||
|
state.participants.forEach((p) => {
|
||||||
|
if (!prev.current.participantIds.has(p.userId) && !isSelf(p.userId)) {
|
||||||
audioManager.playSound('user_join', sfxOpts);
|
audioManager.playSound('user_join', sfxOpts);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// user_leave (others)
|
||||||
// Someone left voice (Others only)
|
prev.current.participantIds.forEach((userId) => {
|
||||||
prevParticipantIds.current.forEach(userId => {
|
|
||||||
if (!currentParticipantIds.has(userId) && !isSelf(userId)) {
|
if (!currentParticipantIds.has(userId) && !isSelf(userId)) {
|
||||||
audioManager.playSound('user_leave', sfxOpts);
|
audioManager.playSound('user_leave', sfxOpts);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// stream_started — ANY participant (incl. self), audible to all
|
||||||
// Someone started screen sharing (Others only)
|
state.participants.forEach((p) => {
|
||||||
state.participants.forEach(p => {
|
if (p.isScreenSharing && !prev.current.screenShareUserIds.has(p.userId)) {
|
||||||
if (p.isScreenSharing && !prevScreenShareUserIds.current.has(p.userId) && !isSelf(p.userId)) {
|
audioManager.playSound('stream_started', sfxOpts);
|
||||||
audioManager.playSound('stream_user_joined', sfxOpts);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// stream_ended — ANY participant, audible to all
|
||||||
// Someone stopped screen sharing (Others only)
|
prev.current.screenShareUserIds.forEach((userId) => {
|
||||||
prevScreenShareUserIds.current.forEach(userId => {
|
if (!currentScreenShareUserIds.has(userId)) {
|
||||||
if (!currentScreenShareUserIds.has(userId) && !isSelf(userId)) {
|
audioManager.playSound('stream_ended', sfxOpts);
|
||||||
audioManager.playSound('stream_user_left', sfxOpts);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
prevParticipantIds.current = currentParticipantIds;
|
prev.current.participantIds = currentParticipantIds;
|
||||||
prevScreenShareUserIds.current = currentScreenShareUserIds;
|
prev.current.screenShareUserIds = currentScreenShareUserIds;
|
||||||
|
|
||||||
// Incoming Call (Ringing) — sync guard prevents multiple playSound during async load
|
// -------- Viewer tracking — streamer-side only (§3.2) --------
|
||||||
|
// The full diff is gated on `selfIsSharing`. When self isn't sharing,
|
||||||
|
// both prev and current are forced to ∅ — no sounds fire even if a late
|
||||||
|
// ping mutates the store. On both stream-start and stream-end transitions
|
||||||
|
// for self, eagerly call clearStreamWatchers; the gate makes the
|
||||||
|
// re-entered subscriber tick silent. This eliminates the race where a
|
||||||
|
// late "Stop Watching" ping arriving between a deferred clear's schedule
|
||||||
|
// and execution would fire phantom stream_user_joined / left.
|
||||||
|
if (myStreamerId && (selfStreamJustStarted || selfStreamJustEnded)) {
|
||||||
|
useVoiceStore.getState().clearStreamWatchers(myStreamerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (myStreamerId && state.isLiveKitConnected && !justDisconnected && selfIsSharing) {
|
||||||
|
const live = new Set(state.streamWatchers.get(myStreamerId) ?? []);
|
||||||
|
const past = prev.current.selfWatchers;
|
||||||
|
|
||||||
|
live.forEach((identity) => {
|
||||||
|
if (!past.has(identity)) audioManager.playSound('stream_user_joined', sfxOpts);
|
||||||
|
});
|
||||||
|
past.forEach((identity) => {
|
||||||
|
if (!live.has(identity)) audioManager.playSound('stream_user_left', sfxOpts);
|
||||||
|
});
|
||||||
|
prev.current.selfWatchers = live;
|
||||||
|
} else {
|
||||||
|
prev.current.selfWatchers = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------- Incoming Call (Ringing) --------
|
||||||
if (state.incomingCall && !incomingCallLoop.current && !incomingCallLoading.current) {
|
if (state.incomingCall && !incomingCallLoop.current && !incomingCallLoading.current) {
|
||||||
incomingCallLoading.current = true;
|
incomingCallLoading.current = true;
|
||||||
audioManager.playSound('call_ringing', { loop: true, volume: getSfxVolume() }).then(source => {
|
audioManager
|
||||||
// If call was cancelled while sound was loading, stop immediately
|
.playSound('call_ringing', { loop: true, volume: getSfxVolume() })
|
||||||
|
.then((source) => {
|
||||||
if (!useVoiceStore.getState().incomingCall) {
|
if (!useVoiceStore.getState().incomingCall) {
|
||||||
source?.stop();
|
source?.stop();
|
||||||
} else {
|
} else {
|
||||||
@@ -144,10 +212,12 @@ export function SoundController() {
|
|||||||
incomingCallLoading.current = false;
|
incomingCallLoading.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Outgoing Call (Calling) — same sync guard pattern
|
// -------- Outgoing Call (Calling) --------
|
||||||
if (state.outgoingCall && !outgoingCallLoop.current && !outgoingCallLoading.current) {
|
if (state.outgoingCall && !outgoingCallLoop.current && !outgoingCallLoading.current) {
|
||||||
outgoingCallLoading.current = true;
|
outgoingCallLoading.current = true;
|
||||||
audioManager.playSound('call_calling', { loop: true, volume: getSfxVolume() }).then(source => {
|
audioManager
|
||||||
|
.playSound('call_calling', { loop: true, volume: getSfxVolume() })
|
||||||
|
.then((source) => {
|
||||||
if (!useVoiceStore.getState().outgoingCall) {
|
if (!useVoiceStore.getState().outgoingCall) {
|
||||||
source?.stop();
|
source?.stop();
|
||||||
} else {
|
} else {
|
||||||
@@ -164,17 +234,31 @@ export function SoundController() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Listen to Chat State Changes (New Real-Time Messages from any channel)
|
// -------- Chat: message sound (DM + mention default, federation-aware) --------
|
||||||
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
|
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
|
||||||
if (isInitialMount.current) return;
|
if (isInitialMount.current) return;
|
||||||
|
|
||||||
// Only trigger on NEW realtimeMessageEvents entries (not API loads)
|
|
||||||
if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) {
|
if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) {
|
||||||
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
|
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
|
||||||
for (const { message } of newEvents) {
|
const allChannels = useVoiceStore.getState().messageSoundAllChannels;
|
||||||
if (message.userId !== currentUser?.id) {
|
// Use the wrapper-level channelId from RealtimeMessageEvent — set by
|
||||||
|
// addRealtimeMessage(channelId, message). It's authoritative for both
|
||||||
|
// space and DM messages. Avoids reaching into the heterogeneous Message
|
||||||
|
// shape (where DM messages may carry dmChannelId at runtime).
|
||||||
|
for (const { channelId, message } of newEvents) {
|
||||||
|
if (!channelId) continue;
|
||||||
|
const isDm = isDmChannel(channelId);
|
||||||
|
if (
|
||||||
|
shouldPlayMessageSound({
|
||||||
|
authorUserId: message.userId,
|
||||||
|
myIds,
|
||||||
|
isDmChannel: isDm,
|
||||||
|
content: message.content,
|
||||||
|
allChannels,
|
||||||
|
})
|
||||||
|
) {
|
||||||
audioManager.playSound('message', { volume: getSfxVolume() });
|
audioManager.playSound('message', { volume: getSfxVolume() });
|
||||||
break; // one sound per batch
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,4 +275,3 @@ export function SoundController() {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user