From 66d2d203dd17f4cf88e1f0793832b23ebe59c742 Mon Sep 17 00:00:00 2001 From: Divyanshu Garg Date: Mon, 11 May 2026 17:36:04 +0530 Subject: [PATCH 1/3] feat: sync history between desktop and web bani controller Two-way history sync so the web controller's History tab reflects what the desktop is showing, and vice versa. Mutations on either side broadcast sync/add/remove/clear events; both sides dedupe by shabadId. A new controller-bus module lets non-controller mutation sites (save-to-history, HistoryPane delete, MiscFooter clear-all) emit history events without prop-drilling the active socket. Helpers no-op when no session is active and swallow socket errors so local mutations never fail because of a flaky link. Also makes web verse-clicks navigate banis and ceremonies by falling back to BaniDB verseId when crossPlatformId doesn't match (web clients don't have desktop's local row ids). Hardens easy-peasy selectors in Launchpad, BaniController, useSlides, MultiPaneContent, MultiPaneHeader, and ShabadText against proxy-revoked crashes during rapid controller-driven action cascades by subscribing to leaf fields and wrapping Launchpad reads in a safe-select fallback. --- .../components/BaniController.jsx | 63 +++++++++----- .../addons/bani-controller/controller-bus.js | 40 +++++++++ .../hooks/use-socket-listeners.js | 86 ++++++++++++++++++- .../utils/handle-request-control.js | 23 +++++ www/main/common/hooks/useSlides.js | 30 ++++--- www/main/launchpad/Launchpad.jsx | 41 +++++++-- .../navigator/misc/components/HistoryPane.jsx | 25 +++++- .../navigator/misc/components/MiscFooter.jsx | 4 + .../navigator/shabad/MultiPaneContent.jsx | 18 ++-- www/main/navigator/shabad/MultiPaneHeader.jsx | 21 +++-- www/main/navigator/shabad/ShabadText.jsx | 75 +++++++++++----- .../navigator/shabad/utils/save-to-history.js | 39 +++++---- 12 files changed, 367 insertions(+), 98 deletions(-) create mode 100644 www/main/addons/bani-controller/controller-bus.js diff --git a/www/main/addons/bani-controller/components/BaniController.jsx b/www/main/addons/bani-controller/components/BaniController.jsx index e45e44b05..dec090f58 100644 --- a/www/main/addons/bani-controller/components/BaniController.jsx +++ b/www/main/addons/bani-controller/components/BaniController.jsx @@ -18,6 +18,7 @@ import ConnectionSwitch from './ConnectionSwitch'; import ZoomController from './ZoomController'; import useSocketListeners from '../hooks/use-socket-listeners'; import updateMultipane from '../../../navigator/search/utils/update-multipane'; +import { setControllerBus, clearControllerBus } from '../controller-bus'; const remote = require('@electron/remote'); @@ -48,21 +49,25 @@ const BaniController = ({ onScreenClose, className }) => { (actions) => actions.baniController, ); - const { - activeShabad, - activeShabadId, - activeVerseId, - homeVerse, - ceremonyId, - sundarGutkaBaniId, - isSundarGutkaBani, - isCeremonyBani, - isMiscSlide, - miscSlideText, - isMiscSlideGurmukhi, - savedCrossPlatformId, - lineNumber, - } = useStoreState((state) => state.navigator); + // Per-field selectors avoid the easy-peasy proxy-revoked crash that occurs + // during rapid bani↔shabad transitions when a component subscribes to a + // whole slice — the cached parent-slice proxy can be revoked between + // renders, and the next render's `.x` access throws inside Launchpad. + const activeShabad = useStoreState((state) => state.navigator.activeShabad); + const activeShabadId = useStoreState((state) => state.navigator.activeShabadId); + const activeVerseId = useStoreState((state) => state.navigator.activeVerseId); + const homeVerse = useStoreState((state) => state.navigator.homeVerse); + const ceremonyId = useStoreState((state) => state.navigator.ceremonyId); + const sundarGutkaBaniId = useStoreState((state) => state.navigator.sundarGutkaBaniId); + const isSundarGutkaBani = useStoreState((state) => state.navigator.isSundarGutkaBani); + const isCeremonyBani = useStoreState((state) => state.navigator.isCeremonyBani); + const isMiscSlide = useStoreState((state) => state.navigator.isMiscSlide); + const miscSlideText = useStoreState((state) => state.navigator.miscSlideText); + const isMiscSlideGurmukhi = useStoreState((state) => state.navigator.isMiscSlideGurmukhi); + const isAnnouncement = useStoreState((state) => state.navigator.isAnnouncement); + const savedCrossPlatformId = useStoreState((state) => state.navigator.savedCrossPlatformId); + const lineNumber = useStoreState((state) => state.navigator.lineNumber); + const verseHistory = useStoreState((state) => state.navigator.verseHistory); const { setIsSundarGutkaBani, @@ -72,18 +77,17 @@ const BaniController = ({ onScreenClose, className }) => { setIsMiscSlide, setMiscSlideText, setIsMiscSlideGurmukhi, + setIsAnnouncement, setSavedCrossPlatformId, setLineNumber, + setVerseHistory, } = useStoreActions((state) => state.navigator); - const { - gurbaniFontSize, - content1FontSize, - content2FontSize, - content3FontSize, - baniLength, - // mangalPosition, - } = useStoreState((state) => state.userSettings); + const gurbaniFontSize = useStoreState((state) => state.userSettings.gurbaniFontSize); + const content1FontSize = useStoreState((state) => state.userSettings.content1FontSize); + const content2FontSize = useStoreState((state) => state.userSettings.content2FontSize); + const content3FontSize = useStoreState((state) => state.userSettings.content3FontSize); + const baniLength = useStoreState((state) => state.userSettings.baniLength); const fontSizes = { gurbani: parseInt(gurbaniFontSize, 10), @@ -178,8 +182,17 @@ const BaniController = ({ onScreenClose, className }) => { window.socket.on('data', (data) => { setSocketData(data); }); + // Register the active socket so non-controller mutation sites + // (save-to-history, HistoryPane delete, MiscFooter clear-all) can + // broadcast history events without prop-drilling the socket. + setControllerBus(window.socket, adminPin); } + } else { + clearControllerBus(); } + return () => { + clearControllerBus(); + }; }, [isListeners, adminPin]); useEffect(() => { @@ -227,6 +240,10 @@ const BaniController = ({ onScreenClose, className }) => { lineNumber, setLineNumber, updatePane, + isAnnouncement, + setIsAnnouncement, + verseHistory, + setVerseHistory, ); }, [socketData]); diff --git a/www/main/addons/bani-controller/controller-bus.js b/www/main/addons/bani-controller/controller-bus.js new file mode 100644 index 000000000..51fb8ff98 --- /dev/null +++ b/www/main/addons/bani-controller/controller-bus.js @@ -0,0 +1,40 @@ +// Loose coupling between non-controller code (save-to-history, HistoryPane, +// MiscFooter) and the active bani-controller socket. +// +// The bani controller registers the active socket + adminPin via setControllerBus +// when a session opens, and clears it when the session ends. Mutation sites then +// call broadcastHistory to publish events without needing to know whether a +// session is currently active. +// +// Backwards-compatible: every helper here is a no-op when no session is active. + +let activeSocket = null; +let activeAdminPin = 0; + +export const setControllerBus = (socket, adminPin) => { + activeSocket = socket || null; + activeAdminPin = parseInt(adminPin, 10) || 0; +}; + +export const clearControllerBus = () => { + activeSocket = null; + activeAdminPin = 0; +}; + +export const isControllerBusActive = () => Boolean(activeSocket); + +export const broadcastHistory = (action, payload = {}) => { + if (!activeSocket || !action) return; + try { + activeSocket.emit('data', { + host: 'sttm-desktop', + type: 'history', + pin: activeAdminPin, + action, + ...payload, + }); + } catch { + // Swallow — desktop history mutations should never fail because of a + // dropped socket. + } +}; diff --git a/www/main/addons/bani-controller/hooks/use-socket-listeners.js b/www/main/addons/bani-controller/hooks/use-socket-listeners.js index 3585b93fa..9b463452e 100644 --- a/www/main/addons/bani-controller/hooks/use-socket-listeners.js +++ b/www/main/addons/bani-controller/hooks/use-socket-listeners.js @@ -5,6 +5,26 @@ const remote = require('@electron/remote'); const analytics = remote.getGlobal('analytics'); +// Maps a wire-format history entry { shabadId, verseId?, label, kind } back to +// the desktop's verseHistory entry shape. Returns null on anything malformed. +const fromWireHistoryEntry = (wire) => { + if (!wire || typeof wire !== 'object') return null; + const shabadId = parseInt(wire.shabadId, 10); + if (!shabadId) return null; + const kind = wire.kind === 'bani' || wire.kind === 'ceremony' ? wire.kind : 'shabad'; + const verseId = parseInt(wire.verseId, 10) || shabadId; + return { + shabadId, + verseId, + label: typeof wire.label === 'string' ? wire.label : '', + type: kind, + meta: { baniLength: '' }, + versesRead: [verseId], + continueFrom: verseId, + homeVerse: 0, + }; +}; + const useSocketListeners = ( socketData, changeActiveShabad, @@ -35,6 +55,12 @@ const useSocketListeners = ( lineNumber, setLineNumber, updatePane, + // New, optional. Default to safe no-op behavior so existing callers that + // don't pass these don't change their semantics. + isAnnouncement = false, + setIsAnnouncement = () => {}, + verseHistory = [], + setVerseHistory = () => {}, ) => { if (socketData) { const isPinCorrect = parseInt(socketData.pin, 10) === adminPin; @@ -63,6 +89,12 @@ const useSocketListeners = ( if (isMiscSlideGurmukhi !== payload.isGurmukhi) { setIsMiscSlideGurmukhi(payload.isGurmukhi); } + // Honor the announcement flag from the wire. Default to false when the + // field is absent so legacy clients keep producing regular misc slides. + const wantsAnnouncement = Boolean(payload.isAnnouncement); + if (wantsAnnouncement !== isAnnouncement) { + setIsAnnouncement(wantsAnnouncement); + } analytics.trackEvent({ category: 'controller', action: 'send text', @@ -85,10 +117,11 @@ const useSocketListeners = ( setSundarGutkaBaniId(baniId); } - if (verseId && activeVerseId !== verseId) { - if (savedCrossPlatformId !== verseId) { - setSavedCrossPlatformId(verseId); - } + if (verseId && savedCrossPlatformId !== verseId) { + // savedCrossPlatformId triggers the verse-highlight effect in + // ShabadText (matches against crossPlatformId, then falls back to + // verseId for clients like web that don't have crossPlatformId). + setSavedCrossPlatformId(verseId); } updatePane('bani', baniId); analytics.trackEvent({ @@ -100,6 +133,7 @@ const useSocketListeners = ( }, ceremony: (payload) => { const ceremonyPayload = parseInt(payload.ceremonyId, 10); + const verseId = parseInt(payload.verseId, 10); if (!isCeremonyBani) { setIsCeremonyBani(true); } @@ -111,6 +145,12 @@ const useSocketListeners = ( if (ceremonyId !== ceremonyPayload) { setCeremonyId(ceremonyPayload); } + if (verseId && savedCrossPlatformId !== verseId) { + // Mirror the bani path: setting savedCrossPlatformId triggers the + // verse-highlight effect in ShabadText so a remote verse-click + // actually navigates the ceremony pane. + setSavedCrossPlatformId(verseId); + } updatePane('ceremony', ceremonyPayload); analytics.trackEvent({ category: 'controller', @@ -119,6 +159,42 @@ const useSocketListeners = ( value: ceremonyPayload, }); }, + history: (payload) => { + // Web → desktop history sync. Loose-coupled to verseHistory in the + // navigator store. All actions are idempotent, so dropped/duplicate + // events are safe. Dedup on shabadId only to match the local + // saveToHistory invariant (HistoryPane keys by shabadId). + const action = payload && payload.action; + if (action === 'sync' && Array.isArray(payload.entries)) { + const mapped = payload.entries.map(fromWireHistoryEntry).filter(Boolean); + const seen = new Set(); + const next = mapped.filter((e) => { + if (seen.has(e.shabadId)) return false; + seen.add(e.shabadId); + return true; + }); + setVerseHistory(next); + return; + } + if (action === 'add' && payload.entry) { + const incoming = fromWireHistoryEntry(payload.entry); + if (!incoming) return; + const exists = verseHistory.some((h) => h.shabadId === incoming.shabadId); + if (!exists) { + setVerseHistory([incoming, ...verseHistory]); + } + return; + } + if (action === 'remove' && payload.entry) { + const target = fromWireHistoryEntry(payload.entry); + if (!target) return; + setVerseHistory(verseHistory.filter((h) => h.shabadId !== target.shabadId)); + return; + } + if (action === 'clear') { + setVerseHistory([]); + } + }, 'request-control': () => handleRequestControl( adminPin, @@ -131,6 +207,8 @@ const useSocketListeners = ( sundarGutkaBaniId, baniLength, // mangalPosition, + verseHistory, + adminPin, ), settings: (payload) => { const { settings } = payload; diff --git a/www/main/addons/bani-controller/utils/handle-request-control.js b/www/main/addons/bani-controller/utils/handle-request-control.js index 556e8f005..2286028be 100644 --- a/www/main/addons/bani-controller/utils/handle-request-control.js +++ b/www/main/addons/bani-controller/utils/handle-request-control.js @@ -2,6 +2,15 @@ const remote = require('@electron/remote'); const analytics = remote.getGlobal('analytics'); +// Maps a navigator verseHistory entry to the wire-format history entry used +// by the controller history sync protocol. +const toWireHistoryEntry = (entry) => ({ + shabadId: entry.shabadId, + verseId: entry.verseId, + label: entry.label, + kind: entry.type === 'bani' || entry.type === 'ceremony' ? entry.type : 'shabad', +}); + const handleRequestControl = ( isPinCorrect, fontSizes, @@ -13,6 +22,8 @@ const handleRequestControl = ( sundarGutkaBaniId, baniLength, // mangalPosition, + verseHistory = [], + adminPin = 0, ) => { document.body.classList.toggle(`controller-on`, isPinCorrect); window.socket.emit('data', { @@ -23,6 +34,18 @@ const handleRequestControl = ( fontSizes, }, }); + + // After a successful join, snapshot the operator's current history to the + // controller so the web-side History tab matches what desktop shows. + if (isPinCorrect && Array.isArray(verseHistory) && verseHistory.length > 0) { + window.socket.emit('data', { + host: 'sttm-desktop', + type: 'history', + pin: parseInt(adminPin, 10) || 0, + action: 'sync', + entries: verseHistory.map(toWireHistoryEntry), + }); + } // if Pin is correct and there is a shabad already in desktop, emit that shabad details. if (isPinCorrect) { const currentShabad = { diff --git a/www/main/common/hooks/useSlides.js b/www/main/common/hooks/useSlides.js index c515c2417..0420fc41f 100644 --- a/www/main/common/hooks/useSlides.js +++ b/www/main/common/hooks/useSlides.js @@ -8,21 +8,23 @@ const { i18n } = remote.require('./app'); const analytics = remote.getGlobal('analytics'); export const useSlides = () => { - const { akhandpatt, autoplayToggle, defaultPaneId } = useStoreState( - (state) => state.userSettings, - ); + // Per-field selectors avoid easy-peasy proxy-revocation crashes during + // rapid action cascades (e.g. fast bani↔shabad transitions). Subscribing + // to a whole slice returns a draft proxy that can be revoked mid-tick; + // subscribing to leaves returns plain values. + const akhandpatt = useStoreState((state) => state.userSettings.akhandpatt); + const autoplayToggle = useStoreState((state) => state.userSettings.autoplayToggle); + const defaultPaneId = useStoreState((state) => state.userSettings.defaultPaneId); const { setAkhandpatt, setAutoplayToggle } = useStoreActions((state) => state.userSettings); - const { - isMiscSlide, - miscSlideText, - isAnnouncement, - isSundarGutkaBani, - isCeremonyBani, - ceremonyId, - pane1, - pane2, - pane3, - } = useStoreState((state) => state.navigator); + const isMiscSlide = useStoreState((state) => state.navigator.isMiscSlide); + const miscSlideText = useStoreState((state) => state.navigator.miscSlideText); + const isAnnouncement = useStoreState((state) => state.navigator.isAnnouncement); + const isSundarGutkaBani = useStoreState((state) => state.navigator.isSundarGutkaBani); + const isCeremonyBani = useStoreState((state) => state.navigator.isCeremonyBani); + const ceremonyId = useStoreState((state) => state.navigator.ceremonyId); + const pane1 = useStoreState((state) => state.navigator.pane1); + const pane2 = useStoreState((state) => state.navigator.pane2); + const pane3 = useStoreState((state) => state.navigator.pane3); const { setIsMiscSlide, setMiscSlideText, diff --git a/www/main/launchpad/Launchpad.jsx b/www/main/launchpad/Launchpad.jsx index 999d44adf..ccc6cc475 100644 --- a/www/main/launchpad/Launchpad.jsx +++ b/www/main/launchpad/Launchpad.jsx @@ -25,12 +25,43 @@ const main = remote.require('./app'); export const InputContext = createContext(); +const EMPTY_SHORTCUTS = {}; + const Launchpad = () => { - const { overlayScreen } = useStoreState((state) => state.app); - const { shortcuts } = useStoreState((state) => state.navigator); - const { setShortcuts } = useStoreActions((state) => state.navigator); - const { setOverlayScreen } = useStoreActions((actions) => actions.app); - const { currentWorkspace, defaultPaneId } = useStoreState((state) => state.userSettings); + // Select specific fields rather than destructuring from a parent slice — + // the parent-slice form returns an immer/easy-peasy proxy that can get + // revoked between renders during error recovery, leading to spurious + // "Cannot perform 'get' on a proxy that has been revoked" crashes. + // + // Even with per-field selectors, the parent-slice proxy can transiently be + // revoked mid-tick during rapid action cascades (e.g. fast bani↔shabad + // transitions over the controller). The safeSelect wrapper catches those + // revoked-proxy reads and returns a stable fallback, so the next render + // re-runs against a fresh state instead of the whole tree unmounting. + // Fallbacks are module-level constants so repeated failures don't churn + // useSyncExternalStore equality checks and loop. + const safeSelect = (selector, fallback) => { + try { + const value = selector(); + return value === undefined ? fallback : value; + } catch (e) { + return fallback; + } + }; + const overlayScreen = useStoreState((state) => + safeSelect(() => state.app.overlayScreen, DEFAULT_OVERLAY), + ); + const shortcuts = useStoreState((state) => + safeSelect(() => state.navigator.shortcuts, EMPTY_SHORTCUTS), + ); + const setShortcuts = useStoreActions((state) => state.navigator.setShortcuts); + const setOverlayScreen = useStoreActions((actions) => actions.app.setOverlayScreen); + const currentWorkspace = useStoreState((state) => + safeSelect(() => state.userSettings.currentWorkspace, ''), + ); + const defaultPaneId = useStoreState((state) => + safeSelect(() => state.userSettings.defaultPaneId, 1), + ); const { displayWaheguruSlide, diff --git a/www/main/navigator/misc/components/HistoryPane.jsx b/www/main/navigator/misc/components/HistoryPane.jsx index 59abe32bb..4bfca97f5 100644 --- a/www/main/navigator/misc/components/HistoryPane.jsx +++ b/www/main/navigator/misc/components/HistoryPane.jsx @@ -1,6 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { useStoreState, useStoreActions } from 'easy-peasy'; +import { broadcastHistory } from '../../../addons/bani-controller/controller-bus'; const remote = require('@electron/remote'); @@ -49,6 +50,16 @@ export const HistoryPane = ({ className, paneId }) => { (historyItem) => historyItem.shabadId !== element.shabadId, ); setVerseHistory(updatedHistory); + // Mirror the removal to a connected web controller, if any. No-op when + // no controller session is active. + broadcastHistory('remove', { + entry: { + shabadId: element.shabadId, + verseId: element.verseId, + label: element.label, + kind: element.type === 'bani' || element.type === 'ceremony' ? element.type : 'shabad', + }, + }); }; const openShabadFromHistory = (element) => { @@ -145,7 +156,19 @@ export const HistoryPane = ({ className, paneId }) => { const versesMarkup = []; - verseHistory.forEach((element) => { + // Defensive dedup against any legacy state that may have stored two entries + // with the same shabadId (e.g. a shabad and a bani sharing an id). Local + // saveToHistory and the wire receive both dedup by shabadId, but persisted + // state from before that invariant was enforced can still violate it and + // crash React's child-key reconciliation. + const seenShabadIds = new Set(); + const uniqueHistory = verseHistory.filter((h) => { + if (seenShabadIds.has(h.shabadId)) return false; + seenShabadIds.add(h.shabadId); + return true; + }); + + uniqueHistory.forEach((element) => { versesMarkup.push(
{ setVerseHistory([]); + // Mirror the wipe to a connected web controller, if any. No-op when + // no controller session is active. + broadcastHistory('clear'); }; const setTab = (tabName) => { diff --git a/www/main/navigator/shabad/MultiPaneContent.jsx b/www/main/navigator/shabad/MultiPaneContent.jsx index 9c49f6068..e851b6c48 100644 --- a/www/main/navigator/shabad/MultiPaneContent.jsx +++ b/www/main/navigator/shabad/MultiPaneContent.jsx @@ -12,13 +12,17 @@ const { i18n } = remote.require('./app'); const MultiPaneContent = ({ data }) => { const paneId = data.multiPaneId; - const navigatorState = useStoreState((state) => state.navigator); - const navigatorActions = useStoreActions((state) => state.navigator); - const paneAttributes = navigatorState[`pane${paneId}`]; - const setPaneAttributes = navigatorActions[`setPane${paneId}`]; - const { activePaneId, homeVerse, versesRead } = navigatorState; - const { setHomeVerse, setVersesRead } = navigatorActions; - const { currentWorkspace } = useStoreState((state) => state.userSettings); + // Per-leaf selectors avoid the easy-peasy proxy-revocation crash that + // surfaces during rapid bani↔shabad transitions when whole-slice + // subscriptions return a draft proxy that gets revoked between renders. + const paneAttributes = useStoreState((state) => state.navigator[`pane${paneId}`]); + const activePaneId = useStoreState((state) => state.navigator.activePaneId); + const homeVerse = useStoreState((state) => state.navigator.homeVerse); + const versesRead = useStoreState((state) => state.navigator.versesRead); + const setPaneAttributes = useStoreActions((actions) => actions.navigator[`setPane${paneId}`]); + const setHomeVerse = useStoreActions((actions) => actions.navigator.setHomeVerse); + const setVersesRead = useStoreActions((actions) => actions.navigator.setVersesRead); + const currentWorkspace = useStoreState((state) => state.userSettings.currentWorkspace); const { displayWaheguruSlide, diff --git a/www/main/navigator/shabad/MultiPaneHeader.jsx b/www/main/navigator/shabad/MultiPaneHeader.jsx index f5d518e8f..958a8f12c 100644 --- a/www/main/navigator/shabad/MultiPaneHeader.jsx +++ b/www/main/navigator/shabad/MultiPaneHeader.jsx @@ -11,12 +11,17 @@ const { i18n } = remote.require('./app'); const MultiPaneHeader = ({ data }) => { const paneId = data.multiPaneId; - const navigatorState = useStoreState((state) => state.navigator); - const navigatorActions = useStoreActions((state) => state.navigator); - const paneAttributes = navigatorState[`pane${paneId}`]; - const setPaneAttributes = navigatorActions[`setPane${paneId}`]; + // Per-leaf selectors avoid easy-peasy proxy-revocation crashes during + // rapid bani↔shabad transitions. Subscribe to each pane individually so + // the dynamic `pane${id}` lookup happens against fresh values. + const pane1 = useStoreState((state) => state.navigator.pane1); + const pane2 = useStoreState((state) => state.navigator.pane2); + const pane3 = useStoreState((state) => state.navigator.pane3); + const panes = { pane1, pane2, pane3 }; + const paneAttributes = panes[`pane${paneId}`]; + const setPaneAttributes = useStoreActions((actions) => actions.navigator[`setPane${paneId}`]); - const { defaultPaneId } = useStoreState((state) => state.userSettings); + const defaultPaneId = useStoreState((state) => state.userSettings.defaultPaneId); const { setDefaultPaneId } = useStoreActions((actions) => actions.userSettings); const [disableLock, setDisableLock] = useState(false); @@ -40,7 +45,7 @@ const MultiPaneHeader = ({ data }) => { } else { nextPane++; } - if (!navigatorState[`pane${nextPane}`].locked) { + if (!panes[`pane${nextPane}`].locked) { return nextPane; } } while (nextPane !== givenPaneId); @@ -67,14 +72,14 @@ const MultiPaneHeader = ({ data }) => { useEffect(() => { const remainingPanes = [1, 2, 3].filter((pane) => pane !== paneId); - if (remainingPanes.every((pane) => navigatorState[`pane${pane}`].locked)) { + if (remainingPanes.every((pane) => panes[`pane${pane}`].locked)) { lockIcon.current.classList.add('disabled'); setDisableLock(true); } else { lockIcon.current.classList.remove('disabled'); setDisableLock(false); } - }, [navigatorState.pane1, navigatorState.pane2, navigatorState.pane3]); + }, [pane1, pane2, pane3]); const selectPaneOption = (event) => { setPaneAttributes({ ...paneAttributes, content: event.target.value }); diff --git a/www/main/navigator/shabad/ShabadText.jsx b/www/main/navigator/shabad/ShabadText.jsx index 2f8b2776d..f19fe95e8 100644 --- a/www/main/navigator/shabad/ShabadText.jsx +++ b/www/main/navigator/shabad/ShabadText.jsx @@ -43,23 +43,31 @@ export const ShabadText = ({ const virtuosoRef = useRef(null); const activeVerseRef = useRef(null); - const { - activeVerseId, - isMiscSlide, - isSundarGutkaBani, - sundarGutkaBaniId, - isCeremonyBani, - ceremonyId, - activeShabadId, - verseHistory, - initialVerseId, - activePaneId, - shortcuts, - lineNumber, - } = useStoreState((state) => state.navigator); + // Select specific fields rather than destructuring from a parent slice — + // the parent-slice form returns an immer/easy-peasy proxy that can get + // revoked between renders during error recovery and rapid bani↔shabad + // transitions, causing "Cannot perform 'get' on a proxy that has been + // revoked" crashes that cascade into Launchpad. + const activeVerseId = useStoreState((state) => state.navigator.activeVerseId); + const isMiscSlide = useStoreState((state) => state.navigator.isMiscSlide); + const isSundarGutkaBani = useStoreState((state) => state.navigator.isSundarGutkaBani); + const sundarGutkaBaniId = useStoreState((state) => state.navigator.sundarGutkaBaniId); + const isCeremonyBani = useStoreState((state) => state.navigator.isCeremonyBani); + const ceremonyId = useStoreState((state) => state.navigator.ceremonyId); + const activeShabadId = useStoreState((state) => state.navigator.activeShabadId); + const verseHistory = useStoreState((state) => state.navigator.verseHistory); + const initialVerseId = useStoreState((state) => state.navigator.initialVerseId); + const activePaneId = useStoreState((state) => state.navigator.activePaneId); + const shortcuts = useStoreState((state) => state.navigator.shortcuts); + const lineNumber = useStoreState((state) => state.navigator.lineNumber); + const savedCrossPlatformId = useStoreState((state) => state.navigator.savedCrossPlatformId); - const { baniLength, liveFeed, autoplayDelay, autoplayToggle, intelligentSpacebar, akhandpatt } = - useStoreState((state) => state.userSettings); + const baniLength = useStoreState((state) => state.userSettings.baniLength); + const liveFeed = useStoreState((state) => state.userSettings.liveFeed); + const autoplayDelay = useStoreState((state) => state.userSettings.autoplayDelay); + const autoplayToggle = useStoreState((state) => state.userSettings.autoplayToggle); + const intelligentSpacebar = useStoreState((state) => state.userSettings.intelligentSpacebar); + const akhandpatt = useStoreState((state) => state.userSettings.akhandpatt); const { setActiveVerseId, @@ -72,7 +80,6 @@ export const ShabadText = ({ setCeremonyId, setIsCeremonyBani, setIsSundarGutkaBani, - savedCrossPlatformId, } = useStoreActions((actions) => actions.navigator); const updateTraversedVerse = (newTraversedVerse, verseIndex, crossPlatformId = null) => { @@ -138,10 +145,19 @@ export const ShabadText = ({ const resumeVerseId = paneAttributes?.activeVerse || filtered[0].verseId; if (filtered.length > 0) { const resumeVerseIndex = filtered.findIndex((v) => v.verseId === resumeVerseId); + // Pass crossPlatformId explicitly so sendToBaniController skips the + // `activeShabad.find(obj.verseId === ...)` lookup. That lookup uses + // the closure-captured `filteredItems` (still empty on first load + // since setFilteredItems above was just queued), so the find returns + // undefined and crashes on `.crossPlatformId` access. if (resumeVerseIndex >= 0) { - updateTraversedVerse(resumeVerseId, resumeVerseIndex); + updateTraversedVerse( + resumeVerseId, + resumeVerseIndex, + filtered[resumeVerseIndex].crossPlatformId, + ); } else { - updateTraversedVerse(filtered[0].verseId, 0); + updateTraversedVerse(filtered[0].verseId, 0, filtered[0].crossPlatformId); } } } @@ -180,11 +196,28 @@ export const ShabadText = ({ }, [filteredItems]); useEffect(() => { - const baniVerseIndex = filteredItems.findIndex( + // Try crossPlatformId first (desktop's native bani-row identifier — this + // is what desktop ↔ desktop sync uses, and what the existing wire + // convention sends in the `highlight`/`verseId` field for bani / + // ceremony events). Fall back to BaniDB global verseId when + // crossPlatformId doesn't match — the web Bani Controller sends verseId + // here because the public BaniDB API doesn't expose desktop's local row + // IDs. Both fields exist on filteredItems, so the fallback preserves + // existing behavior while making web verse-clicks navigate correctly. + let baniVerseIndex = filteredItems.findIndex( (obj) => obj.crossPlatformId === savedCrossPlatformId, ); + if (baniVerseIndex < 0) { + baniVerseIndex = filteredItems.findIndex((obj) => obj.verseId === savedCrossPlatformId); + } if (baniVerseIndex >= 0) { - updateTraversedVerse(filteredItems[baniVerseIndex].ID, baniVerseIndex); + const matched = filteredItems[baniVerseIndex]; + // First arg is the BaniDB verseId (matches every other call site of + // updateTraversedVerse). Pass the row's crossPlatformId as the third + // arg so sendToBaniController doesn't need to look it up via + // `activeShabad.find(obj.verseId === arrayIndex)` — that lookup + // returns undefined and crashes on `.crossPlatformId` access. + updateTraversedVerse(matched.verseId, baniVerseIndex, matched.crossPlatformId); } }, [savedCrossPlatformId]); diff --git a/www/main/navigator/shabad/utils/save-to-history.js b/www/main/navigator/shabad/utils/save-to-history.js index eb0c2f5a9..6e3821155 100644 --- a/www/main/navigator/shabad/utils/save-to-history.js +++ b/www/main/navigator/shabad/utils/save-to-history.js @@ -1,3 +1,5 @@ +import { broadcastHistory } from '../../../addons/bani-controller/controller-bus'; + export const saveToHistory = ( shabadId, verses, @@ -35,22 +37,29 @@ export const saveToHistory = ( } const check = verseHistory.filter((historyObj) => historyObj.shabadId === baniId); if (check.length === 0) { - const updatedHistory = [ - { - shabadId: baniId, - verseId, - label: verse, - type: verseType, - meta: { - baniLength, - }, - versesRead: [verseId], - continueFrom: verseId, - homeVerse: firstVerseIndex, + const newEntry = { + shabadId: baniId, + verseId, + label: verse, + type: verseType, + meta: { + baniLength, }, - ...verseHistory, - ]; - setVerseHistory(updatedHistory); + versesRead: [verseId], + continueFrom: verseId, + homeVerse: firstVerseIndex, + }; + setVerseHistory([newEntry, ...verseHistory]); + // Mirror the new entry to a connected web controller, if any. No-op when + // no controller session is active. + broadcastHistory('add', { + entry: { + shabadId: newEntry.shabadId, + verseId: newEntry.verseId, + label: newEntry.label, + kind: verseType === 'bani' || verseType === 'ceremony' ? verseType : 'shabad', + }, + }); return true; } return false; From 97ee871d93b2be5e029752182ce9e16805cfb9f2 Mon Sep 17 00:00:00 2001 From: Divyanshu Garg Date: Wed, 13 May 2026 17:39:31 +0530 Subject: [PATCH 2/3] fix: map web controller font-size targets to content slot names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The web bani-controller sends font-size events labeled gurbani / translation / teeka / transliteration, but the userSettings store keys those three sliders as content1 / content2 / content3. Two bugs fell out of that mismatch: - inbound changeFontSize tried to resolve setTranslationFontSize and friends, which don't exist as actions, so font-size events from web silently no-op'd. - outbound fontSizes payload read savedSettings['translation-font-size'] etc — schema keys that don't exist — so the web controller's font-size readouts reset to NaN whenever any setting changed. Add a label-to-content-slot map on the inbound path, and read from the actual content{1,2,3}-font-size schema keys on the outbound payload. --- .../bani-controller/hooks/use-socket-listeners.js | 15 ++++++++++++++- .../user-settings/create-user-settings-state.js | 12 +++++++++--- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/www/main/addons/bani-controller/hooks/use-socket-listeners.js b/www/main/addons/bani-controller/hooks/use-socket-listeners.js index 9b463452e..d1275dac7 100644 --- a/www/main/addons/bani-controller/hooks/use-socket-listeners.js +++ b/www/main/addons/bani-controller/hooks/use-socket-listeners.js @@ -213,7 +213,20 @@ const useSocketListeners = ( settings: (payload) => { const { settings } = payload; if (settings.action === 'changeFontSize') { - changeFontSize(settings.target, settings.value === 'plus'); + // Web sends labelled controller targets (gurbani / translation / + // teeka / transliteration). The userSettings store keys those + // sliders as content1/2/3 — without this map, changeFontSize + // looks up setTranslationFontSize / setTeekaFontSize / + // setTransliterationFontSize, which don't exist, and the call + // silently no-ops. + const FONT_TARGET_MAP = { + gurbani: 'gurbani', + translation: 'content1', + teeka: 'content2', + transliteration: 'content3', + }; + const target = FONT_TARGET_MAP[settings.target] || settings.target; + changeFontSize(target, settings.value === 'plus'); } analytics.trackEvent({ category: 'controller', diff --git a/www/main/common/store/user-settings/create-user-settings-state.js b/www/main/common/store/user-settings/create-user-settings-state.js index a2b31e1d5..41be66ca7 100644 --- a/www/main/common/store/user-settings/create-user-settings-state.js +++ b/www/main/common/store/user-settings/create-user-settings-state.js @@ -70,11 +70,17 @@ const createUserSettingsState = (settingsSchema, savedSettings, userConfigPath) global.controller[settingKey](payload); } + // Schema keys are `gurbani-font-size` and the generic `contentN-font-size` + // slots, which map onto the controller's labeled translation / teeka / + // transliteration sliders. The earlier `*-font-size` keys below were + // looking up names that don't exist in the settings schema, so the + // values came back NaN and the web controller's font-size readouts + // reset when any one of them changed. const fontSizes = { gurbani: parseInt(savedSettings['gurbani-font-size'], 10), - translation: parseInt(savedSettings['translation-font-size'], 10), - teeka: parseInt(savedSettings['teeka-font-size'], 10), - transliteration: parseInt(savedSettings['transliteration-font-size'], 10), + translation: parseInt(savedSettings['content1-font-size'], 10), + teeka: parseInt(savedSettings['content2-font-size'], 10), + transliteration: parseInt(savedSettings['content3-font-size'], 10), }; if (window.socket !== undefined && window.socket !== null) { From d72c7e633d154f5252cbf37700762943aa30902b Mon Sep 17 00:00:00 2001 From: Divyanshu Garg Date: Fri, 15 May 2026 03:27:23 +0530 Subject: [PATCH 3/3] fix: web controller bani/ceremony navigation and font-size clamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A grab-bag of fixes for the web bani-controller flow uncovered while testing the cross-device sync added in 66d2d2. Navigation (use-socket-listeners, handle-request-control): - bani and ceremony handlers now parse lineCount off the wire and pipe it into the navigator's lineNumber state, so ShabadText can fall back to position-based seeking when the BaniDB-global verseId doesn't match desktop's Realm-local verseIds (ceremonies in particular live in a different ID space). - both handlers also drop isMiscSlide / isAnnouncement on navigation, otherwise a prior Waheguru / Mool Mantra / announcement stayed on the projection while the state change happened invisibly behind it. - updatePane gets verseId as a third arg so the target pane's activeVerse is set directly — fallback in case the savedCrossPlatformId effect in ShabadText misses on a same-bani re-trigger. - request-control was passing adminPin (numeric) where the handler expected isPinCorrect (boolean), so the controller-on body class was always truthy. Pass the boolean. - handle-request-control was sending ceremonyId / sundarGutkaBaniId as the highlight on connect, which web tries to match against a verseId list and finds nothing. Send activeVerseId for both, and add an explicit verseId field on the wire alongside the legacy highlight. - echo current miscState on response-control so a web client that joins mid-announcement can hydrate its announcement chip. ShabadText highlight fallback: - when neither crossPlatformId nor verseId matches a row in the filtered list, fall back to lineNumber (1-based position) since both clients render the same row order. Add lineNumber to the effect deps. sendToBaniController (change-verse.js): - resolve the highlight via a guarded fallback chain (crossPlatformId → activeShabad.find → newTraversedVerse). The previous code crashed when the closure-captured activeShabad was still empty during the initial setVerseList → updateTraversedVerse call. - always emit verseId alongside highlight on bani / ceremony events so the web client (no crossPlatformId column) has something to match against. useNewShabad: reset isAnnouncement when starting a new shabad, otherwise the announcement-styling flag bled into the next misc slide. quick-tools-utils: clamp changeFontSize to [1, 30] so spamming the controller's minus button can't push a slider to zero/negative and the plus button can't run away. --- .../hooks/use-socket-listeners.js | 72 +++++++++++++++++-- .../utils/handle-request-control.js | 23 ++++-- .../navigator/search/hooks/use-new-shabad.jsx | 8 +++ www/main/navigator/shabad/ShabadText.jsx | 21 +++--- .../navigator/shabad/utils/change-verse.js | 31 ++++++-- www/main/quick-tools-utils.js | 14 +++- 6 files changed, 145 insertions(+), 24 deletions(-) diff --git a/www/main/addons/bani-controller/hooks/use-socket-listeners.js b/www/main/addons/bani-controller/hooks/use-socket-listeners.js index d1275dac7..04444cf1f 100644 --- a/www/main/addons/bani-controller/hooks/use-socket-listeners.js +++ b/www/main/addons/bani-controller/hooks/use-socket-listeners.js @@ -105,6 +105,34 @@ const useSocketListeners = ( bani: (payload) => { const baniId = parseInt(payload.baniId, 10); const verseId = parseInt(payload.verseId, 10); + const lineCount = parseInt(payload.lineCount, 10); + const hasVerseId = !Number.isNaN(verseId) && verseId > 0; + const hasLineCount = !Number.isNaN(lineCount) && lineCount > 0; + if (hasLineCount && lineNumber !== lineCount) { + setLineNumber(lineCount); + } + // Honor an optional baniLength from the wire (e.g. when the web + // controller wants to project a specific length variant). The + // payload key matches what BaaniSearchPanel / handleBani send. + if ( + payload.baniLength && + typeof payload.baniLength === 'string' && + payload.baniLength !== baniLength && + global.setUserSettings && + typeof global.setUserSettings.setBaniLength === 'function' + ) { + global.setUserSettings.setBaniLength(payload.baniLength); + } + // Navigation events should drop the misc-slide overlay so the + // operator's projection actually switches to the bani — otherwise a + // prior Waheguru / Mool Mantra / announcement stays on screen and + // the state change happens invisibly behind it. + if (isMiscSlide) { + setIsMiscSlide(false); + } + if (isAnnouncement) { + setIsAnnouncement(false); + } if (isCeremonyBani) { setIsCeremonyBani(false); } @@ -117,13 +145,16 @@ const useSocketListeners = ( setSundarGutkaBaniId(baniId); } - if (verseId && savedCrossPlatformId !== verseId) { + if (hasVerseId && savedCrossPlatformId !== verseId) { // savedCrossPlatformId triggers the verse-highlight effect in // ShabadText (matches against crossPlatformId, then falls back to // verseId for clients like web that don't have crossPlatformId). setSavedCrossPlatformId(verseId); } - updatePane('bani', baniId); + // Pass verseId into updatePane so the pane's activeVerse is set + // directly — fallback in case the savedCrossPlatformId effect + // misses on a same-bani re-trigger. + updatePane('bani', baniId, hasVerseId ? verseId : undefined); analytics.trackEvent({ category: 'controller', action: 'bani', @@ -134,6 +165,26 @@ const useSocketListeners = ( ceremony: (payload) => { const ceremonyPayload = parseInt(payload.ceremonyId, 10); const verseId = parseInt(payload.verseId, 10); + const lineCount = parseInt(payload.lineCount, 10); + const hasVerseId = !Number.isNaN(verseId) && verseId > 0; + const hasLineCount = !Number.isNaN(lineCount) && lineCount > 0; + // Pipe lineCount into the navigator's lineNumber state so the + // ShabadText effect can fall back to position-based seeking when + // the BaniDB-global verseId from web doesn't match desktop's + // Realm-local verseIds (ceremonies in particular live in a + // different ID space). + if (hasLineCount && lineNumber !== lineCount) { + setLineNumber(lineCount); + } + // Same misc-slide / announcement cleanup as the bani path — a + // ceremony click should exit any active text overlay so the + // ceremony view becomes visible on the projection. + if (isMiscSlide) { + setIsMiscSlide(false); + } + if (isAnnouncement) { + setIsAnnouncement(false); + } if (!isCeremonyBani) { setIsCeremonyBani(true); } @@ -145,13 +196,18 @@ const useSocketListeners = ( if (ceremonyId !== ceremonyPayload) { setCeremonyId(ceremonyPayload); } - if (verseId && savedCrossPlatformId !== verseId) { + if (hasVerseId && savedCrossPlatformId !== verseId) { // Mirror the bani path: setting savedCrossPlatformId triggers the // verse-highlight effect in ShabadText so a remote verse-click // actually navigates the ceremony pane. setSavedCrossPlatformId(verseId); } - updatePane('ceremony', ceremonyPayload); + // Pass verseId into updatePane so the pane's activeVerse is set + // directly. This is a belt-and-suspenders fallback: even if the + // savedCrossPlatformId effect in ShabadText misses (race or stale + // closure), the pane attributes carry the active verse so the + // ShabadText load/resume path lands on the right row. + updatePane('ceremony', ceremonyPayload, hasVerseId ? verseId : undefined); analytics.trackEvent({ category: 'controller', action: 'ceremony', @@ -197,7 +253,7 @@ const useSocketListeners = ( }, 'request-control': () => handleRequestControl( - adminPin, + isPinCorrect, fontSizes, activeShabad, activeShabadId, @@ -209,6 +265,12 @@ const useSocketListeners = ( // mangalPosition, verseHistory, adminPin, + { + isMiscSlide, + isAnnouncement, + miscSlideText, + isMiscSlideGurmukhi, + }, ), settings: (payload) => { const { settings } = payload; diff --git a/www/main/addons/bani-controller/utils/handle-request-control.js b/www/main/addons/bani-controller/utils/handle-request-control.js index 2286028be..eadf8b5f0 100644 --- a/www/main/addons/bani-controller/utils/handle-request-control.js +++ b/www/main/addons/bani-controller/utils/handle-request-control.js @@ -24,15 +24,25 @@ const handleRequestControl = ( // mangalPosition, verseHistory = [], adminPin = 0, + miscState = {}, ) => { - document.body.classList.toggle(`controller-on`, isPinCorrect); + document.body.classList.toggle(`controller-on`, Boolean(isPinCorrect)); window.socket.emit('data', { host: 'sttm-desktop', type: 'response-control', - success: isPinCorrect, + success: Boolean(isPinCorrect), settings: { fontSizes, }, + // Echo the current misc-slide / announcement state at connect so the + // web controller can hydrate its `activeAnnouncement` chip if the + // operator is mid-announcement when a client joins. + miscState: { + isMiscSlide: Boolean(miscState.isMiscSlide), + isAnnouncement: Boolean(miscState.isAnnouncement), + miscSlideText: miscState.miscSlideText ?? '', + isMiscSlideGurmukhi: Boolean(miscState.isMiscSlideGurmukhi), + }, }); // After a successful join, snapshot the operator's current history to the @@ -73,9 +83,13 @@ const handleRequestControl = ( highlight = activeVerseId; homeId = homeVerse; } else if (currentShabad.type === 'ceremony') { - highlight = ceremonyId; + // Previously `highlight` was the ceremonyId itself, which is + // useless to web (it tries to match a verseId against the verse + // list and finds nothing). Send the currently-active verseId so + // the web pane highlights the correct row on connect. + highlight = activeVerseId; } else if (currentShabad.type === 'bani') { - highlight = sundarGutkaBaniId; + highlight = activeVerseId; } window.socket.emit('data', { @@ -84,6 +98,7 @@ const handleRequestControl = ( id: currentShabad.id, shabadid: currentShabad.id, // @deprecated highlight: parseInt(highlight, 10), + verseId: parseInt(activeVerseId, 10), homeId: parseInt(homeId, 10), baniLength: currentShabad.baniLength, // mangalPosition: currentShabad.mangalPosition, diff --git a/www/main/navigator/search/hooks/use-new-shabad.jsx b/www/main/navigator/search/hooks/use-new-shabad.jsx index c41421da8..c20b9bcf5 100644 --- a/www/main/navigator/search/hooks/use-new-shabad.jsx +++ b/www/main/navigator/search/hooks/use-new-shabad.jsx @@ -14,6 +14,7 @@ export const useNewShabad = () => { isSundarGutkaBani, isCeremonyBani, isMiscSlide, + isAnnouncement, singleDisplayActiveTab, searchVerse, } = useStoreState((state) => state.navigator); @@ -26,6 +27,7 @@ export const useNewShabad = () => { setVersesRead, setActiveVerseId, setIsMiscSlide, + setIsAnnouncement, setIsSundarGutkaBani, setIsCeremonyBani, setSingleDisplayActiveTab, @@ -47,6 +49,12 @@ export const useNewShabad = () => { if (isMiscSlide) { setIsMiscSlide(false); } + if (isAnnouncement) { + // Drop the announcement flag too — otherwise the next misc-slide + // emitted from anywhere on the desktop inherits the announcement + // styling because the flag was never reset. + setIsAnnouncement(false); + } if (isSundarGutkaBani) { setIsSundarGutkaBani(false); } diff --git a/www/main/navigator/shabad/ShabadText.jsx b/www/main/navigator/shabad/ShabadText.jsx index f19fe95e8..1ed58ed79 100644 --- a/www/main/navigator/shabad/ShabadText.jsx +++ b/www/main/navigator/shabad/ShabadText.jsx @@ -196,20 +196,23 @@ export const ShabadText = ({ }, [filteredItems]); useEffect(() => { - // Try crossPlatformId first (desktop's native bani-row identifier — this - // is what desktop ↔ desktop sync uses, and what the existing wire - // convention sends in the `highlight`/`verseId` field for bani / - // ceremony events). Fall back to BaniDB global verseId when - // crossPlatformId doesn't match — the web Bani Controller sends verseId - // here because the public BaniDB API doesn't expose desktop's local row - // IDs. Both fields exist on filteredItems, so the fallback preserves - // existing behavior while making web verse-clicks navigate correctly. + // Try crossPlatformId first (desktop ↔ desktop sync uses Realm + // crossPlatformID for this). Then fall back to BaniDB global verseId + // — works for shabads and banis where web and desktop share IDs. + // For ceremonies, desktop's Realm stores rows under a different + // verseId space than the BaniDB HTTP API the web client reads, so + // neither id-based match succeeds. As a last resort, use the + // operator's wire-supplied `lineNumber` (1-based position in the + // verse list) since both clients render the same row order. let baniVerseIndex = filteredItems.findIndex( (obj) => obj.crossPlatformId === savedCrossPlatformId, ); if (baniVerseIndex < 0) { baniVerseIndex = filteredItems.findIndex((obj) => obj.verseId === savedCrossPlatformId); } + if (baniVerseIndex < 0 && lineNumber && lineNumber > 0 && lineNumber <= filteredItems.length) { + baniVerseIndex = lineNumber - 1; + } if (baniVerseIndex >= 0) { const matched = filteredItems[baniVerseIndex]; // First arg is the BaniDB verseId (matches every other call site of @@ -219,7 +222,7 @@ export const ShabadText = ({ // returns undefined and crashes on `.crossPlatformId` access. updateTraversedVerse(matched.verseId, baniVerseIndex, matched.crossPlatformId); } - }, [savedCrossPlatformId]); + }, [savedCrossPlatformId, lineNumber]); useEffect(() => { const overlayVerse = filterOverlayVerseItems(rawVerses, activeVerseId); diff --git a/www/main/navigator/shabad/utils/change-verse.js b/www/main/navigator/shabad/utils/change-verse.js index d6a3d763b..e048230af 100644 --- a/www/main/navigator/shabad/utils/change-verse.js +++ b/www/main/navigator/shabad/utils/change-verse.js @@ -104,17 +104,37 @@ export const sendToBaniController = ( }, ) => { if (window.socket !== undefined && window.socket !== null) { - let baniVerse; - if (!crossPlatformId) { - baniVerse = activeShabad.find((obj) => obj.verseId === newTraversedVerse); + // Resolve a highlight value safely. `crossPlatformId` may be: + // - a real Realm crossPlatformID (best — desktop↔desktop sync) + // - '' (empty string) for Custom ceremony rows that have no Realm + // crossPlatformID column + // - undefined / null (caller didn't pass one) + // When it's missing, look up the matching row in `activeShabad` by + // `verseId`. The closure-captured `activeShabad` may still be empty + // during the initial setVerseList → updateTraversedVerse call, so + // guard the `.find` result before reading `.crossPlatformId`. As a + // final fallback, ship the BaniDB-global `newTraversedVerse` as + // `highlight` — web prefers the explicit `verseId` field anyway. + let resolvedHighlight = crossPlatformId; + if (!resolvedHighlight) { + const matched = Array.isArray(activeShabad) + ? activeShabad.find((obj) => obj && obj.verseId === newTraversedVerse) + : null; + resolvedHighlight = (matched && matched.crossPlatformId) || newTraversedVerse; } + if (isSundarGutkaBani && sundarGutkaBaniId) { window.socket.emit('data', { host: 'sttm-desktop', type: 'bani', id: paneAttributes.activeShabad, shabadid: paneAttributes.activeShabad, // @deprecated - highlight: crossPlatformId || baniVerse.crossPlatformId, + // `highlight` carries the Realm crossPlatformId (desktop ↔ desktop + // convention). `verseId` is the BaniDB-global verseId so the web + // client — which has no crossPlatformId column on its rows — can + // actually match the highlighted verse against its loaded list. + highlight: resolvedHighlight, + verseId: newTraversedVerse, baniLength, // mangalPosition, verseChange: false, @@ -125,7 +145,8 @@ export const sendToBaniController = ( type: 'ceremony', id: paneAttributes.activeShabad, shabadid: paneAttributes.activeShabad, // @deprecated - highlight: crossPlatformId || baniVerse.crossPlatformId, + highlight: resolvedHighlight, + verseId: newTraversedVerse, verseChange: false, }); } else if (activeShabadId) { diff --git a/www/main/quick-tools-utils.js b/www/main/quick-tools-utils.js index ca729dde9..5ddb0e1b2 100644 --- a/www/main/quick-tools-utils.js +++ b/www/main/quick-tools-utils.js @@ -1,10 +1,22 @@ const firstCharToUpperCase = (str) => `${str.charAt(0).toUpperCase()}${str.slice(1)}`; +// Conservative clamps so spamming the minus button can't push a slider to +// zero/negative and the plus button can't run away with the font size. +// The shipped defaults are 4/5/9, so 1..30 leaves ample headroom either way. +const FONT_SIZE_MIN = 1; +const FONT_SIZE_MAX = 30; + +const clampFontSize = (value) => { + if (Number.isNaN(value)) return FONT_SIZE_MIN; + return Math.min(FONT_SIZE_MAX, Math.max(FONT_SIZE_MIN, value)); +}; + export const changeFontSize = (iconType, increase = true) => { const setterAction = `set${firstCharToUpperCase(iconType)}FontSize`; const getterVar = `${iconType}FontSize`; const oldValue = parseInt(global.getUserSettings[getterVar], 10); - const newValue = increase ? oldValue + 1 : oldValue - 1; + const newValue = clampFontSize(increase ? oldValue + 1 : oldValue - 1); + if (newValue === oldValue) return; try { global.setUserSettings[setterAction](newValue); } catch (error) {