Architect for Cinema Mode / Cinema OS in Raamattu Nyt: CinemaShell, verse audio-sync & cues,
auto-advance, dual-track audio (Bible+music, ElevenLabs TTS), Ken Burns, Discipleship Cinema
(task orchestration, Nyt Kooste, verse memory quizzes, TanaanPage), curated/scrolling grand
plans, Info Cinema (Q&A / topic info-palaset, snap-scroll), Topic Cinema (aihe-cinema), and
realtime multiplayer Prayer Rooms (rukoushuone, PTT, WebRTC). Use for any cinema / voice /
audio-sync, discipleship-tila, info-palaset, or prayer-room work.
Cinema Voice Architect
Expert skill for Cinema Mode and audio/voice implementation.
Quick Reference
Base Layer (CinemaShell)
Component
Location
CinemaShell
src/components/cinema/CinemaShell.tsx
Cinema Background
src/components/cinema/CinemaBackground.tsx
Background Music Picker
src/components/cinema/BackgroundMusicPicker.tsx
Background Visual Picker
src/components/cinema/BackgroundVisualPicker.tsx
Cinema Preferences Hook
src/hooks/useCinemaPreferences.ts
Cinema Fullscreen Hook
src/hooks/useCinemaFullscreen.ts
Cinema Audio Hook
src/hooks/useCinemaAudio.ts
CinemaShellContext (shell ctx as React context for shell-less apps)
src/components/cinema/CinemaShellContext.ts
Cinema OS / Command Center (orchestration layer)
Component / Module
Location
Design doc (read first)
Docs/cinema/CINEMA-OS.md
Types (Intent / CinemaApp / CinemaNav)
src/cinema-os/types.ts
Navigation reducer (stack)
src/cinema-os/navigation.ts
Registry + deep-link translation
src/cinema-os/registry.ts
Web-reitti → intent (webRouteToIntent; sisällön route-linkit pysyvät cinemassa)
src/cinema-os/routeIntents.ts
App registration (all apps)
src/cinema-os/cinemaApps.ts
CinemaOSProvider (stack + history + 2-level back)
src/cinema-os/CinemaOSProvider.tsx
CinemaHost (the ONE persistent shell)
src/cinema-os/CinemaHost.tsx
Contexts + useCinemaNav / useCinemaBackHandler
src/cinema-os/context.ts
useCinemaShell (re-export)
src/cinema-os/useCinemaShell.ts
Session history (localStorage)
src/cinema-os/history.ts
Route element (/cinema-os/*)
src/cinema-os/CinemaOSRoot.tsx
Shared settings/music sheets
src/cinema-os/CinemaOSSheets.tsx
Shared settings-sheet wiring (single source for CinemaOSSheets AND CinemaReaderScreen). ⚙ sheet = bg+textsize+completion+speed; Raamatun versio + lukuääni (CinemaVersionVoiceSection) are split to the 🔊 music popover (audio side)
src/components/cinema/useCinemaSettingsProps.ts
Cinema OS help overlay (i-icon → "what is Cinema OS")
src/cinema-os/CinemaOSHelpOverlay.tsx
CinemaOSNavCluster (top-left Back + Command Center pill; shared by CinemaOSChrome + ownsChrome apps like the prayer room)
TopicVerseCinema (1-verse carousel via @raamattu-nyt/cinema-reader embedded)
src/components/cinema/topic/TopicVerseCinema.tsx
TopicVersesCinemaView (default verses step: full-width single-verse carousel, useVerseCues+useAutoAdvance, ←/→=verse, ↑/↓ bubble to flow=card. Renders a FIXED portaled bottom cluster (snapshot strip + segmented progress + consolidated control bar, one rounded pill: ◀prev · 📖Lue (openChapter) · 🎬Cinema (Film icon → 1-verse jae-cinema, only if onOpenVerseCinema) · 🎓Discipleship popover (amber GraduationCap → CinemaVerseActions menu: Pohdittavaksi/Lisää koosteeseen/Muistiinpano/Harjoittele jae/Tutki jaetta) · ▶/⏸ (amber play/pause) · ☰Kaikki (→onShowAllVerses, only if set) · ▶next · │ · ⚙🔊ℹ️) via getCinemaPortalContainer, only while isActive. Header is ONLY the jae row (book-ikoni · "jakeet" · versesShown/referenceCount); the topic name + card N/M live in TopicFlow's single global "AIHE: N/M" title, not repeated here)
TopicVersesView (OLD grid model — now the list option; versePresentation toggle)
src/components/cinema/topic/TopicVersesView.tsx
useTopicVersePresentation (localStorage cinema/list, default cinema; toggle in settings sheet)
src/hooks/topic/useTopicVersePresentation.ts
CinemaVersePreviewPopup (Haku>Jakeet result → verse-in-chapter popup, per-verse jae-cinema)
src/components/cinema/CinemaVersePreviewPopup.tsx
Topic cinema is a 4th CinemaShell consumer (its own TopicFlow over a discriminated TopicCinemaStep[], NOT InfoFlow), reusing useSnapScrollSteps, CinemaShell, and InfoView variant="scroll" for info-block cards; background music only, no verse-TTS.
For full details — internal view stack (flow → chapter → verse) + portal/ESC-pino gotchas, CinemaVerseActions, TopicChapterReader picker gotchas, the TWO presentation models (cinema/list) + fixed bottom cluster + TopicFlow.suspended, verse-range/verse-order rules, get_chapter_bundle book normalization, TopicVerseCinema auto-advance/start-verse, the single global "AIHE: <nimi> N/M" title, the info-palanen card + jae-snapshot popup + author link, the author cinema-app, and the wrap-up layout/continuation paths: See references/topic-cinema.md.
CinemaShell (base layer ~438 lines)
│ Fullscreen, background visuals (Ken Burns), background music,
│ preferences, visual/music pickers, keyboard shortcuts (B/N/V/M)
│ Props: isOpen, onClose, title?, hideControls?, dimControls?
│ Exports: CinemaShellContext (render props for children)
│
├── CinemaReaderScreen (content layer ~1033 lines)
│ │ Verse fetching/mapping, Bible audio, auto-advance, audio sync,
│ │ completion overlay. Uses CinemaShell as wrapper via render props.
│ │ Modes: "chapter" | "verseList" | "summaryItems"
│ │
│ └── useDiscipleshipOrchestration (task queue ~792 lines)
│ │ Task queue, completion persistence, quiz insertion,
│ │ transition overlays, verse bar, kooste, prayer messages
│ │
│ └── Overlay Components
│ ├── DiscipleshipTaskSelector (pick tasks)
│ ├── DiscipleshipInlineTask (prayer/practice/quiz)
│ ├── DiscipleshipTransitionOverlay (between tasks)
│ └── DiscipleshipVerseBar (note/share on pause)
│
├── CuratedPlanCinema (program mode ~66 lines)
│ │ Wrapper: loads grand plan mini_tasks + progression_type via useCuratedPlanCinema.
│ │ CinemaShell with hideControls. Routes to flow based on progression_type:
│ │ progressionType === "scrolling" ? ScrollingPlanFlow : CuratedPlanFlow
│ │ On all-tasks-complete: calls complete_grand_plan RPC + invalidates ["grand-plans"].
│ │
│ ├── CuratedPlanFlow (continuous / linear ~133 lines)
│ │ │ One mini-task card at a time, AnimatePresence crossfade.
│ │ │ Scrim bg-black/40 + card bg-black/60 backdrop-blur-md.
│ │ │ completedIds Set; backward jumps un-complete the target.
│ │ │ Choice routing: next | jump_to_sort_order.
│ │ │
│ │ └── MiniTaskView (step UI ~223 lines)
│ │ SVG circular countdown timer, pause via DiscipleshipPomodoroButton,
│ │ JATKA hidden when choices exist (choice-to-advance).
│ │
│ └── ScrollingPlanFlow (scrolling ~336 lines)
│ Snap-scroll all steps on one page, numbered progress pills (click-to-jump),
│ IntersectionObserver (threshold 0.4) tracks active step,
│ per-step auto-advance timer (duration_seconds; 0/missing = off),
│ user-scroll detection pauses auto-advance 1.5s.
│ Inline StepContent (NOT MiniTaskView) — no countdown ring, no completedIds.
│ See references/curated-plans.md for full details.
│
├── InfoCinema (Q&A / topic info-palaset ~121 lines)
│ │ CinemaShell with hideControls; renders InfoFlow from parent-loaded
│ │ InfoBlocks. Mirrors CuratedPlanCinema. See references/info-cinema.md.
│ │
│ └── InfoFlow (one-page snap-scroll, card per block ~350 lines)
│ Uses useSnapScrollSteps (SHARED mechanics). Left progress rail +
│ numbered pills, per-card auto-advance, prayer-room-style bottom bar.
│ Optional finalSlide (CinemaQuestionWrapUp). key={contentKey} remount.
│
├── PrayerRoomScreen (prayer room consumer ~499 lines)
│ │ CinemaShell with hideControls; owns prayer/verse state, realtime sync,
│ │ PTT, WebRTC audio mesh, invitations. Route: /rukoushuone.
│ │ See references/prayer-room.md for full details.
│ │
│ └── Parallel runtime systems (scoped to config.id)
│ ├── usePrayerRoomSync (presence + host state broadcast, debounced 100ms)
│ ├── usePushToTalk (single-speaker floor + FIFO hand queue)
│ └── useWebRTCAudio (STUN-only audio mesh, 2–5 users)
│
└── Future: Meditation, etc.
└── CinemaShell + custom content
Audio Pipeline:
generate-audio Edge Function → ElevenLabs API (with timestamps)
→ audio_assets table (hash-cached) → audio_cues table (verse timing)
Audio Split
CinemaShell and CinemaReaderScreen both use useCinemaAudio but for different purposes:
CinemaShell: bibleAudioUrl: null (music-only)
CinemaReaderScreen: backgroundMusicUrl: null (Bible audio-only, music from CinemaShell context)
Cinema OS / Command Center
Orchestration layer that wraps the individual cinemas in one persistent
CinemaShell so the user moves Topic → Search → Question → Curated as a single
continuous session (music/background never reset — "älä poistu Cinemasta").
Route /cinema-os (entry card atop the Tänään page). Read
Docs/cinema/CINEMA-OS.md first.
Three separated concerns: Intent (serializable {type,payload}) →
Registry (intent.type → CinemaApp, declarative, no host branches) →
Navigation (stack reducer + history). Apps are shell-less content that
consume useCinemaShell(); they never render a CinemaShell.
Search → Topic chaining: SearchCinemaApp picks a topic →
nav.launch({type:"topic", payload:{slug}}) → OS pushes Topic Cinema; back
returns to Search. QuestionCinemaApp's info-block CTA →
nav.launch({type:"curated", ...}). Apps special-case nothing — they emit intents.
Invariants (load-bearing — don't break these)
Apps are shell-less. Consume useCinemaShell() (from
components/cinema/CinemaShellContext). The standalone wrappers
(TopicCinema, InfoCinema, CuratedPlanCinema) are LEFT UNTOUCHED for their
existing launch buttons; the OS apps (src/cinema-os/apps/*) are parallel
shell-less mirrors. Don't merge them yet.
Intent.payload = plain serializable data only (slug/id/query) — never
functions/objects. One rule → history + localStorage + deep-link + (Phase 2)
DB are the same representation. Derive auth/version/i18n INSIDE the app, not
in the payload.
Two-level back: apps with internal layers (Topic's flow→chapter→verse)
register useCinemaBackHandler(fn); the host's nav.back() (and ESC, shell X,
fullscreen-exit) runs interceptors LIFO first, then pops the OS stack.
Generalizes TopicCinema's old popOrClose. Question/Curated have NO internal
layers → no interceptor.
Only the top frame is mounted (Phase 1).nav.replace PRESERVES the frame
key (in-place payload update, no remount) — Search uses it to persist
query/tab so the topic round-trip restores them. Provider URL-sync is
path-based; replace does NOT record history. Keep-alive is a Phase 2 opt-in
(CinemaApp.keepAlive, default false, never Reader/PrayerRoom).
Only "destinations" are intents. Topic's chapter/verse stay internal
layers (transient, not URL-addressable). Only top-level intents get URLs.
Phase 1 base is /cinema-os so the /cinema landing (CinemaLandingPage) is
untouched. CinemaShell already portals to document.body (universal overlay
root) — apps must NOT add their own portal.
Phase 2 (deferred)
DB history (cinema_sessions/cinema_events), opt-in keep-alive,
browser-back↔OS-back, verses⇄topics swipe remain deferred.
Discipleship-tila is now an in-shell app (discipleship, done — the
previously-deferred CinemaReaderScreen migration). CinemaReaderScreen is
dual-mode: the embedded flag makes its outer wrapper skip its own
CinemaPreferencesProvider AND its CinemaShell, and CinemaReaderScreenContent
consumes useCinemaShell() instead of the render-prop ctx (the music-ref-sync just
reads the same CinemaShellContext). DiscipleshipCinemaApp feeds it
useDiscipleshipTasks() with no autoStart → full useDiscipleshipOrchestration
flow (selector → reading + prayer + practice + memory quiz + Nyt Kösete). ownsChrome: true (CinemaReader ControlBar + overlays replace OS pills; exit/ESC → nav.back,
completion → nav.home); heavy engine/audio mount-bound (never keep-alive). Standalone
/tanaan launch (TanaanModals) unchanged — it omits embedded. Same dual-mode +
ownsChrome template as the prayer room.
Kooste (summary) is an in-shell app too (summary, done). SummaryCinemaApp
resolves {summaryId?} (else active via useActiveSummary) → fetchSummaryDetail
(pages/summary/summaryDetailLoader) → buildCinemaItems (pages/summary/SummaryCinemaMode,
exported pure fns) → dual-mode CinemaReaderScreen embedded mode="summaryItems".
ownsChrome:true. Launched from the SearchCinemaApp "Muut" tab (Koosteet result)
and a Command Center "Kooste" tile. SearchCinemaApp now has 4 tabs — Jakeet, Aiheet,
Kysymykset (usePublishedQuestionSearch → question intent), Muut
(search_user_content RPC; only Koosteet links → summary, rest read-only). Strongs
is intentionally absent (no cinema target → per the "no link for non-cinema" rule).
The Jakeet tab is NOT an auto-carousel by default — it mirrors the base /search page.
Data comes from useUnifiedSearch (NOT the old useVersesSearch) → verses
(match_rank/is_word_form) + topicalVerses. Hits are ordered literal-first then
inflected (splitVersesByRank drops rank-5 fuzzy; compareByRankThenLocation within
each group). Classification = isInflectedHit(v, query): single-word query → inflected
iff the verse text lacks the query as an exact token (Finnish FTS returns inflections as
rank-1 WITHOUT is_word_form, so the rank/flag alone misses them); multi-word → falls back
to is_word_form || match_rank>=2. Inflected hits carry a TAIVUTUS badge. The
"Taivutuksin" filter uses exact token match (NOT startsWith — the base form would
otherwise match all its inflections and filter nothing).
This orderedVerses drives the whole cinema verse search (list + preview + "Aja"
carousel). Two filter dropdowns ("Kirjoittain" = books from results; "Taivutuksin" =
extractWordForms, EXPORTED from VerseFilterBar) are inline (NOT Radix Select → stay
visible in fullscreen), and live inside a framed RAJAUKSET block (corner tag,
verse-card width + border classes, amber when a filter is on, X Tyhjennä to clear all;
FilterDropdown.tsx). Choosing an option closes the dropdown and the chosen value shows
in the collapsed trigger — the web VerseFilterBar mirrors this interaction. The frame
must NOT get backdrop-blur: it creates a stacking context that traps the dropdown's
z-[10002] panel inside it, so the verse cards cover the open menu (raising z-index does
not help). Pagination 16/page; below it the
"Aiheeseen liittyvät jakeet" panel (topicalVerses deduped vs shown ids, VIA-topic
tag) — both click → CinemaVersePreviewPopup. Cards = wrapping dark boxes (flex flex-wrap); the snippet uses a cinema-local cinemaSnippet (match-LEADING, small
lead) NOT the wide-page snippetAroundMatch (preContext 60) — in the narrow w-[300px]line-clamp-2 card the wide pre-context pushed the highlight past the 2 visible lines;
leading "…" dropped at a clean word boundary. arrow keys rove the current page
(column count from DOM offsetTop); click/ENTER opens the preview (found verse in chapter
context, search term highlighted via the query prop + buildHighlightRegex,
per-verse Film button → TopicVerseCinema). "Aja jae-cinema" runs the full
carousel start→finish. Internal layers (list → preview → verseCinema, plus runAll) +
useCinemaBackHandler — same model as ReadingCinemaApp, NO new app.
Prayer Room is now an in-shell app (prayer-room, done — was previously a
separate-destination exclusion). PrayerRoomScreen is dual-mode: an
embedded flag switches between mounting its own CinemaShell (standalone
/rukoushuone) and consuming useCinemaShell() (under the host). It owns its
chrome (ownsChrome: true on the registry entry → CinemaOSChrome returns null
for it; PrayerRoomHeader/BottomBar replace the OS pills, header X = nav.back,
ESC closes an open invite/setup modal first via useCinemaBackHandler). Payload
{roomId?/startCalendarId?/create?} is resolved by PrayerRoomApp
(usePrayerRooms + resolveTodayPrayerRoomConfig/normalizeLoadedRoom); setup is
an internal layer. Realtime/PTT/WebRTC are mount-bound (never keep-alive) —
leaving the app leaves the room (channel removeChannel). The pattern (dual-mode
ownsChrome) is the template for migrating any other own-shell feature
(e.g. the deferred Reader).
Cinema Mode Components
CinemaShell
Reusable base layer at src/components/cinema/CinemaShell.tsx (~438 lines):
// Provides:// 1. Fullscreen modal shell (useCinemaFullscreen)// 2. Animated background visuals (CinemaBackground + GSAP Ken Burns)// 3. Background music (useCinemaAudio, music only)// 4. User preferences (useCinemaPreferences)// 5. Visual/music pickers + keyboard shortcuts (B/N/V/M)// 6. Render props: CinemaShellContext (audioState, selectedTrack, preferences, toggleMusic, etc.)// 7. dimControls prop to fade controls when overlays active
CinemaReaderScreen
Content layer at src/features/cinema/CinemaReaderScreen.tsx (~1033 lines):
// Wraps content in CinemaShell, adds:// 1. Map BundleVerse → CinemaVerse for cinema-reader package// 2. Bible audio playback (separate from CinemaShell's music)// 3. Handle verse navigation (index state)// 4. Auto-advance (WPM-based or audio cue-based)// 5. Audio sync (manual nav → immediate audio-seek to that verse, no prompt)// 6. Discipleship overlay rendering (delegates logic to useDiscipleshipOrchestration)// 7. Compact mode for phones — passes compact + sheet callbacks to CinemaReader
Mobile UI (Compact Mode)
On phones (useBreakpoint().isPhone || isCozy, <768px) Cinema Mode collapses the desktop two-row control panel + top-left HUD pills into a single row + two icon-triggered bottom sheets, freeing ~90px for the verse text.
Dashboard fix: get_today_dashboard checks completed_at::date = CURRENT_DATE for completion-type plans (not current_day which already advanced)
Client-side fix: useTodayTasks checks completed_days.includes(current_day - 1) for completion-type plans as fallback
Query invalidation: Must invalidate ["today-dashboard"], ["reading-plan-streaks"], AND ["user-reading-plans"]
Reading Plan Memory Quiz
useReadingPlanQuiz generates a 3-verse quiz after reading plan completion
(2 plan verses + 1 decoy). Selection priority: user-marked refs
(marked_refs in quiz task metadata, from nytKoosteRefsRef) → preferred
books (NT, Psalms, Proverbs) → any plan verse. Full algorithm:
references/discipleship-cinema.md.
Tanaan Page & DiscipleshipLandingPage
/tanaan sections (TodayTaskList, TomorrowTasksSection,
PermanentTasksSection, RecurringTasksSection, PracticeActivationCard)
and the /opetuslapseus marketing landing page are documented in
references/discipleship-cinema.md. Locations: Quick Reference → Discipleship
Layer / Pages.
Curated Plans (Grand Plans) & Progression Modes
Curated grand plans are a second CinemaShell consumer. The wrapper CuratedPlanCinema routes to one of two flow components based on the plan's progression_type, then renders inside CinemaShell hideControls.
handleClose → onClose(), then if completedRef && completionRedirectUrl → navigate(url) after 300ms
Critical Gotchas
continuous and linear are identical in code. Only "scrolling" branches — the other two both fall through to CuratedPlanFlow. Branch INSIDE the flow if you need them to differ, don't split the router.
ScrollingPlanFlow reimplements step UI. Inline StepContent is a simpler cousin of MiniTaskView (no timer, no pause). Behavioral changes to MiniTaskView do NOT propagate — fix both or refactor to share.
Snap-scroll mechanics are now extracted to useSnapScrollSteps, but ScrollingPlanFlow has NOT adopted it.src/hooks/useSnapScrollSteps.ts (active-index tracking, jump, user-scroll detection, per-step auto-advance) was extracted from ScrollingPlanFlow's proven behaviour and is used by InfoFlow. ScrollingPlanFlow still carries its own duplicate inline IntersectionObserver + timer. A snap-scroll fix in one does not reach the other — prefer migrating ScrollingPlanFlow onto the hook over editing both. Tuning constants (0.4 threshold, 1500ms user-scroll debounce) are deliberately matched; don't "clean up" either copy.
Completion order is load-bearing.completedRef.current = true must run BEFORE the await complete_grand_plan RPC — flow calls onAllTasksCompleted then onClose synchronously, and handleClose reads the ref immediately.
Two RPCs on mount.useCuratedPlanCinema fires get_grand_plan_items AND get_user_grand_plans (the latter only for progression_type). Consider folding progression_type into items if you refactor.
Default progression falls back to continuous. When the user isn't a member yet, get_user_grand_plans has no match → silent continuous default. Worth checking first when a plan "won't scroll".
ScrollingPlanFlow doesn't invoke complete_grand_plan. That's CuratedPlanCinema's job. A new flow must call onAllTasksCompleted() at the end, or the plan never marks complete.
Intersection threshold 0.4 and user-scroll debounce 1500ms are tuned. Lowering the threshold flickers active-index; shortening the debounce fires auto-advance mid-flick. Don't "clean up" these constants.
Info Cinema (Q&A / Topic Info-palaset)
Third CinemaShell consumer. Renders a question's/topic's published Info-palaset as a one-page vertical snap-scroll — one card at a time, top → bottom — the same UX as the curated ScrollingPlanFlow but for read-only answer content. This is the "yksi sivu jota mennään kortti kerrallaan alaspäin" flow.
QuestionAnswersCinema (the Film CTA, 3 variants) → InfoCinema (wrapper: CinemaShell hideControls + music/settings sheets, mirrors CuratedPlanCinema) → InfoFlow (the snap-scroll) → InfoView variant="scroll" (one block card).
Mechanics come from the shared useSnapScrollSteps hook — InfoFlow does NOT reimplement the IntersectionObserver. Per-card auto-advance from each block's duration_seconds (0 = none); user-scroll pauses it; last card never auto-advances.
finalSlide render-prop: optional extra snap-step after the blocks (index blocks.length), e.g. CinemaQuestionWrapUp (feedback bar + related questions). Gets { closeCinema, isActive }. Left-rail marker is a HelpCircle (not a number), auto-advance disabled. onFinalSlideEnter = its Enter-key default action (e.g. "Näytä seuraava kysymys"); Enter is reserved for this — never add Enter-to-advance on content cards.
contentKey remount pattern: InfoCinema passes contentKey (the questionId) to <InfoFlow key={contentKey}> → CinemaShell stays mounted (bg/music continue) while InfoFlow remounts (scroll resets) → seamless "next question" transition without leaving cinema. Same family as reading-plan key={cinema-day-N}.
InfoView is dual-variant: variant="scroll" (cinema) vs the page InfoTextCard. Same block, two renderers — edit the one the task asks for (see questions_answers manifest domain).
Renders nothing with 0 published+non-hidden blocks (CTA hidden, InfoCinema returns null). A "Cinema button missing" report usually means the publish gate, not cinema code.
Prayer Room (Rukoushuone)
Realtime multiplayer full-screen prayer experience built on CinemaShell (not CinemaReader). Canonical example of a CinemaShell-only consumer: reuses fullscreen + Ken Burns + background music + preferences, adds its own content, host-driven realtime sync, push-to-talk, and WebRTC audio mesh on top.
Route: /rukoushuone → PrayerRoomPage → PrayerRoomScreen(initialConfig?, onClose). Passing initialConfig opens straight into "room" view (skips setup) — this is how every "launch" entry point works.
Prayer-calendar Cinema launch (CalendarDetailView, /rukouskalenterit?cal=<id>): a Film "Cinema" button navigates navigate("/rukoushuone", { state: { config } }) with the full today-room sources on (useMyCalendar/useSubscribedCalendars: true) plus calendarId (guarantees this calendar's prayers are present even if unsubscribed + enables per-day nav) and the transient startCalendarId field. PrayerRoomScreen runs a one-shot effect that jumps currentPrayerIndex to the first prayer whose calendarId === startCalendarId once the aggregated list has loaded it — so the room opens on that calendar but the rest stays arrow-navigable. startCalendarId is UI-only — NOT persisted (the prayer_rooms mutations whitelist columns), so saved/reopened rooms never re-trigger the jump.
Two views: "setup" (form) and "room" (CinemaShell hideControls + render props)
isHost = !config.id || !user ? true : config.hostUserId === user.id — solo sessions and anonymous users are always host
Content sources (merged + deduped by baseId = id.replace(/^(cal-|mine-|sub-)/, "")):
Ad-hoc config.prayers (uuid ids)
useMyCalendar → user's own + followed active prayers (mine-<id>)
useSubscribedCalendars minus mutedCalendarIds (sub-<id>)
config.calendarId extra browsing calendar (cal-<id>)
Legacy rooms normalize useMyCalendar/useSubscribedCalendars to true; new rooms default toggles to false in setup — asymmetric by design
Host keyboard: ArrowLeft/Right (prayer nav), ArrowUp/Down (calendar day, only if config.calendarId)
Left weekday rail (PrayerRoomCalendarRail + useCalendarRailModel): for prayer-calendar cards (currentPrayer.source === "calendar") a vertical left rail shows the calendar's own scheduled weekdays (Mon–Sun, via getScheduledWeekdays), with the currently-browsed day as the single active marker — green, not amber, and no past/future colouring (unlike InfoFlow/ScrollingPlanFlow). Driven by calendarDayOffsets[currentPrayer.calendarId] (works for subscribed calendars too, not just config.calendarId). Moves with ArrowUp/Down + header chevrons; host can click a marker to jump (offset delta → shiftCurrentCalendarDay + setCurrentPrayerIndex(0)). useCalendarRailModel is a pure useMemo placed after the realtime hooks (no hook-order risk).
Verses: OSIS is canonical removal key; adds and removes auto-persist when config.id exists
Realtime Multiplayer
Channel: prayer-room:<roomId> via Supabase Realtime.
Presence (keyed by user.id): displayName, avatarUrl fetched from profiles before tracking
Broadcast "sync": Host sends { currentPrayerIndex, currentVerseIndex, calendarDayOffset } debounced 100ms; participants apply in useEffect gated on !isHost && syncState
Push-to-talk (PTT): Single speaker at a time; FIFO handQueue; host grants via avatar click or autoGrantNext on release/leave; events: raise_hand, grant_talk, release_talk
WebRTC audio mesh: STUN-only (stun:stun.l.google.com:19302), 2–5 users, one RTCPeerConnection + <audio> per peer, signaling over the same channel; micOpen = activeSpeakerId === userId && pttPressed toggles track enable
Channel ownership: usePrayerRoomSync owns subscribe/unsubscribe. usePushToTalk and useWebRTCAudio attach listeners but do NOT unsubscribe.
Invitations
Table public.prayer_room_invitations with invitee_id OR invitee_email, status pending|accepted|declined. Invite flow auto-persists the room first via createRoom.mutateAsync when opening the invite dialog from an unsaved config.
Critical Gotchas
Modals inline, not portaled. Setup, invite, verse-full popup, and remove-verse confirmation render as absolute inset-0 z-[10003] children of the CinemaShell subtree. Do NOT use shadcn AlertDialog or Dialog — both auto-portal via Radix to document.body and become invisible inside the fullscreen element. asDialog / inline props on child dialogs exist for this. See references/learnings.md → "Radix Dialog/AlertDialog Auto-Portals" for the full pattern.
Z-index stack: CinemaShell base < Header/BottomBar z-[10002] < inline modals z-[10003]. Don't invent new values.
Title pill is rendered by PrayerRoomHeader, not CinemaShell. Pass NO title prop to <CinemaShell> from PrayerRoomScreen — the header renders the pill on its own row 1 alongside the prayer-count nav. Adding the title back to CinemaShell yields a stacked duplicate.
calendarName already includes "Rukouskalenteri" prefix. It comes from the DB name column (e.g. "Rukouskalenteri Suomi"). Don't prepend prayerRoom.header.calendarPrefix again — that produced the duplicate "Rukouskalenteri Rukouskalenteri Suomi" bug. The Calendar icon next to the name carries the semantics.
Prayer typography matches .cinema-verse-text. PrayerRoomContent's prayer body uses clamp(36px, 5vw, 56px) + min(1100px, 88vw) lane — exactly the same as cinema-reader's verse text (packages/cinema-reader/src/styles/cinema.css). The middle container must NOT have max-w-2xl mx-auto or any narrower cap that would clip this lane.
src/hooks/usePrayerRoom.ts is unused. PrayerRoomScreen manages state inline; do not "refactor to use the hook" without migrating sync/PTT/WebRTC plumbing too.
prayer_rooms / prayer_room_invitations not in types.ts — hooks still use (supabase as any). When types regenerate, drop the casts (see /supabase-typing-architect).
STUN-only mesh fails on symmetric NAT / strict firewalls. Add TURN + SFU if scaling beyond 5 users.
Common Tasks
Add New Voice
Get voice ID from ElevenLabs → add to elevenLabsVoices.ts → add to admin UI → use elevenlabs:{voiceId} format
Debug Audio Sync
Add interval logging in useCinemaAudio to trace findCurrentCue() output.
references/curated-plans.md - Curated grand plans: progression modes (continuous / linear / scrolling), flow routing, completion RPC, MiniTask data model