mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 18:10:47 -04:00
feat(audio): per-user horn/TTS/forward volume with admin-capped ceilings
Every user now gets a personal 0-1 volume for horn, TTS, and mic forward. The value is stored as a fraction of the ceiling that applies to them, so lowering the global admin gain quiets everyone immediately instead of leaving stale absolute values behind. Ceilings resolve in three layers: the global admin gain is the default ceiling; the audioGainBoost flag raises it to an admin-editable hard cap (default 0.5x horn, 0.8x TTS, 0.4x forward); Math.max keeps the flag from ever lowering a ceiling if the global gain is set higher than a cap. Preferences live in identity feature state rather than a cookie so they follow the user and cannot be raised client-side. The rover exposes gain as three ALSA masters, so the resolved gains pushed to a rover are those of the socket currently holding audio control -- re-pushed on driver join/leave and every turn rotation. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ const { loadConfig } = require('../../helpers/configLoader');
|
|||||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
const { isAdmin } = require('../roleService');
|
const { isAdmin } = require('../roleService');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
|
const { getFeatureState, setFeatureState, getUserIdForSocket } = require('../identityService');
|
||||||
const { issueCommand } = require('../commandService');
|
const { issueCommand } = require('../commandService');
|
||||||
|
|
||||||
const audioLevelsEvents = new EventEmitter();
|
const audioLevelsEvents = new EventEmitter();
|
||||||
@@ -16,11 +17,32 @@ const DATA_DIR = resolveDataDir();
|
|||||||
const STORE_PATH = resolveDataPath('audio-levels.json');
|
const STORE_PATH = resolveDataPath('audio-levels.json');
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const configuredDefaults = config.audioLevels || {};
|
const configuredDefaults = config.audioLevels || {};
|
||||||
|
const configuredUserCaps = configuredDefaults.userGainCaps || {};
|
||||||
|
|
||||||
const DEFAULTS = {
|
/*
|
||||||
hornGain: clampGain(configuredDefaults.hornGain, 1),
|
The three gain keys are the same on every layer of this feature: the global
|
||||||
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
|
admin gains, the admin-editable VIP boost caps, and each user's personal
|
||||||
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
|
preference. Iterating one list keeps those layers from drifting apart.
|
||||||
|
*/
|
||||||
|
const GAIN_KEYS = ['hornGain', 'ttsGain', 'forwardGain'];
|
||||||
|
|
||||||
|
/*
|
||||||
|
Per-user preferences live in identity feature state so they follow the user
|
||||||
|
across browsers and cannot be raised by editing a client-side cookie. They are
|
||||||
|
stored as a 0..1 fraction of whatever ceiling currently applies rather than an
|
||||||
|
absolute gain, so lowering the global admin gain immediately quiets everyone
|
||||||
|
without having to rewrite every stored preference.
|
||||||
|
*/
|
||||||
|
const USER_GAINS_NAMESPACE = 'audioGains';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Absolute ceilings for users holding the audioGainBoost flag. These are the
|
||||||
|
hard caps the flag cannot exceed; admins can retune them from the driver page.
|
||||||
|
*/
|
||||||
|
const USER_GAIN_CAP_DEFAULTS = {
|
||||||
|
hornGain: 0.5,
|
||||||
|
ttsGain: 0.8,
|
||||||
|
forwardGain: 0.4,
|
||||||
};
|
};
|
||||||
|
|
||||||
function clampGain(value, fallback = 1) {
|
function clampGain(value, fallback = 1) {
|
||||||
@@ -29,13 +51,41 @@ function clampGain(value, fallback = 1) {
|
|||||||
return Math.max(0, Math.min(4, num));
|
return Math.max(0, Math.min(4, num));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clampFraction(value, fallback = 1) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return fallback;
|
||||||
|
return Math.max(0, Math.min(1, num));
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
hornGain: clampGain(configuredDefaults.hornGain, 1),
|
||||||
|
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
|
||||||
|
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
|
||||||
|
userGainCaps: {
|
||||||
|
hornGain: clampGain(configuredUserCaps.hornGain, USER_GAIN_CAP_DEFAULTS.hornGain),
|
||||||
|
ttsGain: clampGain(configuredUserCaps.ttsGain, USER_GAIN_CAP_DEFAULTS.ttsGain),
|
||||||
|
forwardGain: clampGain(configuredUserCaps.forwardGain, USER_GAIN_CAP_DEFAULTS.forwardGain),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeUserGainCaps(raw = {}, fallback = DEFAULTS.userGainCaps) {
|
||||||
|
return {
|
||||||
|
hornGain: clampGain(raw?.hornGain, fallback.hornGain),
|
||||||
|
ttsGain: clampGain(raw?.ttsGain, fallback.ttsGain),
|
||||||
|
forwardGain: clampGain(raw?.forwardGain, fallback.forwardGain),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeStore(raw = {}) {
|
function normalizeStore(raw = {}) {
|
||||||
return {
|
return {
|
||||||
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
|
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
|
||||||
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
|
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
|
||||||
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
|
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
|
||||||
|
userGainCaps: normalizeUserGainCaps(raw.userGainCaps),
|
||||||
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
|
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
|
||||||
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
|
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
|
||||||
|
capsUpdatedAt: Number.isFinite(raw.capsUpdatedAt) ? raw.capsUpdatedAt : null,
|
||||||
|
capsUpdatedBy: typeof raw.capsUpdatedBy === 'string' ? raw.capsUpdatedBy : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,18 +121,118 @@ function getAudioLevels() {
|
|||||||
hornGain: current.hornGain,
|
hornGain: current.hornGain,
|
||||||
ttsGain: current.ttsGain,
|
ttsGain: current.ttsGain,
|
||||||
forwardGain: current.forwardGain,
|
forwardGain: current.forwardGain,
|
||||||
|
userGainCaps: { ...current.userGainCaps },
|
||||||
updatedAt: current.updatedAt,
|
updatedAt: current.updatedAt,
|
||||||
updatedBy: current.updatedBy,
|
updatedBy: current.updatedBy,
|
||||||
|
capsUpdatedAt: current.capsUpdatedAt,
|
||||||
|
capsUpdatedBy: current.capsUpdatedBy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitChange(reason = 'update') {
|
function getUserGainCaps() {
|
||||||
|
return { ...loadState().userGainCaps };
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitChange(reason = 'update', extra = {}) {
|
||||||
audioLevelsEvents.emit('change', {
|
audioLevelsEvents.emit('change', {
|
||||||
reason,
|
reason,
|
||||||
levels: getAudioLevels(),
|
levels: getAudioLevels(),
|
||||||
|
...extra,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Ceiling resolution. A user without the boost flag can never exceed the global
|
||||||
|
admin gain. The flag raises the ceiling to the admin-managed hard cap, and
|
||||||
|
Math.max keeps the flag from ever being a downgrade: if an admin runs the
|
||||||
|
global gain higher than the boost cap, a boosted user simply keeps the global
|
||||||
|
ceiling instead of losing volume for holding a permission.
|
||||||
|
*/
|
||||||
|
function getGainCeilings(hasBoost) {
|
||||||
|
const current = loadState();
|
||||||
|
const caps = current.userGainCaps;
|
||||||
|
const ceilings = {};
|
||||||
|
GAIN_KEYS.forEach((key) => {
|
||||||
|
const adminCeiling = clampGain(current[key], 0);
|
||||||
|
ceilings[key] = hasBoost ? Math.max(adminCeiling, clampGain(caps[key], 0)) : adminCeiling;
|
||||||
|
});
|
||||||
|
return ceilings;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGainCeilingsForSocket(socket) {
|
||||||
|
return getGainCeilings(Boolean(socket?.data?.hasAudioGainBoost));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUserGains(raw = {}) {
|
||||||
|
const out = {};
|
||||||
|
GAIN_KEYS.forEach((key) => {
|
||||||
|
out[key] = clampFraction(raw?.[key], 1);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserGains(userId) {
|
||||||
|
if (!userId) return normalizeUserGains({});
|
||||||
|
return normalizeUserGains(getFeatureState(userId, USER_GAINS_NAMESPACE, {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserGainsForSocket(socket) {
|
||||||
|
return getUserGains(getUserIdForSocket(socket));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCeilings(fractions, ceilings) {
|
||||||
|
const out = {};
|
||||||
|
GAIN_KEYS.forEach((key) => {
|
||||||
|
out[key] = clampGain(clampFraction(fractions?.[key], 1) * clampGain(ceilings?.[key], 0), 0);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEffectiveLevelsForSocket(socket) {
|
||||||
|
return applyCeilings(getUserGainsForSocket(socket), getGainCeilingsForSocket(socket));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The rover applies gain as three ALSA master controls, so only one set of gains
|
||||||
|
can be live per rover at a time. That is not a limitation in practice: horn,
|
||||||
|
TTS, and mic forwarding are all restricted to the socket currently holding
|
||||||
|
audio control, so pushing that socket's resolved gains gives genuinely
|
||||||
|
per-user volume. When nobody owns audio the global admin gains apply.
|
||||||
|
*/
|
||||||
|
function resolveAudioOwnerSocket(roverId) {
|
||||||
|
const record = roverManager.rovers.get(roverId);
|
||||||
|
if (!record) return null;
|
||||||
|
const driverIds = Array.from(record.drivers || []);
|
||||||
|
if (!driverIds.length) return null;
|
||||||
|
|
||||||
|
// Required lazily: turnService reaches back into roverManager during startup.
|
||||||
|
let activeSocketId = null;
|
||||||
|
try {
|
||||||
|
activeSocketId = require('../turnService').getActiveDrivers()[roverId] || null;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to resolve active driver for audio levels', roverId, err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chosenId = activeSocketId && driverIds.includes(activeSocketId)
|
||||||
|
? activeSocketId
|
||||||
|
: (driverIds.length === 1 ? driverIds[0] : null);
|
||||||
|
if (!chosenId) return null;
|
||||||
|
return io.sockets.sockets.get(chosenId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLevelsForRover(roverId) {
|
||||||
|
const owner = resolveAudioOwnerSocket(roverId);
|
||||||
|
if (!owner) {
|
||||||
|
const current = loadState();
|
||||||
|
return {
|
||||||
|
hornGain: current.hornGain,
|
||||||
|
ttsGain: current.ttsGain,
|
||||||
|
forwardGain: current.forwardGain,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return getEffectiveLevelsForSocket(owner);
|
||||||
|
}
|
||||||
|
|
||||||
function pushLevelsToRover(roverId) {
|
function pushLevelsToRover(roverId) {
|
||||||
if (!roverId) return;
|
if (!roverId) return;
|
||||||
const record = roverManager.rovers.get(roverId);
|
const record = roverManager.rovers.get(roverId);
|
||||||
@@ -90,7 +240,7 @@ function pushLevelsToRover(roverId) {
|
|||||||
try {
|
try {
|
||||||
issueCommand(roverId, {
|
issueCommand(roverId, {
|
||||||
type: 'audioLevels',
|
type: 'audioLevels',
|
||||||
audioLevels: getAudioLevels(),
|
audioLevels: resolveLevelsForRover(roverId),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to push audio levels to rover', roverId, err.message);
|
logger.warn('Failed to push audio levels to rover', roverId, err.message);
|
||||||
@@ -105,6 +255,11 @@ function pushLevelsToAllRovers() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pushLevelsForSocket(socket) {
|
||||||
|
if (!socket) return;
|
||||||
|
roverManager.getRoversForSocket(socket.id).forEach((roverId) => pushLevelsToRover(roverId));
|
||||||
|
}
|
||||||
|
|
||||||
function setAudioLevels(input = {}, actor = null) {
|
function setAudioLevels(input = {}, actor = null) {
|
||||||
const current = loadState();
|
const current = loadState();
|
||||||
const next = {
|
const next = {
|
||||||
@@ -121,12 +276,88 @@ function setAudioLevels(input = {}, actor = null) {
|
|||||||
return getAudioLevels();
|
return getAudioLevels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setUserGainCaps(input = {}, actor = null) {
|
||||||
|
const current = loadState();
|
||||||
|
const next = {
|
||||||
|
...current,
|
||||||
|
userGainCaps: normalizeUserGainCaps(input, current.userGainCaps),
|
||||||
|
capsUpdatedAt: Date.now(),
|
||||||
|
capsUpdatedBy: actor,
|
||||||
|
};
|
||||||
|
persistState(next);
|
||||||
|
/*
|
||||||
|
Lowering a cap has to take effect immediately for anyone already driving,
|
||||||
|
otherwise a boosted user keeps the louder gain until their next turn.
|
||||||
|
*/
|
||||||
|
pushLevelsToAllRovers();
|
||||||
|
emitChange('user_caps_set');
|
||||||
|
return getUserGainCaps();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUserGains(socket, input = {}) {
|
||||||
|
const userId = getUserIdForSocket(socket);
|
||||||
|
if (!userId) throw new Error('Identity required');
|
||||||
|
const current = getUserGains(userId);
|
||||||
|
const next = { ...current };
|
||||||
|
GAIN_KEYS.forEach((key) => {
|
||||||
|
if (input?.[key] === undefined) return;
|
||||||
|
next[key] = clampFraction(input[key], current[key]);
|
||||||
|
});
|
||||||
|
setFeatureState(userId, USER_GAINS_NAMESPACE, next);
|
||||||
|
pushLevelsForSocket(socket);
|
||||||
|
emitChange('user_gains_set', { scope: 'user', userId });
|
||||||
|
return getAudioGainStateForSocket(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The client needs all three layers to render an honest slider: its own stored
|
||||||
|
fraction, the ceiling that fraction is measured against, and the resolved gain
|
||||||
|
so the UI can show what the rover will actually play.
|
||||||
|
*/
|
||||||
|
function getAudioGainStateForSocket(socket) {
|
||||||
|
const current = loadState();
|
||||||
|
const hasBoost = Boolean(socket?.data?.hasAudioGainBoost);
|
||||||
|
const values = getUserGainsForSocket(socket);
|
||||||
|
const ceilings = getGainCeilings(hasBoost);
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
ceilings,
|
||||||
|
effective: applyCeilings(values, ceilings),
|
||||||
|
boostGranted: hasBoost,
|
||||||
|
adminLimits: {
|
||||||
|
hornGain: current.hornGain,
|
||||||
|
ttsGain: current.ttsGain,
|
||||||
|
forwardGain: current.forwardGain,
|
||||||
|
},
|
||||||
|
boostCaps: { ...current.userGainCaps },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
||||||
if (action === 'upsert' && roverId) {
|
if (action === 'upsert' && roverId) {
|
||||||
pushLevelsToRover(roverId);
|
pushLevelsToRover(roverId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
Whoever owns a rover's audio determines which gains are live, so the rover has
|
||||||
|
to be re-pushed whenever that ownership moves: joining or leaving a rover, and
|
||||||
|
every turn rotation.
|
||||||
|
*/
|
||||||
|
roverManager.managerEvents.on('driver', ({ roverId } = {}) => {
|
||||||
|
if (roverId) pushLevelsToRover(roverId);
|
||||||
|
});
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
try {
|
||||||
|
require('../turnService').turnEvents.on('queue', ({ roverId } = {}) => {
|
||||||
|
if (roverId) pushLevelsToRover(roverId);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to subscribe to turn changes for audio levels', err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
socket.on('audioLevels:get', (_, cb = () => {}) => {
|
socket.on('audioLevels:get', (_, cb = () => {}) => {
|
||||||
cb({ success: true, levels: getAudioLevels() });
|
cb({ success: true, levels: getAudioLevels() });
|
||||||
@@ -144,13 +375,51 @@ io.on('connection', (socket) => {
|
|||||||
cb({ error: err.message });
|
cb({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on('audioLevels:setUserCaps', (payload = {}, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
if (!isAdmin(socket)) {
|
||||||
|
throw new Error('Not authorized');
|
||||||
|
}
|
||||||
|
const actor = socket?.data?.user?.username || null;
|
||||||
|
const userGainCaps = setUserGainCaps(payload || {}, actor);
|
||||||
|
cb({ success: true, userGainCaps });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('audioLevels:getUserGains', (_, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
cb({ success: true, audioGains: getAudioGainStateForSocket(socket) });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('audioLevels:setUserGains', (payload = {}, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
cb({ success: true, audioGains: setUserGains(socket, payload || {}) });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
loadState();
|
loadState();
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
GAIN_KEYS,
|
||||||
|
USER_GAIN_CAP_DEFAULTS,
|
||||||
getAudioLevels,
|
getAudioLevels,
|
||||||
setAudioLevels,
|
setAudioLevels,
|
||||||
|
getUserGainCaps,
|
||||||
|
setUserGainCaps,
|
||||||
|
getUserGains,
|
||||||
|
setUserGains,
|
||||||
|
getGainCeilingsForSocket,
|
||||||
|
getEffectiveLevelsForSocket,
|
||||||
|
getAudioGainStateForSocket,
|
||||||
pushLevelsToRover,
|
pushLevelsToRover,
|
||||||
audioLevelsEvents,
|
audioLevelsEvents,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const {
|
|||||||
getUserIdForSocket,
|
getUserIdForSocket,
|
||||||
} = require('../identityService');
|
} = require('../identityService');
|
||||||
const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService');
|
const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService');
|
||||||
const { getAudioLevels, audioLevelsEvents } = require('../audioLevelsService');
|
const { getAudioLevels, getAudioGainStateForSocket, audioLevelsEvents } = require('../audioLevelsService');
|
||||||
const { getButtonBoxState } = require('../buttonBoxService');
|
const { getButtonBoxState } = require('../buttonBoxService');
|
||||||
const { getState: getInterInstanceState, interInstanceEvents } = require('../interInstanceService');
|
const { getState: getInterInstanceState, interInstanceEvents } = require('../interInstanceService');
|
||||||
const {
|
const {
|
||||||
@@ -223,6 +223,7 @@ function buildSession(socket) {
|
|||||||
isVerified: Boolean(socket?.data?.isVerified),
|
isVerified: Boolean(socket?.data?.isVerified),
|
||||||
audioForward: getAudioForwardState(),
|
audioForward: getAudioForwardState(),
|
||||||
audioLevels: getAudioLevels(),
|
audioLevels: getAudioLevels(),
|
||||||
|
audioGains: getAudioGainStateForSocket(socket),
|
||||||
buttonBox: getButtonBoxState(),
|
buttonBox: getButtonBoxState(),
|
||||||
/*
|
/*
|
||||||
Inter-instance state is a read-only directory snapshot. It is included in
|
Inter-instance state is a read-only directory snapshot. It is included in
|
||||||
@@ -454,7 +455,18 @@ audioForwardEvents.on('change', () => {
|
|||||||
syncAll();
|
syncAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
audioLevelsEvents.on('change', () => {
|
audioLevelsEvents.on('change', ({ scope, userId } = {}) => {
|
||||||
|
/*
|
||||||
|
A user dragging their own volume slider only changes their own payload, so
|
||||||
|
it resyncs just that user's tabs. Admin gain and cap edits still change
|
||||||
|
everyone's ceiling and keep the full broadcast.
|
||||||
|
*/
|
||||||
|
if (scope === 'user' && userId) {
|
||||||
|
io.sockets.sockets.forEach((socket) => {
|
||||||
|
if (getUserIdForSocket(socket) === userId) syncSocket(socket);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
syncAll();
|
syncAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user