diff --git a/apps/zoom-app/src/App.jsx b/apps/zoom-app/src/App.jsx index 1b7da4a..ee615cf 100644 --- a/apps/zoom-app/src/App.jsx +++ b/apps/zoom-app/src/App.jsx @@ -9,45 +9,64 @@ import ContactCapture from './components/ContactCapture'; import ZoomConnectionNotice from './components/ZoomConnectionNotice'; import ClubStatusBanner from './components/ClubStatusBanner'; import AnnouncementBanner from './components/AnnouncementBanner'; +import { useNewLook } from './hooks/useNewLook'; import './App.css'; const LiveTab = lazy(() => import('./components/LiveTab')); const AgendaTab = lazy(() => import('./components/AgendaTab')); const ReportTab = lazy(() => import('./components/ReportTab')); +// Design B. Its own chunk, so today's panel does not ship the new one's code. +const NewLookShell = lazy(() => import('./b/NewLookShell')); -function App() { +/** The panel shipped today: tabs, the Live tab, the footer. */ +export function ClassicShell() { const [activeTab, setActiveTab] = useState('live'); + return ( +
+ + {/* Above the tabs, ahead of everything: the week of warning a club + gets before it loses Pro has to be seen by whoever is timing, who + is rarely whoever pays. */} + + + + + +
+ +
+ }> +
+ {activeTab === 'live' && } + {activeTab === 'agenda' && setActiveTab('live')} />} + {activeTab === 'report' && } +
+
+ +
+ ); +} + +function App() { + // Behind the `new_look` release flag until design B is finished and launched. + const newLook = useNewLook(); + return ( -
- - {/* Above the tabs, ahead of everything: the week of warning a club - gets before it loses Pro has to be seen by whoever is timing, who - is rarely whoever pays. */} - - - - - -
- -
- }> -
- {activeTab === 'live' && } - {activeTab === 'agenda' && setActiveTab('live')} />} - {activeTab === 'report' && } -
+ {newLook ? ( + + - -
- - - -
+ ) : ( + + )}
); diff --git a/apps/zoom-app/src/b/NewLookShell.jsx b/apps/zoom-app/src/b/NewLookShell.jsx new file mode 100644 index 0000000..36f17d9 --- /dev/null +++ b/apps/zoom-app/src/b/NewLookShell.jsx @@ -0,0 +1,13 @@ +import { ClassicShell } from '../App'; + +/** + * Design B's panel: the top bar with the Agenda / Timer / Report switch, the + * Pro chip and the ⋯ More menu, the timer card and the "Look & sound" sheets. + * + * Phase 0 of plan 13 only puts the `new_look` switch in place, so until the + * Phase 1 shell lands this is today's panel unchanged. The flag is off + * everywhere, so nobody sees the difference either way. + */ +export default function NewLookShell() { + return ; +} diff --git a/apps/zoom-app/src/b/Sheet.jsx b/apps/zoom-app/src/b/Sheet.jsx new file mode 100644 index 0000000..a96f299 --- /dev/null +++ b/apps/zoom-app/src/b/Sheet.jsx @@ -0,0 +1,111 @@ +import { useEffect, useId, useRef } from 'react'; +import { X } from 'lucide-react'; + +const FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +/** + * A bottom sheet: design B's one way of opening a setting (plan 13). The panel + * dims, the sheet rises from the bottom edge with a title and a close button, + * and only that one setting is inside it. + * + * A modal dialog to assistive tech and the keyboard: focus moves in when it + * opens and goes back to whatever opened it when it closes, Tab stays inside, + * Escape and a press on the dimmed panel close it. + * + * @param {{ + * open: boolean, + * onClose: () => void, + * title: string, + * description?: import('react').ReactNode, + * badge?: import('react').ReactNode, + * children: import('react').ReactNode, + * }} props - `badge` sits beside the title (the PRO tag on Sounds) + */ +export default function Sheet({ open, onClose, title, description, badge, children }) { + const titleId = useId(); + const sheetRef = useRef(null); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + + useEffect(() => { + if (!open) return undefined; + const opener = document.activeElement; + const sheet = sheetRef.current; + const first = sheet?.querySelector(FOCUSABLE); + (first || sheet)?.focus(); + + const handleKeyDown = (event) => { + if (event.key === 'Escape') { + event.stopPropagation(); + onCloseRef.current(); + return; + } + if (event.key !== 'Tab' || !sheet) return; + const focusable = [...sheet.querySelectorAll(FOCUSABLE)]; + if (focusable.length === 0) { + event.preventDefault(); + return; + } + const firstEl = focusable[0]; + const lastEl = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === firstEl) { + event.preventDefault(); + lastEl.focus(); + } else if (!event.shiftKey && document.activeElement === lastEl) { + event.preventDefault(); + firstEl.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + if (opener && typeof opener.focus === 'function' && document.contains(opener)) opener.focus(); + }; + }, [open]); + + if (!open) return null; + + return ( +
+ {/* The dimmed panel. A press here closes, like the close button; it is + not a control of its own, so it stays out of the tab order. */} + + ); +} diff --git a/apps/zoom-app/src/b/Sheet.test.jsx b/apps/zoom-app/src/b/Sheet.test.jsx new file mode 100644 index 0000000..2d7f728 --- /dev/null +++ b/apps/zoom-app/src/b/Sheet.test.jsx @@ -0,0 +1,97 @@ +import '@testing-library/jest-dom'; +import { useState } from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import Sheet from './Sheet'; + +function Harness({ onClose = () => {}, children }) { + const [open, setOpen] = useState(false); + return ( + <> + + { + onClose(); + setOpen(false); + }} + title="Cards" + description="The card the timer shows at each colour." + > + {children ?? ( + <> + + + + )} + + + ); +} + +const openSheet = () => fireEvent.click(screen.getByRole('button', { name: 'Cards' })); + +describe('Sheet', () => { + it('renders nothing while closed', () => { + render(); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('is a labelled modal dialog with its description', () => { + render(); + openSheet(); + const dialog = screen.getByRole('dialog', { name: 'Cards' }); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + expect(screen.getByText('The card the timer shows at each colour.')).toBeInTheDocument(); + }); + + it('moves focus in when it opens, and back to the opener when it closes', () => { + render(); + const opener = screen.getByRole('button', { name: 'Cards' }); + opener.focus(); + openSheet(); + expect(screen.getByRole('button', { name: 'Close' })).toHaveFocus(); + + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(opener).toHaveFocus(); + }); + + it('closes on Escape, on the close button and on the dimmed panel', () => { + const onClose = vi.fn(); + render(); + + openSheet(); + fireEvent.keyDown(document, { key: 'Escape' }); + openSheet(); + fireEvent.click(screen.getByRole('button', { name: 'Close' })); + openSheet(); + fireEvent.click(screen.getByTestId('sheet-backdrop')); + + expect(onClose).toHaveBeenCalledTimes(3); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('keeps Tab inside the sheet', () => { + render(); + openSheet(); + const close = screen.getByRole('button', { name: 'Close' }); + const last = screen.getByRole('button', { name: 'Modern' }); + + last.focus(); + fireEvent.keyDown(document, { key: 'Tab' }); + expect(close).toHaveFocus(); + + fireEvent.keyDown(document, { key: 'Tab', shiftKey: true }); + expect(last).toHaveFocus(); + }); + + it('does not close for a press inside the sheet', () => { + const onClose = vi.fn(); + render(); + openSheet(); + fireEvent.click(screen.getByRole('button', { name: 'Classic' })); + expect(onClose).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/zoom-app/src/components/CardImagesModal.jsx b/apps/zoom-app/src/components/CardImagesModal.jsx index f398f98..4fe869e 100644 --- a/apps/zoom-app/src/components/CardImagesModal.jsx +++ b/apps/zoom-app/src/components/CardImagesModal.jsx @@ -1,261 +1,14 @@ -import { useEffect, useRef, useState, lazy, Suspense } from 'react'; -import { useEntitlement } from '../hooks/useEntitlement'; -import { useFlag } from '../hooks/useFlag'; -import { X, Plus, Check, Trash2, Share2 } from 'lucide-react'; -import { - CARD_COLORS, - CLUB_CARD_SET_ID, - DEFAULT_CARD_SETS, - getCardImageSettings, - activeCardSetId, - clubCardSetAvailable, - clubFeaturesOn, - getClubCardImage, - selectCardSet, - addCustomCardSet, - deleteCustomCardSet, - getCustomCardImage, - fileToCardBlob, - initCardImages, - canPublishCards, - publishCardSet, - loadClubCards, - subscribeCardImages, -} from '@toastmaster-timer/shared'; -import { getCardFileUrl, notifyCardImagesChanged, preloadBackgroundImages } from '../utils/zoomSdk'; -import { getSessionToken } from '../utils/zoomIdentity'; -import { useClub } from '../hooks/useClub'; +import { X } from 'lucide-react'; import OwnBackgroundPicker from './OwnBackgroundPicker'; -import { useToast } from '../context/ToastContext'; -const UpgradeModal = lazy(() => import('./UpgradeModal')); - -const COLOR_LABELS = { - blue: 'Idle (blue)', - green: 'Green', - yellow: 'Yellow', - red: 'Red', -}; - -// Faint status tint behind each empty upload slot, so the row reads as -// blue/green/yellow/red before any image is picked. -const SLOT_TINTS = { - blue: '#eff6ff', - green: '#f0fdf4', - yellow: '#fefce8', - red: '#fef2f2', -}; - -const CLASSIC_FILES = DEFAULT_CARD_SETS[0].files; -const STORAGE_FULL_MESSAGE = - 'Could not save — browser storage is full. Try smaller images or delete another custom set.'; - -const SHARE_ERRORS = { - forbidden: 'Only your club’s admins and editors can share cards with the club.', - upgrade_required: 'Your club’s Pro has ended, so it can’t take new cards.', - storage_limit: 'Your club’s card storage is full.', - image_too_large: 'One of these images is too large to share.', - network: 'Could not reach the server. Check your connection and try again.', -}; - -/** - * One selectable set: a labeled row of the four cards. The row itself is the - * radio; a custom set also carries a delete button, which must not double as - * a click on the row. - */ -function CardSetRow({ label, selected, onSelect, thumbSrc, onDelete, onShare, sharing }) { - return ( -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onSelect(); - } - }} - className={`rounded-lg border p-2 cursor-pointer transition-colors ${ - selected ? 'border-blue-500 ring-1 ring-blue-500' : 'border-gray-200 hover:border-gray-300' - }`} - > -
- - {label} - {selected && } - - - {onShare && ( - - )} - {onDelete && ( - - )} - -
-
- {CARD_COLORS.map((color) => ( - {`${COLOR_LABELS[color]} - ))} -
-
- ); -} +import CardSetsPanel from './CardSetsPanel'; /** - * Card set picker: the two built-in sets, every custom set the organizer has - * added, and a row of uploaders for adding another. Custom sets are stored in - * this browser only, and the built-in sets stay available as the defaults a - * delete returns to — so the choice of artwork, and the rights to it, sit - * with the user. + * The Card Images dialog: the card set picker, and the organizer's own + * background, which is the other image their video shows. */ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { - const { showToast } = useToast(); - const { isPro, known } = useEntitlement(); - // The Upgrade link opens the purchase, which stays dark until pro is - // released. Until then the note reads as it does while the plan is unknown. - const { enabled: proEnabled, known: flagsKnown } = useFlag('pro'); - const [showUpgrade, setShowUpgrade] = useState(false); - const [settings, setSettings] = useState(() => getCardImageSettings()); - // The club's set is not in the metadata, so which set shows is asked of the - // shared module rather than read off `settings`. - const [activeSetId, setActiveSetId] = useState(() => activeCardSetId()); - const { club, clubName } = useClub(); - // The kit carries the name when there is one; the club record always does. - const clubLabel = clubName || club?.club?.name || null; - const [sharingSetId, setSharingSetId] = useState(null); - // Re-render when the club's images arrive, so its thumbnails fill in. - const [, setClubGeneration] = useState(0); - // The set being assembled in the uploader row, color -> {blob, url}. Kept - // only in this modal until the organizer confirms it into a real set. - const [draft, setDraft] = useState({}); - const draftRef = useRef(draft); - draftRef.current = draft; - const fileInputRef = useRef(null); - const draftColorRef = useRef(null); - - // The stored Blobs load asynchronously; if the modal opens before they are - // in, refresh once they are so custom thumbnails fill in. On unmount, - // release the preview URLs of an unconfirmed draft. - useEffect(() => { - let cancelled = false; - initCardImages().then(() => { - if (!cancelled) { - setSettings(getCardImageSettings()); - setActiveSetId(activeCardSetId()); - } - }); - const unsubscribe = subscribeCardImages(() => { - setActiveSetId(activeCardSetId()); - setClubGeneration((generation) => generation + 1); - }); - return () => { - cancelled = true; - unsubscribe(); - Object.values(draftRef.current).forEach((entry) => URL.revokeObjectURL(entry.url)); - }; - }, []); - if (!isOpen) return null; - const changed = () => { - setSettings(getCardImageSettings()); - setActiveSetId(activeCardSetId()); - notifyCardImagesChanged(); - onImagesChanged?.(); - // The notify above dropped every decoded overlay frame; re-decode the new - // set's four cards now, off the critical path, so the coming status - // switches (blue -> green -> yellow -> red) push straight from cache. - preloadBackgroundImages(); - }; - - const handleSelect = (setId) => { - if (setId === activeSetId) return; - if (selectCardSet(setId)) changed(); - else showToast(STORAGE_FULL_MESSAGE, 'error'); - }; - - const handleDraftUpload = async (color, file) => { - if (!file) return; - try { - const blob = await fileToCardBlob(file); - setDraft((prev) => { - if (prev[color]) URL.revokeObjectURL(prev[color].url); - return { ...prev, [color]: { blob, url: URL.createObjectURL(blob) } }; - }); - } catch { - showToast('That file could not be read as an image.', 'error'); - } - }; - - const handleAddDraft = async () => { - const images = {}; - for (const color of CARD_COLORS) { - if (draft[color]) images[color] = draft[color].blob; - } - const id = await addCustomCardSet(images); - if (!id) { - showToast(STORAGE_FULL_MESSAGE, 'error'); - return; - } - Object.values(draft).forEach((entry) => URL.revokeObjectURL(entry.url)); - setDraft({}); - changed(); - showToast('Custom card set added', 'success'); - }; - - const handleDelete = async (setId) => { - if (await deleteCustomCardSet(setId)) { - changed(); - showToast('Custom card set deleted', 'success'); - } - }; - - // No label: "Custom 1" is this device's slot name, and every other club device - // has its own "Custom 1". Unlabelled, the set reads " cards" everywhere. - const handleShare = async (setId) => { - setSharingSetId(setId); - const result = await publishCardSet(setId, { getToken: getSessionToken }); - setSharingSetId(null); - if (result.ok) { - showToast(`Shared with ${clubLabel || 'your club'}. Every club device gets these cards.`, 'success'); - } else { - showToast(SHARE_ERRORS[result.error] || 'Could not share these cards. Try again.', 'error'); - } - }; - - const pickDraftFile = (color) => { - draftColorRef.current = color; - fileInputRef.current?.click(); - }; - - const draftHasImages = CARD_COLORS.some((color) => draft[color]); - return (
@@ -270,136 +23,13 @@ export default function CardImagesModal({ isOpen, onClose, onImagesChanged }) { does — including on your video.

-
- {clubCardSetAvailable() && ( - handleSelect(CLUB_CARD_SET_ID)} - thumbSrc={(color) => getClubCardImage(color) || getCardFileUrl(CLASSIC_FILES[color])} - /> - )} - - {DEFAULT_CARD_SETS.map((set) => ( - handleSelect(set.id)} - thumbSrc={(color) => getCardFileUrl(set.files[color])} - /> - ))} - - {settings.customSets.map((set, index) => ( - handleSelect(set.id)} - // A color the set has no upload for shows — and times with — - // the Classic card. - thumbSrc={(color) => getCustomCardImage(set.id, color) || getCardFileUrl(CLASSIC_FILES[color])} - onDelete={() => handleDelete(set.id)} - onShare={club && canPublishCards() ? () => handleShare(set.id) : undefined} - sharing={sharingSetId === set.id} - /> - ))} - -
-
- Add your own - {draftHasImages && ( - - )} -
-
- {CARD_COLORS.map((color) => - draft[color] ? ( - - ) : ( - - ) - )} -
-

- Upload an image for each color, then Add set. Colors you skip use the Classic card. -

+ + {/* Not a card, but the other image the organizer's video shows — and + the one they have to go looking for otherwise. */} +
+
-
- - {/* Not a card, but the other image the organizer's video shows — and - the one they have to go looking for otherwise. */} -
- -
- - { - handleDraftUpload(draftColorRef.current, e.target.files?.[0]); - // Same file re-picked later must fire change again. - e.target.value = ''; - }} - /> - -

- {clubFeaturesOn(club) ? ( - <> - Custom images are stored only in this browser. - {canPublishCards() ? ' Share a set to put it on every club device. ' : ' '} - - ) : isPro ? ( - <>Custom images are backed up and follow you to your other devices. - ) : known && flagsKnown && proEnabled ? ( - <> - Custom images are stored only in this browser.{' '} - {' '} - to back them up and use them on your other devices.{' '} - - ) : ( - <>Custom images are stored only in this browser. - )} - Please upload only images you have the right to use — for example your club's own - materials. This app is independent and cannot grant permission for third-party logos or - artwork. -

- {showUpgrade && ( - - setShowUpgrade(false)} source="card_images" /> - - )} +
); diff --git a/apps/zoom-app/src/components/CardImagesModal.test.jsx b/apps/zoom-app/src/components/CardImagesModal.test.jsx index 3009ff4..60efc9e 100644 --- a/apps/zoom-app/src/components/CardImagesModal.test.jsx +++ b/apps/zoom-app/src/components/CardImagesModal.test.jsx @@ -10,6 +10,7 @@ import { } from '@toastmaster-timer/shared'; import { publishCardSet } from '@toastmaster-timer/shared'; import CardImagesModal from './CardImagesModal'; +import CardSetsPanel from './CardSetsPanel'; import { ToastProvider } from '../context/ToastContext'; // Stubbed rather than imported: the real module pulls in @zoom/appssdk, which @@ -192,3 +193,20 @@ describe('the card images storage note', () => { expect(upgradeLink()).toBeNull(); }); }); + +// The dialog's card picker on its own, as design B's Cards sheet shows it: the +// sets only, with the own-background picker left to its own sheet. +describe('the card set panel', () => { + it('shows the sets and the storage note, and nothing about the background', async () => { + render( + + + + ); + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); + expect(screen.getByRole('radiogroup', { name: 'Card set' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /classic/i })).toBeInTheDocument(); + expect(storageNote()).toBeInTheDocument(); + expect(screen.queryByText(/background/i)).toBeNull(); + }); +}); diff --git a/apps/zoom-app/src/components/CardSetsPanel.jsx b/apps/zoom-app/src/components/CardSetsPanel.jsx new file mode 100644 index 0000000..634f58b --- /dev/null +++ b/apps/zoom-app/src/components/CardSetsPanel.jsx @@ -0,0 +1,390 @@ +import { useEffect, useRef, useState, lazy, Suspense } from 'react'; +import { useEntitlement } from '../hooks/useEntitlement'; +import { useFlag } from '../hooks/useFlag'; +import { Plus, Check, Trash2, Share2 } from 'lucide-react'; +import { + CARD_COLORS, + CLUB_CARD_SET_ID, + DEFAULT_CARD_SETS, + getCardImageSettings, + activeCardSetId, + clubCardSetAvailable, + clubFeaturesOn, + getClubCardImage, + selectCardSet, + addCustomCardSet, + deleteCustomCardSet, + getCustomCardImage, + fileToCardBlob, + initCardImages, + canPublishCards, + publishCardSet, + loadClubCards, + subscribeCardImages, +} from '@toastmaster-timer/shared'; +import { getCardFileUrl, notifyCardImagesChanged, preloadBackgroundImages } from '../utils/zoomSdk'; +import { getSessionToken } from '../utils/zoomIdentity'; +import { useClub } from '../hooks/useClub'; +import { useToast } from '../context/ToastContext'; +const UpgradeModal = lazy(() => import('./UpgradeModal')); + +const COLOR_LABELS = { + blue: 'Idle (blue)', + green: 'Green', + yellow: 'Yellow', + red: 'Red', +}; + +// Faint status tint behind each empty upload slot, so the row reads as +// blue/green/yellow/red before any image is picked. +const SLOT_TINTS = { + blue: '#eff6ff', + green: '#f0fdf4', + yellow: '#fefce8', + red: '#fef2f2', +}; + +const CLASSIC_FILES = DEFAULT_CARD_SETS[0].files; +const STORAGE_FULL_MESSAGE = + 'Could not save — browser storage is full. Try smaller images or delete another custom set.'; + +const SHARE_ERRORS = { + forbidden: 'Only your club’s admins and editors can share cards with the club.', + upgrade_required: 'Your club’s Pro has ended, so it can’t take new cards.', + storage_limit: 'Your club’s card storage is full.', + image_too_large: 'One of these images is too large to share.', + network: 'Could not reach the server. Check your connection and try again.', +}; + +/** + * One selectable set: a labeled row of the four cards. The row itself is the + * radio; a custom set also carries a delete button, which must not double as + * a click on the row. + */ +function CardSetRow({ label, selected, onSelect, thumbSrc, onDelete, onShare, sharing }) { + return ( +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(); + } + }} + className={`rounded-lg border p-2 cursor-pointer transition-colors ${ + selected ? 'border-blue-500 ring-1 ring-blue-500' : 'border-gray-200 hover:border-gray-300' + }`} + > +
+ + {label} + {selected && } + + + {onShare && ( + + )} + {onDelete && ( + + )} + +
+
+ {CARD_COLORS.map((color) => ( + {`${COLOR_LABELS[color]} + ))} +
+
+ ); +} + +/** + * Card set picker: the two built-in sets, every custom set the organizer has + * added, and a row of uploaders for adding another. Custom sets are stored in + * this browser only, and the built-in sets stay available as the defaults a + * delete returns to — so the choice of artwork, and the rights to it, sit + * with the user. + * + * The body of the Card Images dialog, without the dialog, so design B's Cards + * sheet (plan 13) shows the same picker. `children` render between the sets + * and the note under them; the dialog puts the own-background picker there. + */ +export default function CardSetsPanel({ onImagesChanged, children }) { + const { showToast } = useToast(); + const { isPro, known } = useEntitlement(); + // The Upgrade link opens the purchase, which stays dark until pro is + // released. Until then the note reads as it does while the plan is unknown. + const { enabled: proEnabled, known: flagsKnown } = useFlag('pro'); + const [showUpgrade, setShowUpgrade] = useState(false); + const [settings, setSettings] = useState(() => getCardImageSettings()); + // The club's set is not in the metadata, so which set shows is asked of the + // shared module rather than read off `settings`. + const [activeSetId, setActiveSetId] = useState(() => activeCardSetId()); + const { club, clubName } = useClub(); + // The kit carries the name when there is one; the club record always does. + const clubLabel = clubName || club?.club?.name || null; + const [sharingSetId, setSharingSetId] = useState(null); + // Re-render when the club's images arrive, so its thumbnails fill in. + const [, setClubGeneration] = useState(0); + // The set being assembled in the uploader row, color -> {blob, url}. Kept + // only in this modal until the organizer confirms it into a real set. + const [draft, setDraft] = useState({}); + const draftRef = useRef(draft); + draftRef.current = draft; + const fileInputRef = useRef(null); + const draftColorRef = useRef(null); + + // The stored Blobs load asynchronously; if the modal opens before they are + // in, refresh once they are so custom thumbnails fill in. On unmount, + // release the preview URLs of an unconfirmed draft. + useEffect(() => { + let cancelled = false; + initCardImages().then(() => { + if (!cancelled) { + setSettings(getCardImageSettings()); + setActiveSetId(activeCardSetId()); + } + }); + const unsubscribe = subscribeCardImages(() => { + setActiveSetId(activeCardSetId()); + setClubGeneration((generation) => generation + 1); + }); + return () => { + cancelled = true; + unsubscribe(); + Object.values(draftRef.current).forEach((entry) => URL.revokeObjectURL(entry.url)); + }; + }, []); + + const changed = () => { + setSettings(getCardImageSettings()); + setActiveSetId(activeCardSetId()); + notifyCardImagesChanged(); + onImagesChanged?.(); + // The notify above dropped every decoded overlay frame; re-decode the new + // set's four cards now, off the critical path, so the coming status + // switches (blue -> green -> yellow -> red) push straight from cache. + preloadBackgroundImages(); + }; + + const handleSelect = (setId) => { + if (setId === activeSetId) return; + if (selectCardSet(setId)) changed(); + else showToast(STORAGE_FULL_MESSAGE, 'error'); + }; + + const handleDraftUpload = async (color, file) => { + if (!file) return; + try { + const blob = await fileToCardBlob(file); + setDraft((prev) => { + if (prev[color]) URL.revokeObjectURL(prev[color].url); + return { ...prev, [color]: { blob, url: URL.createObjectURL(blob) } }; + }); + } catch { + showToast('That file could not be read as an image.', 'error'); + } + }; + + const handleAddDraft = async () => { + const images = {}; + for (const color of CARD_COLORS) { + if (draft[color]) images[color] = draft[color].blob; + } + const id = await addCustomCardSet(images); + if (!id) { + showToast(STORAGE_FULL_MESSAGE, 'error'); + return; + } + Object.values(draft).forEach((entry) => URL.revokeObjectURL(entry.url)); + setDraft({}); + changed(); + showToast('Custom card set added', 'success'); + }; + + const handleDelete = async (setId) => { + if (await deleteCustomCardSet(setId)) { + changed(); + showToast('Custom card set deleted', 'success'); + } + }; + + // No label: "Custom 1" is this device's slot name, and every other club device + // has its own "Custom 1". Unlabelled, the set reads " cards" everywhere. + const handleShare = async (setId) => { + setSharingSetId(setId); + const result = await publishCardSet(setId, { getToken: getSessionToken }); + setSharingSetId(null); + if (result.ok) { + showToast(`Shared with ${clubLabel || 'your club'}. Every club device gets these cards.`, 'success'); + } else { + showToast(SHARE_ERRORS[result.error] || 'Could not share these cards. Try again.', 'error'); + } + }; + + const pickDraftFile = (color) => { + draftColorRef.current = color; + fileInputRef.current?.click(); + }; + + const draftHasImages = CARD_COLORS.some((color) => draft[color]); + + return ( + <> +
+ {clubCardSetAvailable() && ( + handleSelect(CLUB_CARD_SET_ID)} + thumbSrc={(color) => getClubCardImage(color) || getCardFileUrl(CLASSIC_FILES[color])} + /> + )} + + {DEFAULT_CARD_SETS.map((set) => ( + handleSelect(set.id)} + thumbSrc={(color) => getCardFileUrl(set.files[color])} + /> + ))} + + {settings.customSets.map((set, index) => ( + handleSelect(set.id)} + // A color the set has no upload for shows — and times with — + // the Classic card. + thumbSrc={(color) => getCustomCardImage(set.id, color) || getCardFileUrl(CLASSIC_FILES[color])} + onDelete={() => handleDelete(set.id)} + onShare={club && canPublishCards() ? () => handleShare(set.id) : undefined} + sharing={sharingSetId === set.id} + /> + ))} + +
+
+ Add your own + {draftHasImages && ( + + )} +
+
+ {CARD_COLORS.map((color) => + draft[color] ? ( + + ) : ( + + ) + )} +
+

+ Upload an image for each color, then Add set. Colors you skip use the Classic card. +

+
+
+ + {children} + + { + handleDraftUpload(draftColorRef.current, e.target.files?.[0]); + // Same file re-picked later must fire change again. + e.target.value = ''; + }} + /> + +

+ {clubFeaturesOn(club) ? ( + <> + Custom images are stored only in this browser. + {canPublishCards() ? ' Share a set to put it on every club device. ' : ' '} + + ) : isPro ? ( + <>Custom images are backed up and follow you to your other devices. + ) : known && flagsKnown && proEnabled ? ( + <> + Custom images are stored only in this browser.{' '} + {' '} + to back them up and use them on your other devices.{' '} + + ) : ( + <>Custom images are stored only in this browser. + )} + Please upload only images you have the right to use — for example your club's own + materials. This app is independent and cannot grant permission for third-party logos or + artwork. +

+ {showUpgrade && ( + + setShowUpgrade(false)} source="card_images" /> + + )} + + ); +} diff --git a/apps/zoom-app/src/components/LiveTab.jsx b/apps/zoom-app/src/components/LiveTab.jsx index ae849e2..7110d03 100644 --- a/apps/zoom-app/src/components/LiveTab.jsx +++ b/apps/zoom-app/src/components/LiveTab.jsx @@ -1,22 +1,17 @@ -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, Eraser, Video, Image, Volume2, Lock } from 'lucide-react'; +import { memo, lazy, Suspense } from 'react'; +import { useLiveTimer } from '../hooks/useLiveTimer'; +import { Play, Square, RotateCcw, Eraser, Video, Image, Volume2, Lock, AlertTriangle, ChevronDown, ChevronUp } from 'lucide-react'; import SpeakerInput from './SpeakerInput'; import TimerDisplay from './TimerDisplay'; import TimerStage from './TimerStage'; import OverlayModeMenu, { MODE_LABELS } from './OverlayModeMenu'; +import TimeInput, { TimeInputModeToggle } from './TimeInput'; +import { DEFAULT_CUSTOM_RULES, saveTimeInputMode, BREAK_ROLE, BREAK_QUICK_PICKS } from '@toastmaster-timer/shared'; +import { isVideoOverlayMode, isClubBadgeMoved, OVERLAY_MODE_CARD } from '../utils/zoomSdk'; +import { SOUNDS_LOCK_MESSAGES } from '../hooks/useSounds'; 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, BREAK_ROLE, BREAK_QUICK_PICKS, DEFAULT_BREAK_SECONDS, deriveBreakRules, getDisplaySeconds, initCardImages, subscribeCardImages, agendaItemNeedsRole } 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, getZoomParticipants, OVERLAY_MODE_CARD, OVERLAY_MODE_STAGE, notifyCardImagesChanged, preloadBackgroundImages } from '../utils/zoomSdk'; -import { useClub } from '../hooks/useClub'; -import { SOUNDS_LOCK_MESSAGES, useSoundsAccess } from '../hooks/useSounds'; -import { formatTime, saveOverlayMode, saveStageClockHidden, loadStageClockHidden, saveRevealFaceWhenIdle, loadRevealFaceWhenIdle } from '@toastmaster-timer/shared'; -import { AlertTriangle, ChevronDown, ChevronUp } from 'lucide-react'; -import { trackEvent } from '../utils/posthog'; // Always on when running the dev server; in a build it takes an explicit // VITE_ENABLE_DEBUG_PANEL=true. Fail-closed on purpose: the old `!== 'false'` @@ -37,941 +32,84 @@ const PREVIEW_COLORS = [ ]; export default memo(function LiveTab() { - const { isRunning, elapsedTime, currentStatus } = useTimerTick(); const { + isRunning, + elapsedTime, + currentStatus, currentSpeaker, - startTimer, - stopTimer, - resetTimer, - setCurrentSpeaker, - updateSpeakerName, - clearActiveSpeaker, - finishCurrentSpeech, - roleRules, roleOptions, agenda, activeSpeakerId, - loadSpeakerFromAgenda, - addToAgenda, - renameAgendaSpeaker, - } = useTimer(); - const { showToast } = useToast(); - - // The role the picker starts on: Standard Speech, unless this device or its - // club removed it from the list. A onChange(e.target.checked)} - className="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 flex-shrink-0" - /> - - - {title} - {locked && - {description} - - - ); -} - -/** - * Sound settings (Pro): the card-change chime, break music, and volume. - * Opened only while the timer is stopped, so previewing music here never - * fights a running break for the speakers. - */ +/** The Sounds dialog: the Pro sound settings in a window of their own. */ export default function SoundsModal({ isOpen, onClose }) { - const settings = useSoundSettings(); - const { usable, lockReason } = useSoundsAccess(); - const { known } = useEntitlement(); - // The Upgrade link opens the purchase, which stays dark until pro is - // released. Until then the note just says sounds are part of Pro. - const { enabled: proEnabled, known: flagsKnown } = useFlag('pro'); - const [showUpgrade, setShowUpgrade] = useState(false); - const [previewing, setPreviewing] = useState(false); - - useEffect(() => () => soundEngine.stopMusic(), []); + const { lockReason } = useSoundsAccess(); if (!isOpen) return null; - const change = (setting, value) => { - updateSoundSettings({ [setting]: value }); - (window.requestIdleCallback || setTimeout)(() => trackEvent('sound_settings_changed', { setting, value })); - }; - - const togglePreview = () => { - if (previewing) { - soundEngine.stopMusic(); - setPreviewing(false); - } else { - soundEngine.startMusic(settings.track, settings.volume); - setPreviewing(true); - } - }; - const handleClose = () => { soundEngine.stopMusic(); - setPreviewing(false); onClose(); }; @@ -92,126 +32,7 @@ export default function SoundsModal({ isOpen, onClose }) { For speakers who can't watch the card while they speak, and for a livelier break.

- {lockReason && ( -

-

- )} - - {/* The tooltip sits on a wrapper: a disabled fieldset's controls get no - hover events of their own in most browsers. */} -
-
-
- change('cues', on)} - title="Chime when the card changes" - locked={!usable} - description="One soft note at green, two at yellow, three at red." - /> -
- Test: - {CUE_BUTTONS.map(({ color, label, className }) => ( - - ))} -
-
- -
- change('music', on)} - title="Music during breaks" - locked={!usable} - description="Plays while Take a Break counts down, and stops at 00:00." - /> -
- {BREAK_TRACKS.length > 1 && ( - - )} - -
-
- - -
-
- -

- Sounds play on this computer only. For the speaker to hear them, share your computer's - sound in Zoom (Share Screen, then Share sound), or in a hybrid meeting play them through the - room's speakers. -

- - {showUpgrade && ( - - setShowUpgrade(false)} source="sounds" /> - - )} +
); diff --git a/apps/zoom-app/src/components/SoundsPanel.jsx b/apps/zoom-app/src/components/SoundsPanel.jsx new file mode 100644 index 0000000..c979844 --- /dev/null +++ b/apps/zoom-app/src/components/SoundsPanel.jsx @@ -0,0 +1,196 @@ +import { useEffect, useState, lazy, Suspense } from 'react'; +import { Play, Square, Lock } from 'lucide-react'; +import { BREAK_TRACKS, updateSoundSettings } from '@toastmaster-timer/shared'; +import { useEntitlement } from '../hooks/useEntitlement'; +import { useFlag } from '../hooks/useFlag'; +import { SOUNDS_LOCK_MESSAGES, soundEngine, useSoundSettings, useSoundsAccess } from '../hooks/useSounds'; +import { trackEvent } from '../utils/posthog'; +const UpgradeModal = lazy(() => import('./UpgradeModal')); + +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' }, +]; + +function ToggleRow({ checked, onChange, title, description, locked }) { + return ( + + ); +} + +/** + * Sound settings (Pro): the card-change chime, break music, and volume. + * Opened only while the timer is stopped, so previewing music here never + * fights a running break for the speakers. + * + * The body of the Sounds dialog, without the dialog, so design B's Sounds + * sheet (plan 13) shows the same controls and the same lock. Unmounting stops + * a music preview. + */ +export default function SoundsPanel() { + const settings = useSoundSettings(); + const { usable, lockReason } = useSoundsAccess(); + const { known } = useEntitlement(); + // The Upgrade link opens the purchase, which stays dark until pro is + // released. Until then the note just says sounds are part of Pro. + const { enabled: proEnabled, known: flagsKnown } = useFlag('pro'); + const [showUpgrade, setShowUpgrade] = useState(false); + const [previewing, setPreviewing] = useState(false); + + useEffect(() => () => soundEngine.stopMusic(), []); + + const change = (setting, value) => { + updateSoundSettings({ [setting]: value }); + (window.requestIdleCallback || setTimeout)(() => trackEvent('sound_settings_changed', { setting, value })); + }; + + const togglePreview = () => { + if (previewing) { + soundEngine.stopMusic(); + setPreviewing(false); + } else { + soundEngine.startMusic(settings.track, settings.volume); + setPreviewing(true); + } + }; + + return ( + <> + {lockReason && ( +

+

+ )} + + {/* The tooltip sits on a wrapper: a disabled fieldset's controls get no + hover events of their own in most browsers. */} +
+
+
+ change('cues', on)} + title="Chime when the card changes" + locked={!usable} + description="One soft note at green, two at yellow, three at red." + /> +
+ Test: + {CUE_BUTTONS.map(({ color, label, className }) => ( + + ))} +
+
+ +
+ change('music', on)} + title="Music during breaks" + locked={!usable} + description="Plays while Take a Break counts down, and stops at 00:00." + /> +
+ {BREAK_TRACKS.length > 1 && ( + + )} + +
+
+ + +
+
+ +

