mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 01:50:47 -04:00
bandwidth savings configs
This commit is contained in:
@@ -6,14 +6,23 @@
|
|||||||
- verified only
|
- verified only
|
||||||
- not allowed
|
- not allowed
|
||||||
- snapshots
|
- snapshots
|
||||||
- non-turn snapshots
|
- non-turn video
|
||||||
- on (you see snapshots when its not your turn)
|
- snapshots (rover non-active turn holders and PTZ non-operators see snapshots)
|
||||||
- off (everyone gets full video all the time)
|
- live (rover non-active turn holders and PTZ non-operators can get full video)
|
||||||
- non-local spectator snapshots
|
- external spectator video
|
||||||
- on (external spectators are only allowed snapshots)
|
- snapshots (external spectators are only allowed snapshots)
|
||||||
- off (all spectators get full video)
|
- live (external spectators can get full video)
|
||||||
- external spectator access (new)
|
- external spectator access (new)
|
||||||
- off (no one can access the spectate page externally)
|
- off (no one can access the spectate page externally)
|
||||||
- on (everyone can access the spectate page externally)
|
- on (everyone can access the spectate page externally)
|
||||||
- admin (external spectators get mode gate overlay, when logged in once that identity gets spectator access forever. use database.)
|
- admin (external spectators get mode gate overlay, when logged in once that identity gets spectator access forever. use database.)
|
||||||
- anything else related to bandwidth savings should also get config
|
- anything else related to bandwidth savings should also get config
|
||||||
|
|
||||||
|
## implemented config shape
|
||||||
|
```yaml
|
||||||
|
bandwidthSavings:
|
||||||
|
multiTabProtection: "verifiedOnly" # allowed | verifiedOnly | notAllowed
|
||||||
|
nonTurnVideo: "snapshots" # snapshots | live
|
||||||
|
externalSpectatorVideo: "snapshots" # snapshots | live
|
||||||
|
externalSpectatorAccess: "on" # off | on | admin
|
||||||
|
```
|
||||||
|
|||||||
@@ -54,6 +54,25 @@ media:
|
|||||||
# Example: http://192.168.0.86:8889/video
|
# Example: http://192.168.0.86:8889/video
|
||||||
whepBaseUrl: "http://192.168.0.86:8889/video"
|
whepBaseUrl: "http://192.168.0.86:8889/video"
|
||||||
|
|
||||||
|
bandwidthSavings:
|
||||||
|
# Duplicate driver-tab handling for the same browser identity.
|
||||||
|
# allowed: no duplicate-tab protection
|
||||||
|
# verifiedOnly: verified/admin users may keep multiple driver tabs; unverified users may not
|
||||||
|
# notAllowed: every identity is limited to one driver tab
|
||||||
|
multiTabProtection: "verifiedOnly"
|
||||||
|
# Live video for users who are attached to a source but do not currently own
|
||||||
|
# its active turn. "snapshots" saves upload bandwidth; "live" allows full
|
||||||
|
# video whenever the normal mode/visibility rules allow it.
|
||||||
|
nonTurnVideo: "snapshots"
|
||||||
|
# Live video for spectators outside the local network. Local spectators are
|
||||||
|
# not restricted by this switch because LAN traffic is not the upload limit.
|
||||||
|
externalSpectatorVideo: "snapshots"
|
||||||
|
# Whether non-local users may enter the spectator page.
|
||||||
|
# off: block external spectators
|
||||||
|
# on: allow external spectators
|
||||||
|
# admin: require an identity feature-state grant at spectatorAccess.external
|
||||||
|
externalSpectatorAccess: "on"
|
||||||
|
|
||||||
audioForward:
|
audioForward:
|
||||||
enabled: true
|
enabled: true
|
||||||
ffmpegBin: "ffmpeg"
|
ffmpegBin: "ffmpeg"
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -78,7 +78,7 @@
|
|||||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-DPK0Bqgk.js"></script>
|
<script type="module" crossorigin src="/assets/index-DfbLYlWD.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-sm_EKOxJ.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-sm_EKOxJ.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
// Bandwidth Savings Helper
|
||||||
|
// Purpose: Normalizes bandwidth-saving config and exposes tiny policy helpers.
|
||||||
|
// Scope: Keeps cross-service video/tab/spectator decisions consistent without
|
||||||
|
// making individual services know raw YAML defaults or legacy config shapes.
|
||||||
|
const { loadConfig } = require('./configLoader');
|
||||||
|
|
||||||
|
const MULTI_TAB_MODES = new Set(['allowed', 'verifiedOnly', 'notAllowed']);
|
||||||
|
const VIDEO_MODES = new Set(['snapshots', 'live']);
|
||||||
|
const EXTERNAL_SPECTATOR_ACCESS_MODES = new Set(['off', 'on', 'admin']);
|
||||||
|
|
||||||
|
const DEFAULT_BANDWIDTH_SAVINGS = Object.freeze({
|
||||||
|
multiTabProtection: 'verifiedOnly',
|
||||||
|
nonTurnVideo: 'snapshots',
|
||||||
|
externalSpectatorVideo: 'snapshots',
|
||||||
|
externalSpectatorAccess: 'on',
|
||||||
|
});
|
||||||
|
|
||||||
|
function normalizeEnum(value, allowed, fallback) {
|
||||||
|
/*
|
||||||
|
Config files are hand-edited on the server, so a typo should not crash the
|
||||||
|
process or silently broaden access. Each option falls back to the current
|
||||||
|
conservative behavior unless it exactly matches a known value.
|
||||||
|
*/
|
||||||
|
const normalized = typeof value === 'string' ? value.trim() : '';
|
||||||
|
return allowed.has(normalized) ? normalized : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBandwidthSavingsPolicy(config = loadConfig()) {
|
||||||
|
const raw = config.bandwidthSavings || {};
|
||||||
|
return {
|
||||||
|
multiTabProtection: normalizeEnum(
|
||||||
|
raw.multiTabProtection,
|
||||||
|
MULTI_TAB_MODES,
|
||||||
|
DEFAULT_BANDWIDTH_SAVINGS.multiTabProtection,
|
||||||
|
),
|
||||||
|
nonTurnVideo: normalizeEnum(
|
||||||
|
raw.nonTurnVideo,
|
||||||
|
VIDEO_MODES,
|
||||||
|
DEFAULT_BANDWIDTH_SAVINGS.nonTurnVideo,
|
||||||
|
),
|
||||||
|
externalSpectatorVideo: normalizeEnum(
|
||||||
|
raw.externalSpectatorVideo,
|
||||||
|
VIDEO_MODES,
|
||||||
|
DEFAULT_BANDWIDTH_SAVINGS.externalSpectatorVideo,
|
||||||
|
),
|
||||||
|
externalSpectatorAccess: normalizeEnum(
|
||||||
|
raw.externalSpectatorAccess,
|
||||||
|
EXTERNAL_SPECTATOR_ACCESS_MODES,
|
||||||
|
DEFAULT_BANDWIDTH_SAVINGS.externalSpectatorAccess,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBandwidthSavingsPolicy() {
|
||||||
|
/*
|
||||||
|
loadConfig() is cached by configLoader, so rebuilding this small object per
|
||||||
|
caller is cheap while still letting tests pass explicit config objects into
|
||||||
|
buildBandwidthSavingsPolicy().
|
||||||
|
*/
|
||||||
|
return buildBandwidthSavingsPolicy(loadConfig());
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldEnforceSingleDriverTab({ isVerified = false, isAdmin = false } = {}) {
|
||||||
|
const { multiTabProtection } = getBandwidthSavingsPolicy();
|
||||||
|
if (multiTabProtection === 'allowed') return false;
|
||||||
|
if (multiTabProtection === 'notAllowed') return true;
|
||||||
|
/*
|
||||||
|
verifiedOnly preserves the old behavior: trusted users can run multiple
|
||||||
|
driver tabs for operations/testing, while anonymous users are limited to one
|
||||||
|
active driver surface for fairness and bandwidth.
|
||||||
|
*/
|
||||||
|
return !isVerified && !isAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseSnapshotsForNonTurnVideo() {
|
||||||
|
return getBandwidthSavingsPolicy().nonTurnVideo === 'snapshots';
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldUseSnapshotsForExternalSpectatorVideo() {
|
||||||
|
return getBandwidthSavingsPolicy().externalSpectatorVideo === 'snapshots';
|
||||||
|
}
|
||||||
|
|
||||||
|
function canUseExternalSpectatorAccess({
|
||||||
|
isLocal = false,
|
||||||
|
isAdmin = false,
|
||||||
|
hasGrant = false,
|
||||||
|
} = {}) {
|
||||||
|
/*
|
||||||
|
Local/LAN spectators are not the upload-bandwidth problem, and admins need
|
||||||
|
to retain access for maintenance. The configured external mode only applies
|
||||||
|
to ordinary non-local spectator sockets.
|
||||||
|
*/
|
||||||
|
if (isLocal || isAdmin) return true;
|
||||||
|
const { externalSpectatorAccess } = getBandwidthSavingsPolicy();
|
||||||
|
if (externalSpectatorAccess === 'off') return false;
|
||||||
|
if (externalSpectatorAccess === 'admin') return Boolean(hasGrant);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULT_BANDWIDTH_SAVINGS,
|
||||||
|
buildBandwidthSavingsPolicy,
|
||||||
|
getBandwidthSavingsPolicy,
|
||||||
|
shouldEnforceSingleDriverTab,
|
||||||
|
shouldUseSnapshotsForNonTurnVideo,
|
||||||
|
shouldUseSnapshotsForExternalSpectatorVideo,
|
||||||
|
canUseExternalSpectatorAccess,
|
||||||
|
};
|
||||||
@@ -8,9 +8,19 @@ const { loadConfig } = require('../../helpers/configLoader');
|
|||||||
const { clearLockdownTimer } = require('../lockdownGuard');
|
const { clearLockdownTimer } = require('../lockdownGuard');
|
||||||
const { getMode, MODES } = require('../modeManager');
|
const { getMode, MODES } = require('../modeManager');
|
||||||
const { setRole } = require('../roleService');
|
const { setRole } = require('../roleService');
|
||||||
|
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
||||||
|
const {
|
||||||
|
canUseExternalSpectatorAccess,
|
||||||
|
getBandwidthSavingsPolicy,
|
||||||
|
} = require('../../helpers/bandwidthSavings');
|
||||||
|
const {
|
||||||
|
getFeatureState,
|
||||||
|
getUserIdForSocket,
|
||||||
|
} = require('../identityService');
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const admins = config.admins || [];
|
const admins = config.admins || [];
|
||||||
|
const SPECTATOR_ACCESS_NAMESPACE = 'spectatorAccess';
|
||||||
|
|
||||||
function findAdmin(username) {
|
function findAdmin(username) {
|
||||||
return admins.find((admin) => admin.username === username);
|
return admins.find((admin) => admin.username === username);
|
||||||
@@ -36,9 +46,44 @@ function isLockdownAdmin(socket) {
|
|||||||
return socket?.data?.role === 'lockdown';
|
return socket?.data?.role === 'lockdown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasExternalSpectatorGrant(socket) {
|
||||||
|
const userId = getUserIdForSocket(socket);
|
||||||
|
if (!userId) return false;
|
||||||
|
const state = getFeatureState(userId, SPECTATOR_ACCESS_NAMESPACE, {});
|
||||||
|
/*
|
||||||
|
The identity database already owns per-user feature state. Keeping the grant
|
||||||
|
as a tiny namespaced boolean avoids a new table and lets the existing admin
|
||||||
|
database editor grant/revoke external spectator access immediately.
|
||||||
|
*/
|
||||||
|
return Boolean(state?.external);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canBecomeSpectator(socket) {
|
||||||
|
const ip = getSocketIp(socket);
|
||||||
|
const local = isLocalNetwork(ip);
|
||||||
|
return canUseExternalSpectatorAccess({
|
||||||
|
isLocal: local,
|
||||||
|
isAdmin: isAdmin(socket),
|
||||||
|
hasGrant: hasExternalSpectatorGrant(socket),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function externalSpectatorAccessError() {
|
||||||
|
const mode = getBandwidthSavingsPolicy().externalSpectatorAccess;
|
||||||
|
if (mode === 'admin') {
|
||||||
|
return 'External spectator access requires admin approval for this identity.';
|
||||||
|
}
|
||||||
|
return 'External spectator access is disabled.';
|
||||||
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
const requestedRole = socket.handshake?.query?.role;
|
const requestedRole = socket.handshake?.query?.role;
|
||||||
const initialRole = requestedRole === 'spectator' ? 'spectator' : 'user';
|
/*
|
||||||
|
Role is assigned before the browser's full identity heartbeat has completed.
|
||||||
|
For admin-gated external spectators, fail closed here; the spectator page can
|
||||||
|
identify the socket and then retry session:setRole once the grant exists.
|
||||||
|
*/
|
||||||
|
const initialRole = requestedRole === 'spectator' && canBecomeSpectator(socket) ? 'spectator' : 'user';
|
||||||
setRole(socket, initialRole);
|
setRole(socket, initialRole);
|
||||||
logger.info('Socket connected with role', socket.id, initialRole);
|
logger.info('Socket connected with role', socket.id, initialRole);
|
||||||
socket.emit('auth:role', { role: initialRole });
|
socket.emit('auth:role', { role: initialRole });
|
||||||
@@ -63,6 +108,12 @@ io.on('connection', (socket) => {
|
|||||||
|
|
||||||
function handleRoleChange({ role } = {}, cb = () => {}) {
|
function handleRoleChange({ role } = {}, cb = () => {}) {
|
||||||
if (role === 'spectator' || role === 'user') {
|
if (role === 'spectator' || role === 'user') {
|
||||||
|
if (role === 'spectator' && !canBecomeSpectator(socket)) {
|
||||||
|
const error = externalSpectatorAccessError();
|
||||||
|
logger.info('Spectator role denied by bandwidth policy', socket.id, { error });
|
||||||
|
cb({ error });
|
||||||
|
return;
|
||||||
|
}
|
||||||
setRole(socket, role);
|
setRole(socket, role);
|
||||||
socket.emit('auth:role', { role });
|
socket.emit('auth:role', { role });
|
||||||
logger.info('Role changed via client request', socket.id, role);
|
logger.info('Role changed via client request', socket.id, role);
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ const io = require('../../globals/io');
|
|||||||
const logger = require('../../globals/logger').child('ptzCamera');
|
const logger = require('../../globals/logger').child('ptzCamera');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
const { isFeatureEnabled } = require('../../helpers/features');
|
const { isFeatureEnabled } = require('../../helpers/features');
|
||||||
|
const {
|
||||||
|
shouldUseSnapshotsForNonTurnVideo,
|
||||||
|
shouldUseSnapshotsForExternalSpectatorVideo,
|
||||||
|
} = require('../../helpers/bandwidthSavings');
|
||||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||||
const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
|
const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
|
||||||
const { isVerified } = require('../verificationService');
|
const { isVerified } = require('../verificationService');
|
||||||
@@ -1291,7 +1295,26 @@ function canRequestLiveVideo(socket) {
|
|||||||
if (!enabled || !passesMode(socket)) return false;
|
if (!enabled || !passesMode(socket)) return false;
|
||||||
if (state.operatorSocketId === socket?.id) return true;
|
if (state.operatorSocketId === socket?.id) return true;
|
||||||
if (isAdmin(socket) || isLockdownAdmin(socket)) return true;
|
if (isAdmin(socket) || isLockdownAdmin(socket)) return true;
|
||||||
return isLocalNetwork(getSocketIp(socket));
|
const role = getRole(socket);
|
||||||
|
const local = isLocalNetwork(getSocketIp(socket));
|
||||||
|
if (role === 'spectator') {
|
||||||
|
/*
|
||||||
|
Spectator PTZ viewing follows the spectator bandwidth switch. LAN viewers
|
||||||
|
stay live because they do not consume server upload; non-local spectators
|
||||||
|
only get live PTZ when the external spectator video policy allows it.
|
||||||
|
*/
|
||||||
|
return local || !shouldUseSnapshotsForExternalSpectatorVideo();
|
||||||
|
}
|
||||||
|
if (canUsePtzFeature(socket) && !shouldUseSnapshotsForNonTurnVideo()) {
|
||||||
|
/*
|
||||||
|
Verified/VIP users who can queue or claim the camera are PTZ "turn"
|
||||||
|
participants even before they become operator. When non-turn video is set
|
||||||
|
to live, they may watch the live feed while waiting; camera movement still
|
||||||
|
remains limited to the active operator by the command handlers.
|
||||||
|
*/
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSnapshotPath() {
|
function getSnapshotPath() {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const logger = require('../../globals/logger').child('sessionService');
|
const logger = require('../../globals/logger').child('sessionService');
|
||||||
const { getRole, roleEvents } = require('../roleService');
|
const { getRole, isAdmin, roleEvents } = require('../roleService');
|
||||||
const { getMode, modeEvents } = require('../modeManager');
|
const { getMode, modeEvents } = require('../modeManager');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { managerEvents } = roverManager;
|
const { managerEvents } = roverManager;
|
||||||
@@ -39,6 +39,14 @@ const { getAdminReason } = require('../adminReasonService');
|
|||||||
const { subscribe } = require('../eventBus');
|
const { subscribe } = require('../eventBus');
|
||||||
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
||||||
const { getFeatureFlags } = require('../../helpers/features');
|
const { getFeatureFlags } = require('../../helpers/features');
|
||||||
|
const {
|
||||||
|
canUseExternalSpectatorAccess,
|
||||||
|
getBandwidthSavingsPolicy,
|
||||||
|
} = require('../../helpers/bandwidthSavings');
|
||||||
|
const {
|
||||||
|
getFeatureState,
|
||||||
|
getUserIdForSocket,
|
||||||
|
} = require('../identityService');
|
||||||
const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService');
|
const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService');
|
||||||
const { getAudioLevels, audioLevelsEvents } = require('../audioLevelsService');
|
const { getAudioLevels, audioLevelsEvents } = require('../audioLevelsService');
|
||||||
const { getButtonBoxState } = require('../buttonBoxService');
|
const { getButtonBoxState } = require('../buttonBoxService');
|
||||||
@@ -62,6 +70,35 @@ logger.info('Discord invite loaded:', discordInvite ? 'present' : 'not configure
|
|||||||
logger.info('Ko-fi link loaded:', kofiLink ? 'present' : 'not configured');
|
logger.info('Ko-fi link loaded:', kofiLink ? 'present' : 'not configured');
|
||||||
logger.info('Socials config loaded:', configuredSocials?.length ? `${configuredSocials.length} entries` : 'not configured');
|
logger.info('Socials config loaded:', configuredSocials?.length ? `${configuredSocials.length} entries` : 'not configured');
|
||||||
|
|
||||||
|
const SPECTATOR_ACCESS_NAMESPACE = 'spectatorAccess';
|
||||||
|
|
||||||
|
function hasExternalSpectatorGrant(socket) {
|
||||||
|
const userId = getUserIdForSocket(socket);
|
||||||
|
if (!userId) return false;
|
||||||
|
const state = getFeatureState(userId, SPECTATOR_ACCESS_NAMESPACE, {});
|
||||||
|
return Boolean(state?.external);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBandwidthSavingsSessionState(socket) {
|
||||||
|
const policy = getBandwidthSavingsPolicy();
|
||||||
|
const local = isLocalNetwork(getSocketIp(socket));
|
||||||
|
const granted = hasExternalSpectatorGrant(socket);
|
||||||
|
return {
|
||||||
|
...policy,
|
||||||
|
/*
|
||||||
|
These derived fields let browser routes make clear UI choices without
|
||||||
|
re-implementing IP/admin/grant logic. The server still enforces the same
|
||||||
|
decisions in auth and video services, so the UI remains advisory only.
|
||||||
|
*/
|
||||||
|
externalSpectatorGranted: granted,
|
||||||
|
canUseExternalSpectatorAccess: canUseExternalSpectatorAccess({
|
||||||
|
isLocal: local,
|
||||||
|
isAdmin: isAdmin(socket),
|
||||||
|
hasGrant: granted,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildUserEntry(socket) {
|
function buildUserEntry(socket) {
|
||||||
if (!socket) return null;
|
if (!socket) return null;
|
||||||
const role = getRole(socket);
|
const role = getRole(socket);
|
||||||
@@ -110,6 +147,7 @@ function buildSession(socket) {
|
|||||||
role: getRole(socket),
|
role: getRole(socket),
|
||||||
mode: getMode(),
|
mode: getMode(),
|
||||||
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
|
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
|
||||||
|
bandwidthSavings: buildBandwidthSavingsSessionState(socket),
|
||||||
/*
|
/*
|
||||||
Features is the single UI contract for optional server capabilities. A
|
Features is the single UI contract for optional server capabilities. A
|
||||||
disabled feature should be absent from navigation/layout decisions even
|
disabled feature should be absent from navigation/layout decisions even
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const {
|
|||||||
resolveUserBySelector,
|
resolveUserBySelector,
|
||||||
userToLegacyIdentityEntry,
|
userToLegacyIdentityEntry,
|
||||||
} = require('../identityService');
|
} = require('../identityService');
|
||||||
|
const { shouldEnforceSingleDriverTab } = require('../../helpers/bandwidthSavings');
|
||||||
|
|
||||||
const verificationEvents = new EventEmitter();
|
const verificationEvents = new EventEmitter();
|
||||||
const IDENTITY_TIMEOUT_MS = 2 * 60 * 1000;
|
const IDENTITY_TIMEOUT_MS = 2 * 60 * 1000;
|
||||||
@@ -103,7 +104,7 @@ function identifySocket(socket, payload = {}) {
|
|||||||
nickname: incomingNickname || getNickname(socket) || '',
|
nickname: incomingNickname || getNickname(socket) || '',
|
||||||
});
|
});
|
||||||
refreshSocketIdentityFlags(socket);
|
refreshSocketIdentityFlags(socket);
|
||||||
enforceSingleUnverifiedSocketPerIdentity(socket);
|
enforceSingleDriverSocketPerIdentity(socket);
|
||||||
emitChange('identify', { socketId: socket.id, userId: result.userId });
|
emitChange('identify', { socketId: socket.id, userId: result.userId });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -132,13 +133,18 @@ function emitDuplicateIdentityAndDisconnect(socket, payload = {}) {
|
|||||||
}, DUPLICATE_IDENTITY_DISCONNECT_DELAY_MS);
|
}, DUPLICATE_IDENTITY_DISCONNECT_DELAY_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
function enforceSingleUnverifiedSocketPerIdentity(currentSocket) {
|
function enforceSingleDriverSocketPerIdentity(currentSocket) {
|
||||||
const currentUserId = getUserIdForSocket(currentSocket);
|
const currentUserId = getUserIdForSocket(currentSocket);
|
||||||
|
const currentRole = getRole(currentSocket);
|
||||||
|
const enforceForCurrentSocket = shouldEnforceSingleDriverTab({
|
||||||
|
isVerified: Boolean(currentSocket?.data?.isVerified),
|
||||||
|
isAdmin: isAdminRole(currentRole),
|
||||||
|
});
|
||||||
if (
|
if (
|
||||||
!currentSocket?.id ||
|
!currentSocket?.id ||
|
||||||
!currentUserId ||
|
!currentUserId ||
|
||||||
currentSocket.data?.isVerified ||
|
currentSocket.data?.identitySurface !== 'driver' ||
|
||||||
currentSocket.data?.identitySurface !== 'driver'
|
!enforceForCurrentSocket
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -150,9 +156,21 @@ function enforceSingleUnverifiedSocketPerIdentity(currentSocket) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!duplicates.length) return;
|
if (!duplicates.length) return;
|
||||||
const verifiedDuplicate = duplicates.find((candidate) => candidate?.data?.isVerified);
|
const verifiedDuplicate = duplicates.find((candidate) => {
|
||||||
|
/*
|
||||||
|
verifiedOnly keeps the previous "verified tab wins" rule. In notAllowed
|
||||||
|
mode, verified users are subject to the same single-driver-tab rule, so a
|
||||||
|
verified duplicate should not protect the newer socket from enforcement.
|
||||||
|
*/
|
||||||
|
const candidateRole = getRole(candidate);
|
||||||
|
const enforceForCandidate = shouldEnforceSingleDriverTab({
|
||||||
|
isVerified: Boolean(candidate?.data?.isVerified),
|
||||||
|
isAdmin: isAdminRole(candidateRole),
|
||||||
|
});
|
||||||
|
return candidate?.data?.isVerified && !enforceForCandidate;
|
||||||
|
});
|
||||||
if (verifiedDuplicate) {
|
if (verifiedDuplicate) {
|
||||||
logger.info('Disconnecting non-verified socket because its user is already active on a verified socket', {
|
logger.info('Disconnecting driver socket because its user is already active on an exempt verified socket', {
|
||||||
socketId: currentSocket.id,
|
socketId: currentSocket.id,
|
||||||
retainedSocketId: verifiedDuplicate.id,
|
retainedSocketId: verifiedDuplicate.id,
|
||||||
userId: currentUserId,
|
userId: currentUserId,
|
||||||
@@ -162,7 +180,7 @@ function enforceSingleUnverifiedSocketPerIdentity(currentSocket) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
duplicates.forEach((duplicate) => {
|
duplicates.forEach((duplicate) => {
|
||||||
logger.info('Disconnecting older non-verified duplicate user socket', {
|
logger.info('Disconnecting older duplicate driver socket', {
|
||||||
socketId: duplicate.id,
|
socketId: duplicate.id,
|
||||||
retainedSocketId: currentSocket.id,
|
retainedSocketId: currentSocket.id,
|
||||||
userId: currentUserId,
|
userId: currentUserId,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
// Video Auth Policy
|
// Video Auth Policy
|
||||||
// Purpose: Encapsulates mode, role, and stream-specific authorization decisions for MediaMTX auth checks.
|
// Purpose: Encapsulates mode, role, and stream-specific authorization decisions for MediaMTX auth checks.
|
||||||
// Scope: Evaluates viewer/publisher eligibility from normalized request context and socket/session state.
|
// Scope: Evaluates viewer/publisher eligibility from normalized request context and socket/session state.
|
||||||
|
const {
|
||||||
|
shouldUseSnapshotsForNonTurnVideo,
|
||||||
|
shouldUseSnapshotsForExternalSpectatorVideo,
|
||||||
|
} = require('../../helpers/bandwidthSavings');
|
||||||
|
|
||||||
function createVideoAuthPolicy(deps) {
|
function createVideoAuthPolicy(deps) {
|
||||||
const {
|
const {
|
||||||
getMode,
|
getMode,
|
||||||
@@ -63,7 +68,7 @@ function createVideoAuthPolicy(deps) {
|
|||||||
const isAudio = streamInfo.id?.endsWith('-audio');
|
const isAudio = streamInfo.id?.endsWith('-audio');
|
||||||
if (role === 'spectator' && !isAdmin(socket) && !isAudio) {
|
if (role === 'spectator' && !isAdmin(socket) && !isAudio) {
|
||||||
const socketIp = getSocketIp(socket);
|
const socketIp = getSocketIp(socket);
|
||||||
if (!isLocalNetwork(socketIp)) {
|
if (!isLocalNetwork(socketIp) && shouldUseSnapshotsForExternalSpectatorVideo()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,6 +78,14 @@ function createVideoAuthPolicy(deps) {
|
|||||||
if (!roverManager.isDriver(roverId, socket)) {
|
if (!roverManager.isDriver(roverId, socket)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!isAudio && shouldUseSnapshotsForNonTurnVideo() && !turnService.canDrive(roverId, socket)) {
|
||||||
|
/*
|
||||||
|
This mirrors videoSocketService's token gate. MediaMTX can ask auth
|
||||||
|
after a token has been issued, so the active-turn bandwidth rule must
|
||||||
|
be evaluated here too instead of trusting an older browser decision.
|
||||||
|
*/
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -8,8 +8,13 @@ const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
|
|||||||
const videoSessions = require('../videoSessions');
|
const videoSessions = require('../videoSessions');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const ptzCameraService = require('../ptzCameraService');
|
const ptzCameraService = require('../ptzCameraService');
|
||||||
|
const turnService = require('../turnService');
|
||||||
const { loadConfig } = require('../../helpers/configLoader');
|
const { loadConfig } = require('../../helpers/configLoader');
|
||||||
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
||||||
|
const {
|
||||||
|
shouldUseSnapshotsForNonTurnVideo,
|
||||||
|
shouldUseSnapshotsForExternalSpectatorVideo,
|
||||||
|
} = require('../../helpers/bandwidthSavings');
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const mediaConfig = config.media || {};
|
const mediaConfig = config.media || {};
|
||||||
@@ -116,10 +121,25 @@ io.on('connection', (socket) => {
|
|||||||
const role = getRole(socket);
|
const role = getRole(socket);
|
||||||
if (role === 'spectator' && !isAdmin(socket) && !isAudio) {
|
if (role === 'spectator' && !isAdmin(socket) && !isAudio) {
|
||||||
const ip = getSocketIp(socket);
|
const ip = getSocketIp(socket);
|
||||||
if (!isLocalNetwork(ip)) {
|
if (!isLocalNetwork(ip) && shouldUseSnapshotsForExternalSpectatorVideo()) {
|
||||||
throw new Error('Not authorized for video');
|
throw new Error('Not authorized for video');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
!isAudio &&
|
||||||
|
role !== 'spectator' &&
|
||||||
|
!isAdmin(socket) &&
|
||||||
|
shouldUseSnapshotsForNonTurnVideo() &&
|
||||||
|
!turnService.canDrive(baseId, socket)
|
||||||
|
) {
|
||||||
|
/*
|
||||||
|
The browser also forces snapshots for non-active turn holders, but
|
||||||
|
the socket token path must enforce the same rule. Otherwise a stale
|
||||||
|
component or direct socket caller could still mint a MediaMTX token
|
||||||
|
while the UI is showing snapshots.
|
||||||
|
*/
|
||||||
|
throw new Error('Live video is limited to the active turn');
|
||||||
|
}
|
||||||
} else if (target.type === 'room') {
|
} else if (target.type === 'room') {
|
||||||
throw new Error('Room cameras now use the snapshot feed');
|
throw new Error('Room cameras now use the snapshot feed');
|
||||||
} else if (target.type === 'ptz') {
|
} else if (target.type === 'ptz') {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
1. improve spectator page, options on what to see and what not to see
|
1. improve spectator page, options on what to see and what not to see
|
||||||
2. assign rovers based on battery percentage, give people highest one
|
2. assign rovers based on battery percentage, give people highest one
|
||||||
3. add config to disable client snapshot forcing, disable bandwidth saving
|
3. verify centralized bandwidth-saving config in real external/LAN testing
|
||||||
4. add admin ui for VIP and private requests instead of only through discord
|
4. add admin ui for VIP and private requests instead of only through discord
|
||||||
5. pull page / tab title from session, use the name from profile of interinstance if enabled, if not just default to old one
|
5. pull page / tab title from session, use the name from profile of interinstance if enabled, if not just default to old one
|
||||||
6. make overcurrent limiter speed sensitive, slower fill at lower speeds
|
6. make overcurrent limiter speed sensitive, slower fill at lower speeds
|
||||||
|
|||||||
@@ -540,13 +540,23 @@ function buildPtzTurnModel(ptz, selfId) {
|
|||||||
|
|
||||||
function PtzMediaPane({ ptz, open, framed = true }) {
|
function PtzMediaPane({ ptz, open, framed = true }) {
|
||||||
const isOperator = Boolean(ptz?.isOperator);
|
const isOperator = Boolean(ptz?.isOperator);
|
||||||
|
const nonTurnVideoPolicy = useSessionSelector(
|
||||||
|
(state) => state.session?.bandwidthSavings?.nonTurnVideo || 'snapshots',
|
||||||
|
);
|
||||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||||
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !isOperator });
|
/*
|
||||||
|
PTZ has its own turn queue, so non-operators are the camera equivalent of a
|
||||||
|
non-active rover driver. The server enforces the same policy in
|
||||||
|
canRequestLiveVideo(); this branch only chooses the expected browser render
|
||||||
|
path and never unlocks movement controls.
|
||||||
|
*/
|
||||||
|
const shouldUseLiveVideo = isOperator || nonTurnVideoPolicy === 'live';
|
||||||
|
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo });
|
||||||
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
||||||
const turnModel = useMemo(() => buildPtzTurnModel(ptz, selfId), [ptz, selfId]);
|
const turnModel = useMemo(() => buildPtzTurnModel(ptz, selfId), [ptz, selfId]);
|
||||||
const media = (
|
const media = (
|
||||||
<>
|
<>
|
||||||
{isOperator ? (
|
{shouldUseLiveVideo ? (
|
||||||
<PtzLiveVideo enabled={open} startMuted={false} />
|
<PtzLiveVideo enabled={open} startMuted={false} />
|
||||||
) : (
|
) : (
|
||||||
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
|
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import RoverDescriptionOverlay from '../HudOverlays/RoverDescriptionOverlay/inde
|
|||||||
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
|
import OvercurrentOverlay from '../HudOverlays/OvercurrentOverlay/index.jsx';
|
||||||
import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
|
import LowBatteryOverlay from '../HudOverlays/LowBatteryOverlay/index.jsx';
|
||||||
import VerticalBatteryOverlay from '../HudOverlays/VerticalBatteryOverlay/index.jsx';
|
import VerticalBatteryOverlay from '../HudOverlays/VerticalBatteryOverlay/index.jsx';
|
||||||
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
|
|
||||||
export default function SpectateVideo({
|
export default function SpectateVideo({
|
||||||
roverId = null,
|
roverId = null,
|
||||||
@@ -11,12 +12,24 @@ export default function SpectateVideo({
|
|||||||
fitParent = false,
|
fitParent = false,
|
||||||
layoutFormat = 'desktop',
|
layoutFormat = 'desktop',
|
||||||
}) {
|
}) {
|
||||||
|
const isExternalSpectatorSnapshotOnly = useSessionSelector((state) =>
|
||||||
|
state.session?.role === 'spectator' &&
|
||||||
|
state.session?.isLocalNetwork === false &&
|
||||||
|
state.session?.bandwidthSavings?.externalSpectatorVideo === 'snapshots',
|
||||||
|
);
|
||||||
|
/*
|
||||||
|
Server auth already denies external spectator WHEP when this policy is set,
|
||||||
|
but explicitly selecting snapshot mode avoids expected authorization denials
|
||||||
|
and keeps analytics focused on actual stream failures.
|
||||||
|
*/
|
||||||
|
const videoMode = isExternalSpectatorSnapshotOnly ? 'snapshot' : null;
|
||||||
return (
|
return (
|
||||||
<div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}>
|
<div className={`flex flex-col gap-0.5 ${fitParent ? 'h-full' : ''}`}>
|
||||||
<div className={`relative w-full overflow-hidden bg-black ${fitParent ? 'h-full flex-1' : 'aspect-[4/3]'}`}>
|
<div className={`relative w-full overflow-hidden bg-black ${fitParent ? 'h-full flex-1' : 'aspect-[4/3]'}`}>
|
||||||
<RoverMediaPlayer
|
<RoverMediaPlayer
|
||||||
roverId={roverId}
|
roverId={roverId}
|
||||||
label={label}
|
label={label}
|
||||||
|
videoMode={videoMode}
|
||||||
/>
|
/>
|
||||||
<RoverDescriptionOverlay
|
<RoverDescriptionOverlay
|
||||||
roverId={roverId}
|
roverId={roverId}
|
||||||
|
|||||||
@@ -326,9 +326,18 @@ function PtzControlReference() {
|
|||||||
function PtzController({ open, onClose, layout = 'desktop' }) {
|
function PtzController({ open, onClose, layout = 'desktop' }) {
|
||||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||||
const isOperator = Boolean(ptz?.isOperator);
|
const isOperator = Boolean(ptz?.isOperator);
|
||||||
|
const nonTurnVideoPolicy = useSessionSelector(
|
||||||
|
(state) => state.session?.bandwidthSavings?.nonTurnVideo || 'snapshots',
|
||||||
|
);
|
||||||
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape';
|
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape';
|
||||||
const { ptzRelease } = useSessionActions();
|
const { ptzRelease } = useSessionActions();
|
||||||
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !isOperator });
|
/*
|
||||||
|
The VIP fullscreen surface is available to queued PTZ users too. Let queued
|
||||||
|
users see live video only when the central non-turn video policy allows it;
|
||||||
|
all movement and light controls still remain guarded by isOperator.
|
||||||
|
*/
|
||||||
|
const shouldUseLiveVideo = isOperator || nonTurnVideoPolicy === 'live';
|
||||||
|
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo });
|
||||||
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
||||||
const [releasePending, setReleasePending] = useState(false);
|
const [releasePending, setReleasePending] = useState(false);
|
||||||
|
|
||||||
@@ -431,7 +440,7 @@ function PtzController({ open, onClose, layout = 'desktop' }) {
|
|||||||
bodyClassName={`grid h-full min-h-0 overflow-hidden ${sidebarWidthClass}`}
|
bodyClassName={`grid h-full min-h-0 overflow-hidden ${sidebarWidthClass}`}
|
||||||
>
|
>
|
||||||
<main className="relative min-h-0 min-w-0 bg-black">
|
<main className="relative min-h-0 min-w-0 bg-black">
|
||||||
{isOperator ? (
|
{shouldUseLiveVideo ? (
|
||||||
<PtzLiveVideo enabled startMuted={false} />
|
<PtzLiveVideo enabled startMuted={false} />
|
||||||
) : (
|
) : (
|
||||||
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
|
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ export function useDriverVideoModePolicy(roverId) {
|
|||||||
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
||||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||||
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
||||||
|
const nonTurnVideoPolicy = useSessionSelector(
|
||||||
|
(state) => state.session?.bandwidthSavings?.nonTurnVideo || 'snapshots',
|
||||||
|
);
|
||||||
const isTurnsMode = mode === 'turns';
|
const isTurnsMode = mode === 'turns';
|
||||||
/*
|
/*
|
||||||
This policy only switches preview/full video around a multi-second turn
|
This policy only switches preview/full video around a multi-second turn
|
||||||
@@ -43,7 +46,13 @@ export function useDriverVideoModePolicy(roverId) {
|
|||||||
});
|
});
|
||||||
return unique.size;
|
return unique.size;
|
||||||
}, [users]);
|
}, [users]);
|
||||||
const shouldUsePreviewByLoad = isTurnsMode && totalDrivers > totalRovers;
|
/*
|
||||||
|
The server sends the bandwidth policy because the same rule is enforced in
|
||||||
|
video authorization. The hook only mirrors that policy so the UI avoids
|
||||||
|
requesting live video when snapshots are the intended non-turn experience.
|
||||||
|
*/
|
||||||
|
const shouldUsePreviewByLoad =
|
||||||
|
nonTurnVideoPolicy === 'snapshots' && isTurnsMode && totalDrivers > totalRovers;
|
||||||
const isPreSwitchWindow =
|
const isPreSwitchWindow =
|
||||||
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
|
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
|
||||||
const showNotTurnNotice = isTurnsMode && !isActiveDriver;
|
const showNotTurnNotice = isTurnsMode && !isActiveDriver;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useSession } from '../context/SessionContext.jsx';
|
|||||||
export function useSpectatorMode() {
|
export function useSpectatorMode() {
|
||||||
const { session, setRole, subscribeAll, connected } = useSession();
|
const { session, setRole, subscribeAll, connected } = useSession();
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
const canUseSpectatorAccess = session?.bandwidthSavings?.canUseExternalSpectatorAccess !== false;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -14,6 +15,15 @@ export function useSpectatorMode() {
|
|||||||
setReady(false);
|
setReady(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!canUseSpectatorAccess) {
|
||||||
|
/*
|
||||||
|
The server will reject the role change too, but stopping here prevents
|
||||||
|
a blocked external spectator from retrying on every session sync while
|
||||||
|
the page is intentionally waiting for an admin grant or config change.
|
||||||
|
*/
|
||||||
|
setReady(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (session?.role !== 'spectator') {
|
if (session?.role !== 'spectator') {
|
||||||
await setRole('spectator');
|
await setRole('spectator');
|
||||||
@@ -33,7 +43,7 @@ export function useSpectatorMode() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [connected, session?.mode, session?.role, setRole, subscribeAll]);
|
}, [canUseSpectatorAccess, connected, session?.mode, session?.role, setRole, subscribeAll]);
|
||||||
|
|
||||||
return ready;
|
return ready;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export default function SpectatorContent() {
|
|||||||
const latestReplay = useSessionSelector((state) => state.latestReplay);
|
const latestReplay = useSessionSelector((state) => state.latestReplay);
|
||||||
const { clearLatestReplay } = useSessionActions();
|
const { clearLatestReplay } = useSessionActions();
|
||||||
const inLockdown = session?.mode === 'lockdown';
|
const inLockdown = session?.mode === 'lockdown';
|
||||||
|
const canUseSpectatorAccess = session?.bandwidthSavings?.canUseExternalSpectatorAccess !== false;
|
||||||
|
const spectatorAccessMode = session?.bandwidthSavings?.externalSpectatorAccess || 'on';
|
||||||
useDefaultNickname();
|
useDefaultNickname();
|
||||||
// The spectator route is not rendered through App.jsx, so it must opt into
|
// The spectator route is not rendered through App.jsx, so it must opt into
|
||||||
// the same persisted identity heartbeat here. That keeps the existing
|
// the same persisted identity heartbeat here. That keeps the existing
|
||||||
@@ -44,6 +46,26 @@ export default function SpectatorContent() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (session && !canUseSpectatorAccess) {
|
||||||
|
/*
|
||||||
|
The server keeps this socket in the normal user role when external
|
||||||
|
spectating is blocked. Rendering a full-page gate makes that intentional
|
||||||
|
state obvious instead of showing an empty spectator shell that repeatedly
|
||||||
|
fails to subscribe.
|
||||||
|
*/
|
||||||
|
const message = spectatorAccessMode === 'admin'
|
||||||
|
? 'This external spectator identity needs admin approval before it can view the spectator page.'
|
||||||
|
: 'External spectator access is disabled on this server.';
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-black text-slate-200">
|
||||||
|
<div className="surface max-w-md space-y-0.5 p-1 text-center text-sm">
|
||||||
|
<p className="text-lg font-semibold text-white">Spectate access required.</p>
|
||||||
|
<p className="text-slate-300">{message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const mainClass = isPortraitLayout
|
const mainClass = isPortraitLayout
|
||||||
? 'flex min-h-screen flex-col bg-black text-slate-100 md:h-screen md:overflow-hidden'
|
? 'flex min-h-screen flex-col bg-black text-slate-100 md:h-screen md:overflow-hidden'
|
||||||
: 'grid min-h-screen grid-cols-1 gap-0.5 bg-black text-slate-100 md:h-full md:min-h-0 md:grid-cols-[minmax(0,1fr)_18rem] lg:grid-cols-[minmax(0,1fr)_20rem]';
|
: 'grid min-h-screen grid-cols-1 gap-0.5 bg-black text-slate-100 md:h-full md:min-h-0 md:grid-cols-[minmax(0,1fr)_18rem] lg:grid-cols-[minmax(0,1fr)_20rem]';
|
||||||
|
|||||||
@@ -81,6 +81,19 @@ function PtzSnapshotFallback({ label, source }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PtzLiveOrSnapshot({ label }) {
|
function PtzLiveOrSnapshot({ label }) {
|
||||||
|
const isExternalSpectatorSnapshotOnly = useSessionSelector((state) =>
|
||||||
|
state.session?.role === 'spectator' &&
|
||||||
|
state.session?.isLocalNetwork === false &&
|
||||||
|
state.session?.bandwidthSavings?.externalSpectatorVideo === 'snapshots',
|
||||||
|
);
|
||||||
|
/*
|
||||||
|
PTZ live authorization is server-owned, but the spectator page knows when
|
||||||
|
the configured outcome is snapshot-only. Rendering the fallback directly
|
||||||
|
avoids a guaranteed denied WHEP request for every external spectator card.
|
||||||
|
*/
|
||||||
|
if (isExternalSpectatorSnapshotOnly) {
|
||||||
|
return <PtzSnapshotFallback label={label} source={null} />;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<PtzLiveVideo
|
<PtzLiveVideo
|
||||||
enabled
|
enabled
|
||||||
|
|||||||
Reference in New Issue
Block a user