Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 40 additions & 23 deletions www/main/addons/bani-controller/components/BaniController.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -227,6 +240,10 @@ const BaniController = ({ onScreenClose, className }) => {
lineNumber,
setLineNumber,
updatePane,
isAnnouncement,
setIsAnnouncement,
verseHistory,
setVerseHistory,
);
}, [socketData]);

Expand Down
40 changes: 40 additions & 0 deletions www/main/addons/bani-controller/controller-bus.js
Original file line number Diff line number Diff line change
@@ -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.
}
};
169 changes: 161 additions & 8 deletions www/main/addons/bani-controller/hooks/use-socket-listeners.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand All @@ -73,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);
}
Expand All @@ -85,12 +145,16 @@ const useSocketListeners = (
setSundarGutkaBaniId(baniId);
}

if (verseId && activeVerseId !== verseId) {
if (savedCrossPlatformId !== verseId) {
setSavedCrossPlatformId(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',
Expand All @@ -100,6 +164,27 @@ 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);
}
Expand All @@ -111,17 +196,64 @@ const useSocketListeners = (
if (ceremonyId !== ceremonyPayload) {
setCeremonyId(ceremonyPayload);
}
updatePane('ceremony', ceremonyPayload);
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);
}
// 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',
label: 'ceremonyId',
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,
isPinCorrect,
fontSizes,
activeShabad,
activeShabadId,
Expand All @@ -131,11 +263,32 @@ const useSocketListeners = (
sundarGutkaBaniId,
baniLength,
// mangalPosition,
verseHistory,
adminPin,
{
isMiscSlide,
isAnnouncement,
miscSlideText,
isMiscSlideGurmukhi,
},
),
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',
Expand Down
Loading