+ Sounds play on this computer only. For the speaker to hear them, share your computer's + sound in Zoom (Share Screen, then Share sound), or in a hybrid meeting play them through the + room's speakers. +

+ + {showUpgrade && ( + + setShowUpgrade(false)} source="sounds" /> + + )} + + ); +} diff --git a/apps/zoom-app/src/components/TimerDisplay.jsx b/apps/zoom-app/src/components/TimerDisplay.jsx index 5253fe3..2f07cde 100644 --- a/apps/zoom-app/src/components/TimerDisplay.jsx +++ b/apps/zoom-app/src/components/TimerDisplay.jsx @@ -1,7 +1,6 @@ -import { memo, useRef, useState } from 'react'; -import { Move, Plus, Minus, Eye, EyeOff, RotateCcw } from 'lucide-react'; +import { memo, useRef } from 'react'; import { formatTime, getPhaseInfo, formatPhaseTextFor, getDisplaySeconds } from '@toastmaster-timer/shared'; -import ClubBadge, { ClubBadgeLayer } from '@toastmaster-timer/ui/ClubBadge'; +import VideoPreviewLayer from './VideoPreviewLayer'; export default memo(function TimerDisplay({ elapsedTime, @@ -27,8 +26,6 @@ export default memo(function TimerDisplay({ const displayTime = formatTime(getDisplaySeconds(elapsedTime, rules)); const containerRef = useRef(null); - const [dragging, setDragging] = useState(false); - const [draggingBadge, setDraggingBadge] = useState(false); // Background color based on status const statusColors = { @@ -53,56 +50,6 @@ export default memo(function TimerDisplay({ textShadow: '0 1px 2px rgba(0,0,0,0.5)', }; - // This box stands in for the organizer's video tile: dragging the badge - // within it places the count-up on the pushed background by the same - // fraction of the frame. Normalized coordinates make the square preview and - // the 16:9 frame agree about what "top left" means. - const positionFromEvent = (event) => { - const rect = containerRef.current.getBoundingClientRect(); - return { - x: Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)), - y: Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)), - }; - }; - - const handlePointerDown = (event) => { - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - setDragging(true); - }; - - const handlePointerMove = (event) => { - if (!dragging) return; - onReadoutPositionChange?.(positionFromEvent(event), { commit: false }); - }; - - // Pushing the repositioned frame to Zoom costs a multi-MB bridge transfer, - // so it happens once, on release — the badge itself follows every move. - const handlePointerEnd = (event) => { - if (!dragging) return; - setDragging(false); - onReadoutPositionChange?.(positionFromEvent(event), { commit: true }); - }; - - // The badge is the same gesture on the same preview, so it is the same three - // handlers against a second piece of state rather than a second control. - const handleBadgePointerDown = (event) => { - event.preventDefault(); - event.currentTarget.setPointerCapture(event.pointerId); - setDraggingBadge(true); - }; - - const handleBadgePointerMove = (event) => { - if (!draggingBadge) return; - onBadgePlacementChange?.(positionFromEvent(event), { commit: false }); - }; - - const handleBadgePointerEnd = (event) => { - if (!draggingBadge) return; - setDraggingBadge(false); - onBadgePlacementChange?.(positionFromEvent(event), { commit: true }); - }; - return (
)} - {/* The club's badge, where it will land on the video. Dragged and scaled - with the same gesture as the count-up, and hidden independently of it: - an organizer who switched the clock off has not asked to take their - club's name off the card. - - The move stays on this device — the club's placement is the club's - default, and where a badge sits relative to someone's own face is not - something to push onto every other timer. "Reset to club position" - puts it back. */} - {clubKit && badgePlacement && ( - -
- - {!draggingBadge && ( -
- {badgePlacement.visible && ( - <> - - - - )} - - {badgeMoved && ( - - )} -
- )} -
-
- )} - - {/* Where the count-up sits on the video other participants see. Only the - video modes show it: the full-screen timer pushes no frame to drag - anything on. - - No data-tooltip anywhere in this cluster, deliberately: the tooltip - pseudo-element repaints above the chip while it moves under pointer - capture, which smeared black trails across the preview. */} - {readoutPosition && ( -
- - {/* Hidden while dragging: the chip is the only thing that should - follow the pointer, and stray buttons under it catch the drop. - Size controls sit to the chip's left and visibility to its right - — unless the chip is near the left edge (where it starts, under - the logo), in which case everything moves right rather than - overflowing the card. Always out of flow, so nothing shifts the - chip off its anchor. */} - {!dragging && (() => { - const nearLeftEdge = readoutPosition.x < 0.35; - const sizeButtons = readoutVisible && ( - <> - - - - ); - const eyeButton = ( - - ); - return nearLeftEdge ? ( -
- {sizeButtons} - {eyeButton} -
- ) : ( - <> - {readoutVisible && ( -
- {sizeButtons} -
- )} -
- {eyeButton} -
- - ); - })()} -
- )} + {/* The count-up chip and the club badge, each dragged, sized and hidden + right on the preview. */} +
); }); diff --git a/apps/zoom-app/src/components/TimerSounds.test.jsx b/apps/zoom-app/src/components/TimerSounds.test.jsx index b65687a..e83c174 100644 --- a/apps/zoom-app/src/components/TimerSounds.test.jsx +++ b/apps/zoom-app/src/components/TimerSounds.test.jsx @@ -11,7 +11,8 @@ import { } from '@toastmaster-timer/shared'; import TimerSounds from './TimerSounds'; import SoundsModal from './SoundsModal'; -import { soundEngine } from '../hooks/useSounds'; +import SoundsPanel from './SoundsPanel'; +import { SOUNDS_LOCK_MESSAGES, soundEngine } from '../hooks/useSounds'; // Stubbed rather than imported: the real module pulls in @zoom/appssdk, which // hangs vitest under jsdom. @@ -236,3 +237,25 @@ describe('the Sounds modal', () => { expect(onClose).toHaveBeenCalled(); }); }); + +// The dialog's body on its own, as design B's Sounds sheet shows it. +describe('the Sounds panel', () => { + it('shows the same lock without the dialog around it', () => { + setFlags({ sounds: true, pro: false }); + setEntitlement({ plan: 'free' }); + render(); + expect(screen.getByText(SOUNDS_LOCK_MESSAGES.unreleased)).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: /chime when the card changes/i })).toBeDisabled(); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('stops a music preview when it goes away', () => { + setFlags({ sounds: true, pro: true }); + setEntitlement(PRO); + const view = render(); + fireEvent.click(screen.getByRole('button', { name: /preview music/i })); + soundEngine.stopMusic.mockClear(); + view.unmount(); + expect(soundEngine.stopMusic).toHaveBeenCalled(); + }); +}); diff --git a/apps/zoom-app/src/components/VideoPreviewLayer.jsx b/apps/zoom-app/src/components/VideoPreviewLayer.jsx new file mode 100644 index 0000000..18f6824 --- /dev/null +++ b/apps/zoom-app/src/components/VideoPreviewLayer.jsx @@ -0,0 +1,275 @@ +import { useState } from 'react'; +import { Move, Plus, Minus, Eye, EyeOff, RotateCcw } from 'lucide-react'; +import ClubBadge, { ClubBadgeLayer } from '@toastmaster-timer/ui/ClubBadge'; + +/** + * What the organizer moves around on the timer preview: the count-up chip + * participants see on the video, and the club's badge. Each is dragged into + * place, sized with − / + and hidden with the eye, and the move reaches Zoom + * only on release. + * + * Its own component so today's square preview (TimerDisplay) and design B's + * 16:9 card (plan 13) share one implementation of the gesture. Positions are + * fractions of `containerRef`'s box, which is why the two shapes agree about + * where things land on the 16:9 frame. + * + * Rendered inside the positioned preview box; both pieces are absolutely + * placed within it. + */ +export default function VideoPreviewLayer({ + containerRef, + displayTime, + readoutPosition, + onReadoutPositionChange, + readoutVisible, + onToggleReadoutVisible, + onAdjustReadoutScale, + clubKit, + badgePlacement, + badgeMoved, + onBadgePlacementChange, + onAdjustBadgeScale, + onToggleBadgeVisible, + onResetBadgePlacement, +}) { + const [dragging, setDragging] = useState(false); + const [draggingBadge, setDraggingBadge] = useState(false); + + // This box stands in for the organizer's video tile: dragging the badge + // within it places the count-up on the pushed background by the same + // fraction of the frame. Normalized coordinates make the square preview and + // the 16:9 frame agree about what "top left" means. + const positionFromEvent = (event) => { + const rect = containerRef.current.getBoundingClientRect(); + return { + x: Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)), + y: Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)), + }; + }; + + const handlePointerDown = (event) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + setDragging(true); + }; + + const handlePointerMove = (event) => { + if (!dragging) return; + onReadoutPositionChange?.(positionFromEvent(event), { commit: false }); + }; + + // Pushing the repositioned frame to Zoom costs a multi-MB bridge transfer, + // so it happens once, on release — the badge itself follows every move. + const handlePointerEnd = (event) => { + if (!dragging) return; + setDragging(false); + onReadoutPositionChange?.(positionFromEvent(event), { commit: true }); + }; + + // The badge is the same gesture on the same preview, so it is the same three + // handlers against a second piece of state rather than a second control. + const handleBadgePointerDown = (event) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + setDraggingBadge(true); + }; + + const handleBadgePointerMove = (event) => { + if (!draggingBadge) return; + onBadgePlacementChange?.(positionFromEvent(event), { commit: false }); + }; + + const handleBadgePointerEnd = (event) => { + if (!draggingBadge) return; + setDraggingBadge(false); + onBadgePlacementChange?.(positionFromEvent(event), { commit: true }); + }; + + return ( + <> + {/* The club's badge, where it will land on the video. Dragged and scaled + with the same gesture as the count-up, and hidden independently of it: + an organizer who switched the clock off has not asked to take their + club's name off the card. + + The move stays on this device — the club's placement is the club's + default, and where a badge sits relative to someone's own face is not + something to push onto every other timer. "Reset to club position" + puts it back. */} + {clubKit && badgePlacement && ( + +
+ + {!draggingBadge && ( +
+ {badgePlacement.visible && ( + <> + + + + )} + + {badgeMoved && ( + + )} +
+ )} +
+
+ )} + + {/* Where the count-up sits on the video other participants see. Only the + video modes show it: the full-screen timer pushes no frame to drag + anything on. + + No data-tooltip anywhere in this cluster, deliberately: the tooltip + pseudo-element repaints above the chip while it moves under pointer + capture, which smeared black trails across the preview. */} + {readoutPosition && ( +
+ + {/* Hidden while dragging: the chip is the only thing that should + follow the pointer, and stray buttons under it catch the drop. + Size controls sit to the chip's left and visibility to its right + — unless the chip is near the left edge (where it starts, under + the logo), in which case everything moves right rather than + overflowing the card. Always out of flow, so nothing shifts the + chip off its anchor. */} + {!dragging && (() => { + const nearLeftEdge = readoutPosition.x < 0.35; + const sizeButtons = readoutVisible && ( + <> + + + + ); + const eyeButton = ( + + ); + return nearLeftEdge ? ( +
+ {sizeButtons} + {eyeButton} +
+ ) : ( + <> + {readoutVisible && ( +
+ {sizeButtons} +
+ )} +
+ {eyeButton} +
+ + ); + })()} +
+ )} + + ); +} diff --git a/apps/zoom-app/src/components/VideoPreviewLayer.test.jsx b/apps/zoom-app/src/components/VideoPreviewLayer.test.jsx new file mode 100644 index 0000000..b8a584e --- /dev/null +++ b/apps/zoom-app/src/components/VideoPreviewLayer.test.jsx @@ -0,0 +1,120 @@ +import '@testing-library/jest-dom'; +import { useRef } from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import VideoPreviewLayer from './VideoPreviewLayer'; + +const KIT = { name: 'Claude Test Club', primaryColor: '#0f766e', logoUrl: null, showOnCards: true }; + +/** A 400×225 preview box, so pointer positions map to round fractions. */ +function Harness(props) { + const ref = useRef(null); + return ( +
{ + ref.current = node; + if (node) node.getBoundingClientRect = () => ({ left: 0, top: 0, width: 400, height: 225, right: 400, bottom: 225 }); + }} + style={{ position: 'relative' }} + > + +
+ ); +} + +const chip = () => screen.getByRole('button', { name: 'Drag to place the count-up participants see on your video' }); +const badge = () => screen.getByRole('button', { name: "Drag to place your club's badge on your video" }); + +beforeAll(() => { + // jsdom has no pointer capture. + HTMLElement.prototype.setPointerCapture = () => {}; +}); + +describe('VideoPreviewLayer', () => { + it('follows a count-up drag live and hands it to Zoom once, on release', () => { + const onReadoutPositionChange = vi.fn(); + render(); + + fireEvent.pointerDown(chip(), { pointerId: 1, clientX: 200, clientY: 112 }); + fireEvent.pointerMove(chip(), { pointerId: 1, clientX: 100, clientY: 45 }); + fireEvent.pointerMove(chip(), { pointerId: 1, clientX: 300, clientY: 180 }); + fireEvent.pointerUp(chip(), { pointerId: 1, clientX: 300, clientY: 180 }); + + const commits = onReadoutPositionChange.mock.calls.filter(([, opts]) => opts.commit); + expect(commits).toEqual([[{ x: 0.75, y: 0.8 }, { commit: true }]]); + expect(onReadoutPositionChange).toHaveBeenCalledWith({ x: 0.25, y: 0.2 }, { commit: false }); + }); + + it('clamps a drag that leaves the preview to its edge', () => { + const onReadoutPositionChange = vi.fn(); + render(); + + fireEvent.pointerDown(chip(), { pointerId: 1 }); + fireEvent.pointerUp(chip(), { pointerId: 1, clientX: -50, clientY: 900 }); + expect(onReadoutPositionChange).toHaveBeenLastCalledWith({ x: 0, y: 1 }, { commit: true }); + }); + + it('hides the size and eye buttons while the chip is being dragged', () => { + render( {}} />); + expect(screen.getByRole('button', { name: 'Make the count-up larger' })).toBeInTheDocument(); + + fireEvent.pointerDown(chip(), { pointerId: 1 }); + expect(screen.queryByRole('button', { name: 'Make the count-up larger' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Hide the count-up from your video' })).not.toBeInTheDocument(); + }); + + it('sizes and hides the count-up, and offers only "show" once it is hidden', () => { + const onAdjustReadoutScale = vi.fn(); + const onToggleReadoutVisible = vi.fn(); + const { rerender } = render( + + ); + fireEvent.click(screen.getByRole('button', { name: 'Make the count-up smaller' })); + fireEvent.click(screen.getByRole('button', { name: 'Make the count-up larger' })); + fireEvent.click(screen.getByRole('button', { name: 'Hide the count-up from your video' })); + expect(onAdjustReadoutScale.mock.calls).toEqual([[-1], [1]]); + expect(onToggleReadoutVisible).toHaveBeenCalledTimes(1); + + rerender(); + expect(screen.queryByRole('button', { name: 'Make the count-up larger' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Show the count-up on your video' })).toBeInTheDocument(); + }); + + it('draws no count-up where there is no frame to place it on', () => { + render(); + expect(screen.queryByRole('button', { name: /count-up/ })).not.toBeInTheDocument(); + }); + + it('drags, sizes and hides the club badge, and offers the reset only once it was moved', () => { + const onBadgePlacementChange = vi.fn(); + const onAdjustBadgeScale = vi.fn(); + const onToggleBadgeVisible = vi.fn(); + const onResetBadgePlacement = vi.fn(); + const placement = { x: 0.8, y: 0.12, scale: 0.12, visible: true }; + const props = { clubKit: KIT, badgePlacement: placement, onBadgePlacementChange, onAdjustBadgeScale, onToggleBadgeVisible, onResetBadgePlacement }; + const { rerender } = render(); + + expect(screen.queryByRole('button', { name: 'Reset to club position' })).not.toBeInTheDocument(); + fireEvent.pointerDown(badge(), { pointerId: 2 }); + fireEvent.pointerUp(badge(), { pointerId: 2, clientX: 40, clientY: 45 }); + expect(onBadgePlacementChange).toHaveBeenLastCalledWith({ x: 0.1, y: 0.2 }, { commit: true }); + + fireEvent.click(screen.getByRole('button', { name: 'Make the club badge larger' })); + fireEvent.click(screen.getByRole('button', { name: "Hide your club's badge on your video" })); + expect(onAdjustBadgeScale).toHaveBeenCalledWith(1); + expect(onToggleBadgeVisible).toHaveBeenCalledTimes(1); + + rerender(); + fireEvent.click(screen.getByRole('button', { name: 'Reset to club position' })); + expect(onResetBadgePlacement).toHaveBeenCalledTimes(1); + }); + + it('draws no badge without a club kit', () => { + render(); + expect(screen.queryByRole('button', { name: /club's badge/ })).not.toBeInTheDocument(); + }); +}); diff --git a/apps/zoom-app/src/hooks/useLiveTimer.js b/apps/zoom-app/src/hooks/useLiveTimer.js new file mode 100644 index 0000000..60672bd --- /dev/null +++ b/apps/zoom-app/src/hooks/useLiveTimer.js @@ -0,0 +1,1038 @@ +import { useState, useEffect, useRef, useMemo } from 'react'; +import { useTimer, useTimerTick } from '../context/TimerContext'; +import { useToast } from '../context/ToastContext'; +import { DEFAULT_ROLE_RULES, DEFAULT_CUSTOM_RULES, loadTimeInputMode, BREAK_ROLE, DEFAULT_BREAK_SECONDS, deriveBreakRules, getDisplaySeconds, initCardImages, subscribeCardImages, agendaItemNeedsRole, formatTime, saveOverlayMode, saveStageClockHidden, loadStageClockHidden, saveRevealFaceWhenIdle, loadRevealFaceWhenIdle } 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, getZoomParticipants, OVERLAY_MODE_STAGE, notifyCardImagesChanged, preloadBackgroundImages } from '../utils/zoomSdk'; +import { useClub } from './useClub'; +import { useSoundsAccess } from './useSounds'; +import { trackEvent } from '../utils/posthog'; + +/** + * Everything the timer tab does, apart from how it looks: the speaker and role + * being timed, Start / Stop / Continue / Reset / Finish, and every conversation + * with Zoom about the organizer's video (the overlay pushes, video on/off, the + * display mode, share and pop-out, where the count-up and the club badge sit). + * + * Lifted out of LiveTab unchanged so the redesigned panel (design B, plan 13) + * can draw the same timer without a second copy of the video logic. Whichever + * view calls it owns the overlay effect, so exactly one view may call it at a + * time: two would push to Zoom and poll the SDK twice. + * + * @returns {Object} state and handlers for a timer view + */ +export function useLiveTimer() { + const { isRunning, elapsedTime, currentStatus } = useTimerTick(); + const { + currentSpeaker, + startTimer, + stopTimer, + resetTimer, + setCurrentSpeaker, + updateSpeakerName, + clearActiveSpeaker, + finishCurrentSpeech, + roleRules, + roleOptions, + agenda, + activeSpeakerId, + loadSpeakerFromAgenda, + addToAgenda, + renameAgendaSpeaker, + } = useTimer(); + const { showToast } = useToast(); + + // The role the picker starts on: Standard Speech, unless this device or its + // club removed it from the list. A