mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
buncha stuff, charging complete sounds, room display button feedback, etc
This commit is contained in:
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
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Roomba Rover" />
|
||||
<title>Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-D3PfbwGG.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BDdrX06B.css">
|
||||
<script type="module" crossorigin src="/assets/index-BJBZKaDf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BIlplCN6.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// audio Forward Service charge complete sound
|
||||
// Purpose: Plays the built-in rover sound effect when the battery manager marks a charging cycle done.
|
||||
// Scope: Keeps the battery policy and audio pipeline decoupled by listening to the server event bus only.
|
||||
const path = require('path');
|
||||
const { subscribe } = require('../eventBus');
|
||||
|
||||
const DONE_CHARGING_SOUND_PATH = path.resolve(__dirname, '..', '..', '..', 'public', 'donecharging.wav');
|
||||
|
||||
function registerChargeCompleteSound(deps) {
|
||||
const {
|
||||
logger,
|
||||
playServerAudioFile,
|
||||
} = deps;
|
||||
|
||||
subscribe('battery.unlocked', (event = {}) => {
|
||||
const roverId = String(event?.payload?.roverId || '').trim();
|
||||
if (!roverId) return;
|
||||
|
||||
try {
|
||||
// battery.unlocked is the existing server-side definition of "done
|
||||
// charging" because it only fires after the battery manager has waited
|
||||
// through its full charge-release policy. Playing from this event avoids
|
||||
// duplicating charging thresholds or raw sensor-state guesses here.
|
||||
playServerAudioFile(roverId, DONE_CHARGING_SOUND_PATH, {
|
||||
source: 'charge-complete',
|
||||
});
|
||||
logger.info('Played charging complete sound', { roverId, filePath: DONE_CHARGING_SOUND_PATH });
|
||||
} catch (err) {
|
||||
// A missing/offline rover or unavailable ffmpeg should not affect the
|
||||
// battery manager's unlock decision. The sound is an announcement layered
|
||||
// on top of the state transition, so failures are logged and contained.
|
||||
logger.warn('Failed to play charging complete sound', {
|
||||
roverId,
|
||||
filePath: DONE_CHARGING_SOUND_PATH,
|
||||
error: err?.message || String(err),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
registerChargeCompleteSound,
|
||||
};
|
||||
@@ -13,6 +13,7 @@ const videoSessions = require('../videoSessions');
|
||||
const { createAudioForwardPolicy } = require('./policy');
|
||||
const { createAudioForwardWorkerEngine } = require('./workerEngine');
|
||||
const { registerAudioForwardHooks } = require('./hooks');
|
||||
const { registerChargeCompleteSound } = require('./chargeCompleteSound');
|
||||
|
||||
const audioForwardEvents = new EventEmitter();
|
||||
const config = loadConfig();
|
||||
@@ -95,6 +96,7 @@ const {
|
||||
ensureWorker,
|
||||
stopWorker,
|
||||
playUploadedAudio,
|
||||
playServerAudioFile,
|
||||
stopPlayback,
|
||||
revokeWhipSessionForRover,
|
||||
stopWhipForRover,
|
||||
@@ -125,6 +127,11 @@ registerAudioForwardHooks({
|
||||
startSilenceWriter,
|
||||
});
|
||||
|
||||
registerChargeCompleteSound({
|
||||
logger,
|
||||
playServerAudioFile,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
getAudioForwardState,
|
||||
audioForwardEvents,
|
||||
|
||||
@@ -158,7 +158,7 @@ function createAudioForwardWorkerEngine(deps) {
|
||||
];
|
||||
}
|
||||
|
||||
function buildUploadWriterArgs(filePath) {
|
||||
function buildFileWriterArgs(filePath) {
|
||||
return [
|
||||
'-hide_banner',
|
||||
'-loglevel',
|
||||
@@ -253,21 +253,41 @@ function createAudioForwardWorkerEngine(deps) {
|
||||
setState(roverId, { state: 'idle', source: 'silence', error: null, startedAt: null });
|
||||
}
|
||||
|
||||
function startUploadWriter(roverId, filePath, ownerSocketId) {
|
||||
function startFileWriter(roverId, filePath, options = {}) {
|
||||
const worker = workers.get(roverId);
|
||||
if (!worker || worker.stopping) return;
|
||||
|
||||
const source = options.source || 'upload';
|
||||
const contentKind = options.contentKind || source;
|
||||
const ownerSocketId = options.ownerSocketId || null;
|
||||
const cleanupAfterPlayback = Boolean(options.cleanupAfterPlayback);
|
||||
|
||||
stopContentProc(worker);
|
||||
cleanupUploadFile(worker);
|
||||
worker.activeUploadPath = filePath;
|
||||
worker.activeOwnerSocketId = ownerSocketId || null;
|
||||
const proc = spawnFfmpeg(roverId, 'upload-writer', buildUploadWriterArgs(filePath), { captureStdout: true });
|
||||
|
||||
// Browser-uploaded files are temporary files that this service owns, so the
|
||||
// worker remembers them and deletes them when playback is interrupted or
|
||||
// replaced. Built-in server assets are deliberately not tracked here because
|
||||
// they are checked-in files shared across all rovers and must survive after
|
||||
// a single playback finishes.
|
||||
if (cleanupAfterPlayback) {
|
||||
worker.activeUploadPath = filePath;
|
||||
} else {
|
||||
worker.activeUploadPath = null;
|
||||
}
|
||||
|
||||
// Socket ownership is meaningful only for user-started upload playback.
|
||||
// Server-started sounds such as the charging-complete cue pass no owner so
|
||||
// turn changes and browser disconnects do not treat the built-in sound as a
|
||||
// stale client session.
|
||||
worker.activeOwnerSocketId = ownerSocketId;
|
||||
const proc = spawnFfmpeg(roverId, `${source}-writer`, buildFileWriterArgs(filePath), { captureStdout: true });
|
||||
worker.contentProc = proc;
|
||||
worker.contentKind = 'upload';
|
||||
worker.contentKind = contentKind;
|
||||
const seq = ++worker.writerSeq;
|
||||
attachWriterPipe(worker, proc);
|
||||
|
||||
setState(roverId, { state: 'playing', source: 'upload', error: null, startedAt: Date.now() });
|
||||
setState(roverId, { state: 'playing', source, error: null, startedAt: Date.now() });
|
||||
|
||||
proc.on('exit', (code, signal) => {
|
||||
const current = workers.get(roverId);
|
||||
@@ -279,8 +299,8 @@ function createAudioForwardWorkerEngine(deps) {
|
||||
if (code != null && code !== 0 && signal !== 'SIGTERM') {
|
||||
setState(roverId, {
|
||||
state: 'error',
|
||||
source: 'upload',
|
||||
error: `upload writer exited code=${code} signal=${signal || 'none'}`,
|
||||
source,
|
||||
error: `${source} writer exited code=${code} signal=${signal || 'none'}`,
|
||||
startedAt: null,
|
||||
});
|
||||
}
|
||||
@@ -414,7 +434,34 @@ function createAudioForwardWorkerEngine(deps) {
|
||||
stopWhipForRover(roverId, 'upload_override');
|
||||
ensureWorker(roverId);
|
||||
const uploadPath = writeUploadFile(roverId, payload);
|
||||
startUploadWriter(roverId, uploadPath, ownerSocketId);
|
||||
startFileWriter(roverId, uploadPath, {
|
||||
source: 'upload',
|
||||
contentKind: 'upload',
|
||||
ownerSocketId,
|
||||
cleanupAfterPlayback: true,
|
||||
});
|
||||
}
|
||||
|
||||
function playServerAudioFile(roverId, filePath, options = {}) {
|
||||
const source = options.source || 'server-file';
|
||||
const normalizedPath = path.resolve(filePath || '');
|
||||
const stat = fs.statSync(normalizedPath);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`Audio file is not a regular file: ${normalizedPath}`);
|
||||
}
|
||||
|
||||
// Built-in sounds intentionally interrupt mic forwarding just like uploads
|
||||
// do. The rover can only publish one forwarded audio stream at a time, so
|
||||
// keeping WHIP alive would leave the automatic cue inaudible or mixed with
|
||||
// a stale publisher process.
|
||||
stopWhipForRover(roverId, `${source}_override`);
|
||||
ensureWorker(roverId);
|
||||
startFileWriter(roverId, normalizedPath, {
|
||||
source,
|
||||
contentKind: source,
|
||||
ownerSocketId: null,
|
||||
cleanupAfterPlayback: false,
|
||||
});
|
||||
}
|
||||
|
||||
function stopPlayback(roverId) {
|
||||
@@ -451,6 +498,7 @@ function createAudioForwardWorkerEngine(deps) {
|
||||
ensureWorker,
|
||||
stopWorker,
|
||||
playUploadedAudio,
|
||||
playServerAudioFile,
|
||||
stopPlayback,
|
||||
revokeWhipSessionForRover,
|
||||
stopWhipForRover,
|
||||
|
||||
@@ -6,6 +6,7 @@ const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('barcodeScannerService');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||
const { sendAlert } = require('../alertService');
|
||||
|
||||
const DATA_DIR = resolveDataDir();
|
||||
const REGISTRY_PATH = resolveDataPath('barcode-registry.json');
|
||||
@@ -138,6 +139,18 @@ function broadcastState() {
|
||||
io.emit('barcode:state', buildStatePayload());
|
||||
}
|
||||
|
||||
function sendBarcodeScanAlert(result) {
|
||||
if (!result) return;
|
||||
// Barcode scan toasts use the existing alert feed so drivers/spectators get a
|
||||
// lightweight system notice without adding any scanner-specific panels. The
|
||||
// scanner page remains the source of the big local display and speech output.
|
||||
sendAlert({
|
||||
color: result.known ? '#22c55e' : '#f59e0b',
|
||||
title: 'Barcode Scanned',
|
||||
message: result.label || result.code || 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
function resolveScan(rawCode) {
|
||||
const code = normalizeCode(rawCode);
|
||||
const loaded = loadRegistryForScan();
|
||||
@@ -179,8 +192,12 @@ function resolveScan(rawCode) {
|
||||
known: false,
|
||||
type: null,
|
||||
entityId: null,
|
||||
label: 'unknown',
|
||||
speechText: 'unknown',
|
||||
// Unknown but well-formed barcodes should be visible/audible as the code
|
||||
// itself. That makes mis-labeled objects and new unregistered barcodes
|
||||
// debuggable from the rover-facing scanner page without adding any extra
|
||||
// UI panels or registry-management logic to the browser.
|
||||
label: `Unknown: ${code}`,
|
||||
speechText: `Unknown: ${code}`,
|
||||
scannedAt,
|
||||
registryError: loaded.error || null,
|
||||
error: null,
|
||||
@@ -211,6 +228,7 @@ function applyScan(rawCode) {
|
||||
registryError: result.registryError || null,
|
||||
};
|
||||
broadcastState();
|
||||
sendBarcodeScanAlert(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Defines the human Alert Button Service module and the helpers/state used by this service unit.
|
||||
// Scope: Keeps runtime behavior unchanged while isolating responsibilities into a clear module boundary.
|
||||
const sharp = require('sharp');
|
||||
const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('humanAlertButton');
|
||||
const { subscribe, publishEvent } = require('../eventBus');
|
||||
const { getMode, MODES, setMode } = require('../modeManager');
|
||||
@@ -23,6 +24,7 @@ const LIGHTS_LOCKED_TTS = 'Room lights are now locked on.';
|
||||
const LIGHTS_UNLOCKED_TTS = 'Room lights are now unlocked.';
|
||||
const TILE_WIDTH = 480;
|
||||
const TILE_HEIGHT = 270;
|
||||
const DISPLAY_NOTICE_DURATION_MS = 4500;
|
||||
|
||||
logger.info('HA button actions enabled', {
|
||||
actions: [HUMAN_ALERT_ACTION, MODE_TURNS_ACTION, MODE_ADMIN_ACTION, LIGHTS_LOCK_TOGGLE_ACTION],
|
||||
@@ -51,6 +53,21 @@ function sendTtsToNonPrivateRovers(text) {
|
||||
});
|
||||
}
|
||||
|
||||
function emitDisplayNotice(text) {
|
||||
const clean = String(text || '').trim();
|
||||
if (!clean) return;
|
||||
// The room display should show the same successful action feedback that the
|
||||
// rovers speak. This socket event is intentionally display-specific so normal
|
||||
// driver/admin pages do not inherit a large visual interruption.
|
||||
io.emit('display:notice', {
|
||||
id: `ha-button-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
text: clean,
|
||||
source: 'ha-button',
|
||||
durationMs: DISPLAY_NOTICE_DURATION_MS,
|
||||
ts: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async function buildTile(camera, state) {
|
||||
const base = sharp({
|
||||
create: {
|
||||
@@ -118,6 +135,7 @@ async function handleTrigger(event = {}) {
|
||||
{ force: true },
|
||||
);
|
||||
sendTtsToNonPrivateRovers(MODE_TURNS_TTS);
|
||||
emitDisplayNotice(MODE_TURNS_TTS);
|
||||
return;
|
||||
}
|
||||
if (action === MODE_ADMIN_ACTION) {
|
||||
@@ -127,6 +145,7 @@ async function handleTrigger(event = {}) {
|
||||
{ force: true },
|
||||
);
|
||||
sendTtsToNonPrivateRovers(MODE_ADMIN_TTS);
|
||||
emitDisplayNotice(MODE_ADMIN_TTS);
|
||||
return;
|
||||
}
|
||||
if (action === LIGHTS_LOCK_TOGGLE_ACTION) {
|
||||
@@ -134,7 +153,9 @@ async function handleTrigger(event = {}) {
|
||||
source: 'ha-button:lightsLockToggle',
|
||||
forceApply: true,
|
||||
});
|
||||
sendTtsToNonPrivateRovers(lockedOn ? LIGHTS_LOCKED_TTS : LIGHTS_UNLOCKED_TTS);
|
||||
const message = lockedOn ? LIGHTS_LOCKED_TTS : LIGHTS_UNLOCKED_TTS;
|
||||
sendTtsToNonPrivateRovers(message);
|
||||
emitDisplayNotice(message);
|
||||
return;
|
||||
}
|
||||
if (action !== HUMAN_ALERT_ACTION) {
|
||||
@@ -161,6 +182,7 @@ async function handleTrigger(event = {}) {
|
||||
trigger: event?.payload || null,
|
||||
},
|
||||
});
|
||||
emitDisplayNotice(HUMAN_ALERT_MESSAGE);
|
||||
}
|
||||
|
||||
subscribe(HA_BUTTON_EVENT_TYPE, (event) => {
|
||||
|
||||
+1
-5
@@ -41,11 +41,10 @@ import useUserIdentitySync from './hooks/useUserIdentitySync.js';
|
||||
import GlobalObjectiveBanner from './components/GlobalObjectiveBanner/index.jsx';
|
||||
import RoverQueuesPanel from './components/RoverQueuesPanel/index.jsx';
|
||||
import VipPanel from './components/VipPanel/index.jsx';
|
||||
import { useSessionActions, useSessionSelector } from './context/SessionContext.jsx';
|
||||
import { useSessionSelector } from './context/SessionContext.jsx';
|
||||
import ButtonBoxPanel from './components/ButtonBoxPanel/index.jsx';
|
||||
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
|
||||
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
|
||||
import ReplayReadyPopup from './components/ReplaySourcesPanel/ReplayReadyPopup.jsx';
|
||||
import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.js';
|
||||
|
||||
function useLayoutMode() {
|
||||
@@ -279,8 +278,6 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
const swapMobileControlColumns = Boolean(pageSettings?.swapMobileControlColumns);
|
||||
const fullscreenButtonSide = swapMobileControlColumns ? 'left' : 'right';
|
||||
const showFloatingFullscreenButton = !isDesktop && (fullscreenIsIOS || fullscreenNativeSupported);
|
||||
const latestRequestedReplay = useSessionSelector((state) => state.latestRequestedReplay);
|
||||
const { clearReplayModal } = useSessionActions();
|
||||
const [helpVisible, setHelpVisible] = useState(false);
|
||||
const [quickstartVisible, setQuickstartVisible] = useState(false);
|
||||
|
||||
@@ -363,7 +360,6 @@ function AppWithProviders({ layout, isDesktop, fullscreen }) {
|
||||
onOpenHelp={openHelpFromQuickstart}
|
||||
onClose={closeQuickstart}
|
||||
/>
|
||||
<ReplayReadyPopup replay={latestRequestedReplay} onClose={clearReplayModal} />
|
||||
{showFloatingFullscreenButton ? (
|
||||
<FloatingFullscreenButton
|
||||
side={fullscreenButtonSide}
|
||||
|
||||
@@ -84,6 +84,7 @@ export default function ReplayReadyPopup({ replay, onClose, variant = 'modal' })
|
||||
|
||||
const isPanel = variant === 'panel';
|
||||
const isFloatingPanel = variant === 'floating-panel';
|
||||
const isModal = !isPanel && !isFloatingPanel;
|
||||
const title = String(replay?.title || 'Replay').trim() || 'Replay';
|
||||
const messageUrl = normalizeUrl(replay?.messageUrl);
|
||||
const meta = formatBytes(replay?.size) || null;
|
||||
@@ -136,11 +137,12 @@ export default function ReplayReadyPopup({ replay, onClose, variant = 'modal' })
|
||||
title={title}
|
||||
meta={meta}
|
||||
actions={actions}
|
||||
fillHeight={isPanel}
|
||||
fillHeight={isPanel || isModal}
|
||||
clipOverflow={false}
|
||||
bodyClassName={`${isPanel ? 'flex min-h-0 flex-1 flex-col' : ''} space-y-0.5 p-0.5 text-sm text-slate-200`}
|
||||
className={isModal ? 'h-full w-full' : ''}
|
||||
bodyClassName={`${isPanel || isModal ? 'flex min-h-0 flex-1 flex-col' : ''} space-y-0.5 p-0.5 text-sm text-slate-200`}
|
||||
>
|
||||
<div className={`${isPanel ? 'min-h-0 flex-1' : ''} overflow-hidden rounded bg-black`}>
|
||||
<div className={`${isPanel || isModal ? 'min-h-0 flex-1' : ''} overflow-hidden rounded bg-black`}>
|
||||
<video
|
||||
key={videoUrl}
|
||||
src={videoUrl}
|
||||
@@ -149,7 +151,11 @@ export default function ReplayReadyPopup({ replay, onClose, variant = 'modal' })
|
||||
preload="auto"
|
||||
playsInline
|
||||
{...videoLifecycleProps}
|
||||
className={`${isPanel ? 'h-full min-h-[10rem]' : 'aspect-video max-h-[72vh]'} w-full bg-black`}
|
||||
// Spectator pages use the modal variant as the primary replay viewer, so
|
||||
// that video should fill the available viewport instead of behaving like
|
||||
// a centered dialog preview. object-contain preserves the replay frame
|
||||
// without cropping if the browser viewport is not the same aspect ratio.
|
||||
className={`${isPanel ? 'h-full min-h-[10rem]' : isModal ? 'h-full object-contain' : 'aspect-video max-h-[72vh]'} w-full bg-black`}
|
||||
/>
|
||||
</div>
|
||||
</CardFrame>
|
||||
@@ -160,11 +166,12 @@ export default function ReplayReadyPopup({ replay, onClose, variant = 'modal' })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/80 p-1" onClick={onClose} role="presentation">
|
||||
<div className="fixed inset-0 z-[110] flex items-stretch justify-stretch bg-black" onClick={onClose} role="presentation">
|
||||
<div
|
||||
className="pointer-events-auto w-full max-w-4xl"
|
||||
className="pointer-events-auto h-full w-full"
|
||||
onClick={(event) => {
|
||||
// The backdrop closes the popup, but clicks inside the card must leave video controls usable.
|
||||
// The backdrop closes the popup, but clicks inside the fullscreen card
|
||||
// must leave video controls and header actions usable.
|
||||
event.stopPropagation();
|
||||
}}
|
||||
role="presentation"
|
||||
|
||||
@@ -27,7 +27,6 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
const replaySources = useSessionSelector((state) => state.session?.replaySources ?? []);
|
||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||
const assignmentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||
const selfSocketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||
const replayState = useSessionSelector((state) => state.session?.replay || null);
|
||||
const latestReplay = useSessionSelector((state) => state.latestReplay);
|
||||
@@ -48,17 +47,9 @@ export default function ReplaySourcesPanel({ panelId = 'replay-sources', fillHei
|
||||
activeJobId ? state.replayJobs?.[activeJobId] || null : null
|
||||
));
|
||||
const latestReplayJobId = latestReplay?.jobId || null;
|
||||
const latestReplayRequesterSocketId = latestReplay?.requestedBy?.socketId || null;
|
||||
const latestReplayRequestedBySelf = Boolean(
|
||||
latestReplayJobId &&
|
||||
selfSocketId &&
|
||||
latestReplayRequesterSocketId &&
|
||||
String(latestReplayRequesterSocketId) === String(selfSocketId),
|
||||
);
|
||||
const showPanelReplay = Boolean(
|
||||
latestReplay?.url &&
|
||||
latestReplayJobId &&
|
||||
!latestReplayRequestedBySelf &&
|
||||
dismissedPanelReplayId !== latestReplayJobId,
|
||||
);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import RewardRunOverlay from '../../components/RewardRunOverlay/index.jsx';
|
||||
import OnlinePeopleStrip from './components/OnlinePeopleStrip.jsx';
|
||||
import DisplayRoverGrid from './components/DisplayRoverGrid.jsx';
|
||||
import DisplayChatFeed from './components/DisplayChatFeed.jsx';
|
||||
import DisplayNoticeOverlay from './components/DisplayNoticeOverlay.jsx';
|
||||
|
||||
export default function ServerDisplayContent() {
|
||||
const { session } = useSession();
|
||||
@@ -43,6 +44,7 @@ export default function ServerDisplayContent() {
|
||||
<div className="min-h-0 flex-[1.28]">
|
||||
<DisplayChatFeed />
|
||||
</div>
|
||||
<DisplayNoticeOverlay />
|
||||
<RewardRunOverlay />
|
||||
{/* Display is spectator-like: every Discord-hosted replay should take over
|
||||
this physical-room board, not only replays requested by this browser. */}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
// Display Notice Overlay
|
||||
// Purpose: Shows server-side room-control feedback as huge temporary display text.
|
||||
// Scope: Listens only on the /display page so normal driver and spectator pages are not interrupted.
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useSocket } from '../../../context/SocketContext.jsx';
|
||||
|
||||
const DEFAULT_DURATION_MS = 4500;
|
||||
|
||||
function normalizeNotice(payload = {}) {
|
||||
const text = String(payload?.text || '').trim();
|
||||
if (!text) return null;
|
||||
const durationMs = Number.isFinite(Number(payload?.durationMs))
|
||||
? Math.max(1200, Math.min(15000, Number(payload.durationMs)))
|
||||
: DEFAULT_DURATION_MS;
|
||||
return {
|
||||
id: payload?.id || `display-notice-${Date.now()}`,
|
||||
text,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
export default function DisplayNoticeOverlay() {
|
||||
const socket = useSocket();
|
||||
const [notice, setNotice] = useState(null);
|
||||
const timerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
function clearNoticeTimer() {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleNotice(payload = {}) {
|
||||
const next = normalizeNotice(payload);
|
||||
if (!next) return;
|
||||
// Home Assistant buttons can be pressed rapidly. The room display should
|
||||
// reflect the most recent confirmed action instead of queueing stale room
|
||||
// state messages after the action has already changed again.
|
||||
clearNoticeTimer();
|
||||
setNotice(next);
|
||||
timerRef.current = setTimeout(() => {
|
||||
setNotice((current) => (current?.id === next.id ? null : current));
|
||||
timerRef.current = null;
|
||||
}, next.durationMs);
|
||||
}
|
||||
|
||||
socket.on('display:notice', handleNotice);
|
||||
return () => {
|
||||
clearNoticeTimer();
|
||||
socket.off('display:notice', handleNotice);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
if (!notice) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed inset-0 z-[130] flex items-center justify-center bg-black px-[4vw] py-[4vh]">
|
||||
<div className="max-w-[92vw] border-4 border-cyan-200 bg-slate-950 px-[3vw] py-[2.5vh] text-center">
|
||||
<div className="whitespace-pre-wrap text-[clamp(3.2rem,11vh,11rem)] font-black leading-[0.95] text-white">
|
||||
{notice.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user