From e787929bf27454531474f39b294fafb320d68652 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Tue, 3 Nov 2026 20:33:23 -0500 Subject: [PATCH 01/57] fix(overlay): drop the My Background menu item; Card Images has the picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mode menu's "My Background…" opened a modal that only wrapped OwnBackgroundPicker, which the Card Images modal already shows. Remove the item, the wrapper modal and its LiveTab wiring. The "Show my own background" toggle stays in the menu. --- apps/zoom-app/src/components/LiveTab.jsx | 9 ------ .../src/components/OverlayModeMenu.jsx | 26 ++------------- .../src/components/OwnBackgroundModal.jsx | 32 ------------------- .../src/components/OwnBackgroundPicker.jsx | 3 +- 4 files changed, 3 insertions(+), 67 deletions(-) delete mode 100644 apps/zoom-app/src/components/OwnBackgroundModal.jsx diff --git a/apps/zoom-app/src/components/LiveTab.jsx b/apps/zoom-app/src/components/LiveTab.jsx index 925c928..0748f8c 100644 --- a/apps/zoom-app/src/components/LiveTab.jsx +++ b/apps/zoom-app/src/components/LiveTab.jsx @@ -8,7 +8,6 @@ import TimerStage from './TimerStage'; import OverlayModeMenu, { MODE_LABELS } from './OverlayModeMenu'; const EditRulesModal = lazy(() => import('./EditRulesModal')); const CardImagesModal = lazy(() => import('./CardImagesModal')); -const OwnBackgroundModal = lazy(() => import('./OwnBackgroundModal')); import TimeInput, { TimeInputModeToggle } from './TimeInput'; import { DEFAULT_ROLE_RULES, DEFAULT_CUSTOM_RULES, loadTimeInputMode, saveTimeInputMode, BREAK_ROLE, BREAK_QUICK_PICKS, DEFAULT_BREAK_SECONDS, deriveBreakRules, getDisplaySeconds, initCardImages } from '@toastmaster-timer/shared'; import { getVideoState, setVideoState, applyOverlay, removeOverlay, clearVideoPipelines, isOverlayActive, getBackgroundUrl, getSdkStatus, setLogCallback, getOverlayMode, setOverlayMode, getOverlayTimePosition, setOverlayTimePosition, getOverlayTimeScale, setOverlayTimeScale, isOverlayTimeVisible, setOverlayTimeVisible, setOverlayTimeLabel, setPopoutChangeCallback, setShareChangeCallback, setAppShare, setAppPopout, isAppShareActive, isAppPoppedOut, isVideoOverlayMode, getClubBadgePlacement, setClubBadgePlacement, resetClubBadgePlacement, isClubBadgeMoved, OVERLAY_MODE_CARD, OVERLAY_MODE_STAGE } from '../utils/zoomSdk'; @@ -72,7 +71,6 @@ export default memo(function LiveTab() { const [timeInputMode, setTimeInputMode] = useState(loadTimeInputMode); const [showEditRulesModal, setShowEditRulesModal] = useState(false); const [showCardImagesModal, setShowCardImagesModal] = useState(false); - const [showOwnBackgroundModal, setShowOwnBackgroundModal] = useState(false); // Bumped whenever a custom card image is uploaded or reset, so the overlay // effect below re-pushes whatever is currently showing with the new artwork. const [cardImagesGeneration, setCardImagesGeneration] = useState(0); @@ -978,7 +976,6 @@ export default memo(function LiveTab() { revealFaceWhenIdle={revealFaceWhenIdle} onToggleRevealFaceWhenIdle={handleToggleRevealFaceWhenIdle} onCustomizeCards={() => setShowCardImagesModal(true)} - onCustomizeOwnBackground={() => setShowOwnBackgroundModal(true)} /> @@ -1384,12 +1381,6 @@ export default memo(function LiveTab() { /> )} - - {showOwnBackgroundModal && ( - - setShowOwnBackgroundModal(false)} /> - - )} ); }); diff --git a/apps/zoom-app/src/components/OverlayModeMenu.jsx b/apps/zoom-app/src/components/OverlayModeMenu.jsx index 11d8296..ad5ccf6 100644 --- a/apps/zoom-app/src/components/OverlayModeMenu.jsx +++ b/apps/zoom-app/src/components/OverlayModeMenu.jsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect, memo } from 'react'; -import { Monitor, Camera, Maximize2, ChevronDown, Check, Image, ImagePlus } from 'lucide-react'; +import { Monitor, Camera, Maximize2, ChevronDown, Check, Image } from 'lucide-react'; import { OVERLAY_MODE_CARD, OVERLAY_MODE_CAMERA, @@ -51,7 +51,7 @@ export const MODE_LABELS = Object.fromEntries(OVERLAY_MODES.map(({ mode, label } /** * Mode picker for the Live tab: the active mode plus a menu to change it. */ -export default memo(function OverlayModeMenu({ value, onChange, revealFaceWhenIdle, onToggleRevealFaceWhenIdle, onCustomizeCards, onCustomizeOwnBackground }) { +export default memo(function OverlayModeMenu({ value, onChange, revealFaceWhenIdle, onToggleRevealFaceWhenIdle, onCustomizeCards }) { const [open, setOpen] = useState(false); const buttonRef = useRef(null); const menuRef = useRef(null); @@ -174,28 +174,6 @@ export default memo(function OverlayModeMenu({ value, onChange, revealFaceWhenId )} - - {/* Directly under the toggle, because it is what the toggle lands on. - Video modes only, for the same reason the toggle is: the stage - never touches the camera. */} - {isVideoOverlayMode(value) && onCustomizeOwnBackground && ( - - )} )} diff --git a/apps/zoom-app/src/components/OwnBackgroundModal.jsx b/apps/zoom-app/src/components/OwnBackgroundModal.jsx deleted file mode 100644 index 6d4a6bf..0000000 --- a/apps/zoom-app/src/components/OwnBackgroundModal.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import { X } from 'lucide-react'; -import OwnBackgroundPicker from './OwnBackgroundPicker'; - -/** - * The mode menu's way into the own-background setting. The same picker also - * sits inside the Card Images modal; this wrapper exists so the setting can be - * reached from beside the "Show my own background" toggle it serves, without - * making the organizer go looking through the card artwork to find it. - */ -export default function OwnBackgroundModal({ onClose }) { - return ( -
-
-
-

My Background

- -
-

- Where your video lands when a speech ends, and whenever you clear the timer. -

- - - -

- Stored only in this browser. Please upload only images you have the right to use. -

-
-
- ); -} diff --git a/apps/zoom-app/src/components/OwnBackgroundPicker.jsx b/apps/zoom-app/src/components/OwnBackgroundPicker.jsx index ac62e68..fcf582f 100644 --- a/apps/zoom-app/src/components/OwnBackgroundPicker.jsx +++ b/apps/zoom-app/src/components/OwnBackgroundPicker.jsx @@ -14,8 +14,7 @@ import { useToast } from '../context/ToastContext'; /** * Pick the background the timer puts back when a speech ends. * - * Shown in two places — the Card Images modal and its own modal off the mode - * menu — so it is a section rather than a dialog of its own. + * A section of the Card Images modal rather than a dialog of its own. * * Why it exists: without one, handing the organizer their background back means * asking Zoom what they had and asking for its pixels, across APIs that are not From 3046c9b7c7d77c495f5e42afe9299052e843b707 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Tue, 3 Nov 2026 20:48:54 -0500 Subject: [PATCH 02/57] test(zoom-client): review timer-core against the My Background menu removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No step changes: no step opened the mode menu's "My Background…" item. Note under "Not covered" that the own-background picker now lives only in Card Images. --- e2e/zoom-client/timer-core.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/e2e/zoom-client/timer-core.md b/e2e/zoom-client/timer-core.md index 8476167..23bde71 100644 --- a/e2e/zoom-client/timer-core.md +++ b/e2e/zoom-client/timer-core.md @@ -96,5 +96,7 @@ PostHog carrying that role. ## Not covered - Colour accuracy on every camera/background setting. - A second participant seeing the video (needs a second computer). +- Choosing the background you return to between speeches. Its picker lives only in Card + Images (`OwnBackgroundPicker.jsx`); the mode menu's "My Background…" item was removed. - Agenda import, and the roles it assigns to imported lines (`importBulkSpeakers` in `TimerContext.jsx`). See [agenda-import](agenda-import.md). From c8979403200dfc7fbb666949f7adc4c277b98bf7 Mon Sep 17 00:00:00 2001 From: Shuhao Date: Tue, 3 Nov 2026 21:58:10 -0500 Subject: [PATCH 03/57] feat(sounds): card-change chime and break music for Pro (#105) A soft chime when the card changes (one note at green, two at yellow, three at red) in the Zoom app and the web timer, and music while Take a Break counts down in the Zoom app. Pro only, behind the new `sounds` release flag, and off until the user turns it on. Sound plays on the timer's own computer; the Sounds modal says the speaker hears it only through Zoom's Share sound. The chime and the placeholder "Music box" track are synthesized with Web Audio; file tracks can be added under public/music/ via BREAK_TRACKS. The player is mounted above the tabs so sound carries on when the Live tab is switched away or the web panel is minimized. Tests: shared engine and component unit tests, web and Zoom e2e that count real oscillators, and a new timer-sounds Zoom-client spec (timer-core reviewed: no step changes, pointer under Not covered). --- apps/web/src/components/LiveTab.jsx | 22 +- apps/web/src/components/SoundsModal.jsx | 118 ++++++ apps/web/src/components/TimerSounds.jsx | 37 ++ apps/web/src/hooks/useSounds.js | 26 ++ apps/web/src/pages/TimerApp.jsx | 2 + apps/zoom-app/src/App.jsx | 2 + apps/zoom-app/src/components/LiveTab.jsx | 22 +- apps/zoom-app/src/components/SoundsModal.jsx | 205 ++++++++++ apps/zoom-app/src/components/TimerSounds.jsx | 55 +++ .../src/components/TimerSounds.test.jsx | 176 +++++++++ apps/zoom-app/src/hooks/useSounds.js | 28 ++ e2e/web.timer-sounds.spec.js | 68 ++++ e2e/zoom-client/INDEX.md | 3 +- e2e/zoom-client/timer-core.md | 2 + e2e/zoom-client/timer-sounds.md | 93 +++++ e2e/zoom.timer-sounds.spec.js | 56 +++ packages/shared/__tests__/timerSounds.test.js | 218 ++++++++++ packages/shared/index.js | 1 + packages/shared/timerSounds.js | 373 ++++++++++++++++++ worker/flags.js | 4 + 20 files changed, 1508 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/components/SoundsModal.jsx create mode 100644 apps/web/src/components/TimerSounds.jsx create mode 100644 apps/web/src/hooks/useSounds.js create mode 100644 apps/zoom-app/src/components/SoundsModal.jsx create mode 100644 apps/zoom-app/src/components/TimerSounds.jsx create mode 100644 apps/zoom-app/src/components/TimerSounds.test.jsx create mode 100644 apps/zoom-app/src/hooks/useSounds.js create mode 100644 e2e/web.timer-sounds.spec.js create mode 100644 e2e/zoom-client/timer-sounds.md create mode 100644 e2e/zoom.timer-sounds.spec.js create mode 100644 packages/shared/__tests__/timerSounds.test.js create mode 100644 packages/shared/timerSounds.js diff --git a/apps/web/src/components/LiveTab.jsx b/apps/web/src/components/LiveTab.jsx index 7e08431..9966df3 100644 --- a/apps/web/src/components/LiveTab.jsx +++ b/apps/web/src/components/LiveTab.jsx @@ -1,11 +1,12 @@ import { useState, useEffect, useRef, useMemo, memo, lazy, Suspense } from 'react'; import { useTimer, useTimerTick } from '../context/TimerContext'; import { useToast } from '../context/ToastContext'; -import { Play, Square, RotateCcw, Image } from 'lucide-react'; +import { Play, Square, RotateCcw, Image, Volume2 } from 'lucide-react'; import SpeakerInput from './SpeakerInput'; import TimerDisplay from './TimerDisplay'; const EditRulesModal = lazy(() => import('./EditRulesModal')); const CardImagesModal = lazy(() => import('./CardImagesModal')); +const SoundsModal = lazy(() => import('./SoundsModal')); import TimeInput, { TimeInputModeToggle } from './TimeInput'; import { DEFAULT_ROLE_RULES, DEFAULT_CUSTOM_RULES, loadTimeInputMode, saveTimeInputMode, initCardImages } from '@toastmaster-timer/shared'; import { setPageBackgroundFromStatus } from '../utils/pageBackground'; @@ -13,6 +14,7 @@ import { parseSpeakerFromSearch, stripSpeakerParams } from '../utils/speakerDeep import { getCardImageUrl, preloadCardImages } from '../utils/cardArtwork'; import { trackEvent } from '../utils/posthog'; import { useClub } from '../hooks/useClub'; +import { useSoundsAccess } from '../hooks/useSounds'; import { clubBadgePlacement } from '@toastmaster-timer/shared'; const PREVIEW_COLORS = [ @@ -55,6 +57,8 @@ export default memo(function LiveTab({ onTimerStart }) { const [timeInputMode, setTimeInputMode] = useState(loadTimeInputMode); const [showEditRulesModal, setShowEditRulesModal] = useState(false); const [showCardImagesModal, setShowCardImagesModal] = useState(false); + const [showSoundsModal, setShowSoundsModal] = useState(false); + const { visible: soundsVisible } = useSoundsAccess(); // Bumped on upload/reset so the tile and page background re-read the images. const [, setCardImagesGeneration] = useState(0); const [previewColor, setPreviewColor] = useState(null); @@ -291,6 +295,16 @@ export default memo(function LiveTab({ onTimerStart }) { > + {soundsVisible && ( + + )} )} @@ -357,6 +371,12 @@ export default memo(function LiveTab({ onTimerStart }) { /> )} + + {showSoundsModal && ( + + setShowSoundsModal(false)} /> + + )} ); }); diff --git a/apps/web/src/components/SoundsModal.jsx b/apps/web/src/components/SoundsModal.jsx new file mode 100644 index 0000000..eedc195 --- /dev/null +++ b/apps/web/src/components/SoundsModal.jsx @@ -0,0 +1,118 @@ +import { Link } from 'react-router-dom' +import { X } from 'lucide-react' +import { updateSoundSettings } from '@toastmaster-timer/shared' +import { useEntitlement } from '../hooks/useEntitlement' +import { useFlag } from '../hooks/useFlag' +import { soundEngine, useSoundSettings, useSoundsAccess } from '../hooks/useSounds' +import { trackEvent } from '../utils/posthog' + +const CUE_BUTTONS = [ + { color: 'green', label: 'Green', className: 'bg-green-500 hover:bg-green-600' }, + { color: 'yellow', label: 'Yellow', className: 'bg-yellow-400 hover:bg-yellow-500' }, + { color: 'red', label: 'Red', className: 'bg-red-500 hover:bg-red-600' }, +] + +/** + * Sound settings (Pro): the card-change chime and its volume. Break music is + * the Zoom app's; this timer has no Take a Break role. + */ +export default function SoundsModal({ isOpen, onClose }) { + const settings = useSoundSettings() + const { usable } = useSoundsAccess() + const { known } = useEntitlement() + // The account page sells Pro, and stays dark until pro is released. + const { enabled: proEnabled, known: flagsKnown } = useFlag('pro') + + if (!isOpen) return null + + return ( +
+
+
+

+ Sounds + + Pro + +

+ +
+

+ For speakers who can't watch the card while they speak. +

+ + {!usable && ( +

+ Sounds are part of Pro. + {known && flagsKnown && proEnabled && ( + <> + {' '} + + Upgrade to Pro + {' '} + to turn them on. + + )} +

+ )} + +
+
+ +
+ Test: + {CUE_BUTTONS.map(({ color, label, className }) => ( + + ))} +
+
+ + +
+ +

+ Sounds play from this device. In person, the room hears them. On a video call, share your + computer's sound so the speaker does. +

+
+
+ ) +} diff --git a/apps/web/src/components/TimerSounds.jsx b/apps/web/src/components/TimerSounds.jsx new file mode 100644 index 0000000..82f56a2 --- /dev/null +++ b/apps/web/src/components/TimerSounds.jsx @@ -0,0 +1,37 @@ +import { useEffect, useRef } from 'react' +import { cueForStatusChange } from '@toastmaster-timer/shared' +import { useTimerTick } from '../context/TimerContext' +import { soundEngine, useSoundSettings, useSoundsAccess } from '../hooks/useSounds' + +/** + * Plays the card-change chime. Renders nothing. + * + * Mounted above the panel rather than in the Live tab: the timer usually runs + * with the panel minimized, which unmounts the tabs. + */ +export default function TimerSounds() { + const { currentStatus, isRunning } = useTimerTick() + const settings = useSoundSettings() + const { usable } = useSoundsAccess() + const previousStatus = useRef(currentStatus) + const wanted = usable && settings.cues + + // Browsers only let audio start after a click. Start is one, but create the + // context on any click so the first chime is not the one that gets eaten. + useEffect(() => { + if (!wanted) return undefined + const unlock = () => soundEngine.unlock() + document.addEventListener('pointerdown', unlock, true) + return () => document.removeEventListener('pointerdown', unlock, true) + }, [wanted]) + + useEffect(() => { + const previous = previousStatus.current + previousStatus.current = currentStatus + if (!wanted || !isRunning) return + const cue = cueForStatusChange(previous, currentStatus) + if (cue) soundEngine.playCue(cue, settings.volume) + }, [currentStatus, isRunning, wanted, settings.volume]) + + return null +} diff --git a/apps/web/src/hooks/useSounds.js b/apps/web/src/hooks/useSounds.js new file mode 100644 index 0000000..2e0e6c0 --- /dev/null +++ b/apps/web/src/hooks/useSounds.js @@ -0,0 +1,26 @@ +import { useSyncExternalStore } from 'react' +import { createSoundEngine, getSoundSettings, subscribeSoundSettings } from '@toastmaster-timer/shared' +import { useEntitlement } from './useEntitlement' +import { useFlag } from './useFlag' + +// One engine for the app: the player and the settings modal's test buttons +// share its AudioContext. +export const soundEngine = createSoundEngine() + +/** The saved sound settings, live. */ +export function useSoundSettings() { + return useSyncExternalStore(subscribeSoundSettings, getSoundSettings, getSoundSettings) +} + +/** + * Whether sounds show at all (the release flag) and whether this user may turn + * them on (Pro). A free user sees the controls, locked, with the way to Pro. + * + * @returns {{visible: boolean, usable: boolean}} + */ +export function useSoundsAccess() { + const { enabled, known } = useFlag('sounds') + const { isPro } = useEntitlement() + const visible = known && enabled + return { visible, usable: visible && isPro } +} diff --git a/apps/web/src/pages/TimerApp.jsx b/apps/web/src/pages/TimerApp.jsx index e66746b..5361750 100644 --- a/apps/web/src/pages/TimerApp.jsx +++ b/apps/web/src/pages/TimerApp.jsx @@ -13,6 +13,7 @@ const AgendaTab = lazy(() => import('../components/AgendaTab')) import ReportTab from '../components/ReportTab' import Footer from '../components/Footer' import PeriodicPrompts from '../components/PeriodicPrompts' +import TimerSounds from '../components/TimerSounds' import AccountMenu from '../components/AccountMenu' import ClubStatusBanner from '../components/ClubStatusBanner' import '../App.css' @@ -132,6 +133,7 @@ export default function TimerApp() { + ) diff --git a/apps/zoom-app/src/App.jsx b/apps/zoom-app/src/App.jsx index ffc4c47..a0c34ae 100644 --- a/apps/zoom-app/src/App.jsx +++ b/apps/zoom-app/src/App.jsx @@ -4,6 +4,7 @@ import { ToastProvider } from './context/ToastContext'; import NavTabs from './components/NavTabs'; import Footer from './components/Footer'; import PeriodicPrompts from './components/PeriodicPrompts'; +import TimerSounds from './components/TimerSounds'; import ContactCapture from './components/ContactCapture'; import ZoomConnectionNotice from './components/ZoomConnectionNotice'; import ClubStatusBanner from './components/ClubStatusBanner'; @@ -42,6 +43,7 @@ function App() {