| name | cinema-voice-architect |
| description | Architect for Cinema Mode, Discipleship Cinema, CinemaShell, audio/voice
(ElevenLabs TTS), grand-plan cinema flows, Info Cinema (Q&A / topic
info-palaset), and realtime multiplayer Prayer Rooms in Raamattu Nyt. Covers
full-screen verse presentation, verse-level audio sync & cues,
auto-scroll/auto-advance, dual-track audio (Bible+music), Ken Burns,
discipleship task orchestration, Nyt Kooste, verse memory quizzes,
TanaanPage, curated/scrolling grand plans, one-page snap-scroll card flows,
and Prayer Room (PTT, WebRTC mesh, hand-raise, host sync, calendar prayers).
Triggers: cinema mode, voice playback, audio sync, verse scrolling,
ElevenLabs, TTS, audio cues, auto-advance, full screen reader, background
music, Ken Burns, discipleship cinema, CinemaShell,
useDiscipleshipOrchestration, Nyt Kooste, curated plan, scrolling plan,
grand plan cinema, verse memory, reading plan cinema, TanaanPage, tanaan,
opetuslapseus, info cinema, info-palaset, info blocks, InfoFlow, InfoView,
Q&A cinema, vastaukset cinema-tilassa, snap scroll, useSnapScrollSteps,
rukoushuone, prayer room, push-to-talk, PTT, WebRTC audio,
multiplayer prayer, calendar prayers, rukouskalenteri cinema
|
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 |
| 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) | src/cinema-os/CinemaOSNavCluster.tsx |
| Apps: command-center / search / topic / question / questions / curated / reading / reading-plan / prayer-room / discipleship / summary | src/cinema-os/apps/*.tsx |
PrayerRoomApp (multiplayer Rukoushuone in-shell; ownsChrome → OS chrome hidden) | src/cinema-os/apps/PrayerRoomApp.tsx |
DiscipleshipCinemaApp (full Tänään Discipleship-tila in-shell; dual-mode CinemaReaderScreen + ownsChrome) | src/cinema-os/apps/DiscipleshipCinemaApp.tsx |
SummaryCinemaApp (Kooste played as verse cinema; dual-mode CinemaReaderScreen mode="summaryItems" + ownsChrome) | src/cinema-os/apps/SummaryCinemaApp.tsx |
| Launch entry (Tänään top) | src/pages/tanaan-page/CinemaOSEntryCard.tsx |
Content Layer (CinemaReaderScreen)
| Component | Location |
|---|
| CinemaReaderScreen | src/features/cinema/CinemaReaderScreen.tsx |
| Bible Audio Hook | src/hooks/useBibleAudio.ts |
| Auto-Advance Hook | src/hooks/useAutoAdvance.ts |
| Chapter Bundle Hook | src/hooks/useChapterBundle.tsx |
| Audio Sync | src/lib/cinemaAudioSync.ts |
| Audio Estimation | src/lib/audioEstimation.ts |
| Cinema Types | src/types/cinema.ts |
Discipleship Layer
| Component | Location |
|---|
| Discipleship Orchestration | src/hooks/useDiscipleshipOrchestration.ts |
| Discipleship Utils | src/components/cinema/discipleshipUtils.tsx |
| Discipleship TaskSelector | src/components/cinema/DiscipleshipTaskSelector.tsx |
| Discipleship InlineTask | src/components/cinema/DiscipleshipInlineTask.tsx |
| Discipleship VerseBar | src/components/cinema/DiscipleshipVerseBar.tsx |
| Discipleship Pomodoro | src/components/cinema/DiscipleshipPomodoroButton.tsx |
| Discipleship Transition | src/components/cinema/DiscipleshipTransitionOverlay.tsx |
| Verse Memory Quiz | src/components/practice/VerseMemoryQuiz.tsx |
| Discipleship Tasks Hook | src/hooks/useDiscipleshipTasks.ts |
| Nyt Summary Hook | src/hooks/useNytSummary.ts |
| Reading Plan Quiz Hook | src/hooks/useReadingPlanQuiz.ts |
| Smart Verse Selection | src/hooks/useSmartVerseSelection.ts |
Program Mode (Curated Plans)
| Component | Location |
|---|
| CuratedPlanCinema (wrapper + flow router) | src/components/cinema/CuratedPlanCinema.tsx |
CuratedPlanFlow (continuous / linear) | src/components/cinema/CuratedPlanFlow.tsx |
ScrollingPlanFlow (scrolling) | src/components/cinema/ScrollingPlanFlow.tsx |
| MiniTaskView (inside CuratedPlanFlow) | src/components/cinema/MiniTaskView.tsx |
| Curated Plan Cinema Hook | packages/shared-practices/src/hooks/useCuratedPlanCinema.ts |
Shared types (GrandPlanProgressionType, MiniTaskContentConfig, MiniTaskRoute) | packages/shared-practices/src/types.ts |
| Shared snap-scroll mechanics (InfoFlow + ScrollingPlanFlow) | src/hooks/useSnapScrollSteps.ts |
Info Cinema (Q&A / Topic Info-palaset)
| Component / Hook | Location |
|---|
| InfoCinema (wrapper: CinemaShell + sheets) | src/components/cinema/InfoCinema.tsx |
| InfoFlow (one-page snap-scroll, card per block) | src/components/cinema/InfoFlow.tsx |
InfoView (variant="scroll" = one block card) | src/components/cinema/InfoView.tsx |
CinemaQuestionWrapUp (Q&A finalSlide) | src/components/cinema/CinemaQuestionWrapUp.tsx |
QuestionAnswersCinema (the Film CTA) | src/pages/question-detail/QuestionAnswersCinema.tsx |
| CinemaWrapUpRelatedQuestions (shared "Liittyvät kysymykset" list) | src/components/cinema/CinemaWrapUpRelatedQuestions.tsx |
Topic Cinema (aihe-cinema)
| Component / Hook | Location |
|---|
| TopicCinema (wrapper: CinemaShell + sheets, mirrors InfoCinema) | src/components/cinema/TopicCinema.tsx |
TopicFlow (snap-scroll over heterogeneous TopicCinemaStep[]) | src/components/cinema/topic/TopicFlow.tsx |
TopicCinemaWrapUp (topic finalSlide) | src/components/cinema/topic/TopicCinemaWrapUp.tsx |
| Card views (explanation / strongs / verses / highlights) | src/components/cinema/topic/Topic*View.tsx |
| Step composer hook | src/hooks/cinema/useTopicCinemaSteps.ts |
TopicCinemaButton (the Film CTA on /aihe/<slug> + haku/aihekortti + preview) | src/pages/topic-page/TopicCinemaButton.tsx |
| CinemaVerseActions (per-verse: pohdittavaksi/koosteeseen/muistiinpano + open) | src/components/cinema/CinemaVerseActions.tsx |
| TopicChapterReader (kevyt luku-cinema: 6-verse scroll, prev/next chapter, X) | src/components/cinema/topic/TopicChapterReader.tsx |
TopicVerseCinema (1-verse carousel via @raamattu-nyt/cinema-reader embedded) | src/components/cinema/topic/TopicVerseCinema.tsx |
| CinemaVersePreviewPopup (Haku>Jakeet result → verse-in-chapter popup, per-verse jae-cinema) | src/components/cinema/CinemaVersePreviewPopup.tsx |
Topic cinema is a 4th CinemaShell consumer. Unlike Info Cinema it does NOT
reuse InfoFlow (which is InfoBlock[]-only) — it has its own TopicFlow
over a discriminated TopicCinemaStep[] (explanation | info | strongs |
verses | highlights), reusing useSnapScrollSteps, CinemaShell, and
InfoView variant="scroll" for the info-block cards. Background music only,
no verse-TTS. Steps composed in useTopicCinemaSteps from existing topic
hooks (useTopicData, useTopicInfoBlocks, useUserHighlightsForTopic,
useAnchorQuestions, useQuestionsForInfoBlocks).
Internal view stack (one shell, three layers). TopicCinema keeps a SINGLE
CinemaShell and toggles layers rendered on top of each other (music continues):
flow (TopicFlow) → chapter (TopicChapterReader, lightweight 6-verse scroll
of a chapter via useChapterBundle, prev/next chapter + X) → verseCinema
(TopicVerseCinema, the real 1-verse-at-a-time carousel = @raamattu-nyt/cinema-reader
<CinemaReader embedded> — NO own shell/fullscreen, no double music). Opened from
per-verse buttons: a verse card's "Lue" → chapter reader at that verse; a chapter
verse's "Cinema" → verse carousel at that verse.
Stacking-context / portal gotcha. TopicCinema is rendered via createPortal(…, document.body). Reason: TopicCinemaButton mounts inside sticky headers
(TopicPageHeader z-50, SearchPageHeader z-40, TopicPreviewHeader) which create
their own stacking context — so CinemaShell's fixed inset-0 z-[9999] was scoped to
the header and the app sidebar (fixed z-[60] at page root, a higher root-level context)
painted over the cinema's left edge. Portaling to body escapes the header context. Any
new cinema launched from inside a position:sticky/relative + z-index ancestor needs the
same portal (the reading cinema isn't, so it never hit this).
ESC-pino gotcha (load-bearing). Browser ESC exits fullscreen, and CinemaShell
auto-closes on fullscreen-exit. So TopicCinema routes the shell's onClose to a
popOrClose guard that pops one layer (verseCinema→chapter→flow) and only truly
closes at flow. A capture-phase keydown handler covers ESC when NOT in browser
fullscreen (!document.fullscreenElement) so the 2nd/3rd ESC keeps popping. Never
make the layers separate CinemaReaderScreen/CinemaShell instances — that yields
double-fullscreen + double-music + the auto-close race.
Per-verse actions (CinemaVerseActions, shared by topic verse cards + chapter
reader): pohdittavaksi (upsert_highlight_full→toggle_verse_interest), koosteeseen
(useNytSummary), muistiinpano (saveVerseNote) — modeled on DiscipleshipVerseBar
(don't edit that) — plus a context 4th button (Lue / Cinema). Pohdittavaksi+note need
verse_id (from get_verses_by_refs / BundleVerse.id) + login; they hide otherwise.
The 4th open button shows ONLY when onOpen is passed. TopicChapterReader's
onOpenVerseCinema is optional → omitting it hides the per-verse Film/"Cinema"
button: ReadingPlanCinemaApp does this (it's opened FROM jae-cinema, so jae→luku→jae
would be a loop). TopicCinema / TopicCinemaApp / ReadingCinemaApp keep it (there
the verse carousel is the primary path).
TopicChapterReader picker gotchas (load-bearing). (1) Stacking: the top bar
(holding the showBibleNav BibleNavRow book/chapter/version picker) and the verse list
are siblings under the reader root (zIndex:3). They must NOT share a z — the top bar is
relative z-30, the verse list/bottom bar stay z-10, so the BibleNavRow dropdown paints
ABOVE the verses. With equal z the later-DOM verse list painted over the dropdown → it
"showed through" AND intercepted clicks (picker unselectable). (2) Verse numbers are a
baseline <span> at 1.05em (NOT a tiny <sup>) — slightly larger than the verse text.
(3) BibleNavRow has arrow+Enter+Esc keyboard nav inside an open panel: a capture-phase
window keydown (only while openPanel set) + stopPropagation so it preempts the chapter
reader's Left/Right chapter-nav; grid-aware (navCols matches the grid classes), active item
ring + scrollIntoView. The three select handlers are useCallback (used as effect deps).
Verse-range = one block, one menu. A topic reference can be a range (Joh.3:1-8).
useTopicCinemaSteps builds VersesStep.groups: VerseGroup[] (one group per reference,
keyed osisStart / osisStart-osisEnd — same key useTopicData uses for its verses
Map), NOT a flat verse list. TopicVersesView renders ONE block per group (verses
concatenated, per-verse number as <sup> only when range) with ONE CinemaVerseActions.
Range-aware semantics via verseIds (all verse UUIDs → pohdittavaksi marks the whole
range) + endVerse (koosteeseen adds the range Joh.3:1-8 as one item; note appends
[koskee N-M jakeita] and attaches to the FIRST verse; Lue starts the chapter reader at
the first verse). addToNytSummary(book, chapter, verseNumber, versionCode, endVerse?).
Verse ORDER must match the page (one ordering, three surfaces). The aihe-cinema verse
cards, the /aihe topic-page verses tab, and the search TopicPreview reference list
must all show references in the SAME order. The canonical order is
sortReferences in useTopicData.ts: relevance_score DESC → NT(new) first → canonical
book_order → chapter → verse. The cinema + topic page both go through useTopicData
(same sorted references array). The search preview is the odd one out — it loads refs via
the get_topic_preview(p_slug, …) RPC, which must carry the identical ORDER BY
tie-break (relevance alone is NOT enough; equal-relevance refs otherwise return in arbitrary
DB order and the preview visibly diverges from the cinema). If "cinema order ≠ page order"
is reported, check whether the reported "page" is the search preview and whether that RPC's
reference ORDER BY still matches sortReferences.
Language: selectTopicDescription(topic, isFinnish) picks FI/EN description fields;
verse refs via formatOsisReference(osis, null, isFinnish). get_verses_by_refs
returns verse_id + book_code (threaded into VerseData). Book label in the
chapter/verse cinema: book may be a lowercase db code ("john") — normalize with
getOsisFromAny(book) → OSIS before getOsisBookNameFinnish / getEnglishBookNameFromOsis.
get_chapter_bundle (useChapterBundle) gotcha (load-bearing): its p_book_name
resolves a DB book NAME, NOT the OSIS code — numbered-book OSIS codes (1Cor/2Thess)
return "Book not found" → empty chapter (single books like John/Matt happen to
resolve). So TopicChapterReader, TopicVerseCinema, and CinemaVersePreviewPopup pass
getCanonicalDbBookName(book) to useChapterBundle (OSIS/code → "1 Corinthians").
They MUST all normalize identically or the shared bundle cache key diverges and
TopicVerseCinema's lazy start-verse init misses the cache (→ starts at verse 0). Keep the
raw book (OSIS) for labels + CinemaVerseActions (koosteeseen localization).
TopicVerseCinema is self-fetching (useChapterBundle, cache shared with the
chapter reader). Auto-advance is NOT the engine — the CinemaReader GSAP engine's
internal auto-play timer is DISABLED in this project ("verse advancement handled by
app-level useAutoAdvance"). So playing={true} alone does nothing; you MUST drive a
controlled currentIndex with useAutoAdvance (@/hooks/useAutoAdvance, cue-timed:
onAdvance=setCurrentIndex, onComplete→completion overlay) exactly like
CinemaReaderScreen. useVerseCues (no audio → estimated cues) feeds both the
cueDurations (segmented progress bar) and the auto-advance timing. onIndexChange
keeps the controlled index in sync with manual nav (progress click / drag / arrows).
Audio + manual nav (load-bearing): with Bible TTS playing, manual nav must go through
onManualNavigation so useCinemaAudioSync enters manual mode (suppresses audio-follow)
and immediately seeks the audio to that verse; otherwise the package's syncToAudioTime
yanks the highlight straight back ("snap-back"). The package fires onManualNavigation from
BOTH handlePrev/handleNext AND handleSeek (progress click/drag) — if a future progress
handler skips it, audio-chapter clicks regress. The settle timer only LIFTS suppression; it
must not re-seek (that would rewind the just-started verse).
Compact controls: compact + cueDurations/verseNumbers (segmented bar, like the
reading cinema) + onOpenSettings/onOpenMusic wired to TopicCinema's sheets.
Per-chapter reset/replay = just setCurrentIndex(0) (controlled index → engine seeks;
no key remount needed — the engine already remounts on verses change).
Start-verse gotcha (fixed in package): both GSAP engines' mount force offset = 0
inside a double-rAF after measurement, so the carousel always started at verse 0 — a
synchronous post-mount engine.seek runs before that rAF/measurement and is wiped. Fix:
CinemaEngineMountOptions.initialIndex (applied inside the rAF after measure, both
vertical + horizontal); CinemaReader passes initialIndex: indexRef.current. So the
CONSUMER must have the correct controlled currentIndex AT MOUNT — TopicVerseCinema
lazy-inits currentIndex from the already-cached bundle (useState(() => …)), not via a
post-mount effect (which would lose to the engine's mount reset). This also finally makes
CinemaReaderScreen's initialVerseIndex honor non-zero starts. Compact music icon:
the package ControlBar only shows the speaker icon when onOpenMusic && (onToggleMusic || onMusicVolumeChange) — pass the shell-ctx music handles (onToggleMusic etc.) from
TopicCinema, not just onOpenMusic. The music sheet itself is the shared
CinemaMusicPopover (no new component). Chapter reader arrow keys are gated by active
(off when the verse carousel is layered on top).
Shared wrap-up rule: the two finalSlide cards (CinemaQuestionWrapUp,
TopicCinemaWrapUp) intentionally share ONLY the related-questions list via
CinemaWrapUpRelatedQuestions (each builds its own deduped
CinemaWrapUpRelatedRow[] then renders it). The cards are NOT merged — their
feedback (QuestionFeedbackBar vs generic useFeedback thumbs+text), primary
action (next-question vs free-text search), and navigation model (callbacks
for Q&A's contentKey in-cinema transition vs direct navigate+close)
differ fundamentally. Merging would need conditional slots > the duplication
removed. Add a new shared primitive only when markup is byte-identical.
TopicCinemaWrapUp continuation paths (reading plan + prayer room). The
wrap-up's "Päivän lukusuunnitelma" opens ReadingPlanChooserOverlay
(components/cinema/topic/) — a createPortal(…, document.body) popup (z above
the cinema's z-[9999]) listing today's reading plans (useDiscipleshipTasks() →
type === "reading_plan", completed=green) with Enter = first unread, and a
bottom "Lopeta" button. When EVERY plan is read (allDone), focus moves to
Lopeta and firstUnread is undefined → plain ENTER closes (ends the
walkthrough; in ReadingPlanCinemaApp onClose = nav.back) instead of re-opening
a done plan. Picking a plan closes the cinema and navigates /tanaan?startReadingPlanId=<task.id>.
The chooser MUST portal to getCinemaPortalContainer() ?? document.body, NOT bare
document.body — CinemaShell is in native fullscreen, so a body-portaled overlay
renders OUTSIDE the fullscreen element and is invisible ("button does nothing"). Same
fullscreen-portal trap the mobile sheets solve; navigate-only buttons (prayer room) are
unaffected because they exit fullscreen.
useTanaanPageState consumes that param (once data loads, ref-guarded strip like
?startCinema), resolves it against cinemaModalTasks (SAME id space), and opens
the reading-plan cinema with initialTask (autoStart off → starts that plan, then
DiscipleshipTaskSelector shows the rest). "Avaa rukoushuone" opens today's room
directly via resolveTodayPrayerRoomConfig(savedPrayerRooms) (extracted from
useTodayPrayerRoom) → navigate("/rukoushuone", { state: { config } })
(PrayerRoomPage opens straight into "room" view). Do NOT revert these to bare
navigate("/tanaan") / navigate("/rukoushuone") — the latter opens an empty setup
(effectively a new room).
Prayer Room (Rukoushuone)
| Component / Hook | Location |
|---|
| PrayerRoomScreen | src/features/prayer-room/PrayerRoomScreen.tsx |
| PrayerRoomPage (route) | src/pages/PrayerRoomPage.tsx |
| PrayerRoomSetup | src/features/prayer-room/PrayerRoomSetup.tsx |
| PrayerRoomContent | src/features/prayer-room/PrayerRoomContent.tsx |
| PrayerRoomHeader | src/features/prayer-room/PrayerRoomHeader.tsx |
| PrayerRoomBottomBar | src/features/prayer-room/PrayerRoomBottomBar.tsx |
| PrayerRoomCalendarRail (left weekday rail) | src/features/prayer-room/PrayerRoomCalendarRail.tsx |
| useCalendarRailModel | src/features/prayer-room/hooks/useCalendarRailModel.ts |
| PrayerRoomInviteDialog | src/features/prayer-room/PrayerRoomInviteDialog.tsx |
| CalendarPrayerBrowser | src/features/prayer-room/CalendarPrayerBrowser.tsx |
| Prayer Room Types | src/features/prayer-room/types.ts |
| useTodayPrayerRoom | src/features/prayer-room/useTodayPrayerRoom.ts |
| usePrayerRooms | src/hooks/usePrayerRooms.ts |
| usePrayerRoomSync (Realtime) | src/hooks/usePrayerRoomSync.ts |
| usePrayerRoomInvitations | src/hooks/usePrayerRoomInvitations.ts |
| usePushToTalk | src/hooks/usePushToTalk.ts |
| useWebRTCAudio | src/hooks/useWebRTCAudio.ts |
Audio Pipeline
| Component | Location |
|---|
| Audio Service | src/lib/audioService.ts |
| ElevenLabs Voices | src/lib/elevenLabsVoices.ts |
| Audio Generation | supabase/functions/generate-audio/index.ts |
Pages
| Component | Location |
|---|
| Tanaan Page | src/pages/TanaanPage.tsx |
| Discipleship Landing | src/pages/DiscipleshipLandingPage.tsx |
| Spiritual Path Faith | src/pages/SpiritualPathFaithPage.tsx |
| Daily Reading View | src/components/reading-plans/DailyReadingView.tsx |
Architecture Overview
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, default 30s),
│ 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.
/cinema-os → CinemaOSProvider (stack reducer + history + localStorage)
└─ CinemaHost (mounted ONCE)
└─ CinemaPreferencesProvider (hoisted, shared once)
└─ <CinemaShell> (persistent: music / bg / fullscreen)
└─ CinemaShellContext.Provider
└─ ActiveCinemaApp = registry[topIntent.type].Component
command-center · search · topic · question · curated
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 "Rajaus
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 ("Rajaus kirjoittain" = books from results; "Rajaus
taivutuksin" = extractWordForms, now EXPORTED from VerseFilterBar) are inline
(NOT Radix Select → stay visible in fullscreen). 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):
CinemaReaderScreen
Content layer at src/features/cinema/CinemaReaderScreen.tsx (~1033 lines):
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.
🔊 icon → CinemaMusicPopover (Raamatun versio + lukuääni CinemaVersionVoiceSection, track, favorite, volume, favorites-only, change track)
⚙ icon → CinemaSettingsSheet (background, completion mode, speed, voice volume + on/off)
- Adaptive verse typography via CSS
clamp() + vw (3 tiers; small-phone ≤380px gets 24-30px to fit Acts 1:14 on iPhone SE)
- Sheets render inside native fullscreen via
getCinemaPortalContainer() (Radix portal targeting fix)
Critical: @media (max-width: 479px) matches iPhone 14 Pro Max (430px). Use max-width: 380px for genuine "small phone" rules.
For full details (component contracts, control distribution table, font tier table, portal pattern, gotchas): See references/mobile-ui.md
useDiscipleshipOrchestration
Task queue hook at src/hooks/useDiscipleshipOrchestration.ts (~792 lines):
Animation Modes
Five animation modes in src/types/cinema.ts: slide, zoom, stack, loopH, loopV
Ken Burns Effect
CinemaBackground.tsx: Random transform over 25s (scale 1.0-1.15, translate ±5%)
Audio System
ElevenLabs Integration
Voices in src/lib/elevenLabsVoices.ts: Venla (female, T5qAFgaL2uYxoUtojUzQ), Urho (male, 1WVCONUwYGulVaKg4oTr)
Audio Generation Flow
Client → Edge Function → check hash cache → ElevenLabs API (with timestamps)
→ parse timestamps → verse cues → store MP3 → save metadata + cues → return
For detailed API reference, see references/elevenlabs-api.md
Audio Cue Format
See references/audio-cue-format.md for full specification.
Auto-Advance (Without Audio)
useAutoAdvance: WPM-based timing (default 150, adjustable 50-400). Min 1.5s per verse.
Priority: audio cues > auto-advance timer.
Dual-Track Audio
useCinemaAudio: Bible + background music tracks with independent volume (0-1).
Database Schema
See existing tables: audio_assets, audio_cues, cinema_preferences, background_tracks, background_visuals in bible_schema.
Discipleship Cinema Mode
Immersive full-screen task flow launched from Tanaan page. Guides users through daily reading plans, prayers, practices, and memory quizzes sequentially.
For full details: See references/discipleship-cinema.md
Key Concepts
- Launched via "Discipleship-tila" button on
/tanaan → CinemaReaderScreen(mode="verseList")
useTodayTasks normalizes practices + plans + prayers → UnifiedTodayTask[]
useDiscipleshipTasks convenience hook composes all data sources
- Task routing:
reading_plan → verse reader, prayer/practice → DiscipleshipInlineTask
- Quiz content types:
verse_memory, read_memory, reading_plan_memory → VerseMemoryQuiz
- Between tasks:
DiscipleshipTransitionOverlay with progress bar + free navigation
DiscipleshipVerseBar on pause: Add to Nyt Kooste, Note, Share, Pomodoro Break
- Nyt Kooste: Auto-created daily verse collection, appended as final task
- Reading Plan Memory Quiz: Auto-inserted after each reading plan day completion (discipleship mode only, NOT autoStart)
- initialTask prop: Start specific task immediately, bypass selector
- autoStart prop: Skip task selector, auto-queue all uncompleted tasks, auto-skip transitions
- Break duration from
app_config.discipleship_break_minutes (default 5 min)
Two Cinema Launch Modes from Tanaan
| Mode | Button | Props | Behavior |
|---|
| Reading Plan Cinema | Blue Play button | discipleshipTasks={readingPlanCinemaTasks} autoStart | Auto-plays reading plans only. No task selector, no discipleship UI toggle, no memory quizzes, no transition overlays. |
| Discipleship Cinema | Amber Clapperboard button | discipleshipTasks={allTasks} | Full discipleship flow: task selector → reading + prayer + practice → quizzes → kooste. |
autoStart behavior:
- Queues all uncompleted tasks, starts first immediately (line ~1071-1092)
- Hides discipleship toggle/next button in CinemaReader (
discipleshipMode={false}, no onDiscipleshipToggle)
- Skips reading plan memory quizzes (
!props.autoStart guard on quiz insertion)
- Auto-skips transition overlays between tasks (useEffect at line ~1285-1290)
- When all tasks done: calls
onAllTasksCompleted and closes
Reading Plan Completion
handleComplete calls mark_reading_day_complete RPC when a reading plan finishes. Key details:
- Completion-type plans: RPC advances
current_day immediately (e.g. 5→6)
- 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.
For full details: See references/curated-plans.md
Progression Types
type GrandPlanProgressionType = 'continuous' | 'linear' | 'scrolling';
| Value | Flow Component | UX |
|---|
continuous (default) | CuratedPlanFlow | One mini-task card at a time + countdown timer |
linear | CuratedPlanFlow | Same code path as continuous — semantic distinction only |
scrolling | ScrollingPlanFlow | All steps on one snap-scrollable page with auto-advance |
Flow router (CuratedPlanCinema.tsx line ~55):
const FlowComponent = progressionType === "scrolling" ? ScrollingPlanFlow : CuratedPlanFlow;
Both flows share the same props contract: { tasks, onAllTasksCompleted, onClose }.
CuratedPlanFlow (continuous / linear)
- One mini-task card at a time via
AnimatePresence mode="wait" crossfade
- Scrim
bg-black/40 + card bg-black/60 backdrop-blur-md max-w-md
- Uses
<MiniTaskView> (SVG circular countdown timer, pause via DiscipleshipPomodoroButton)
completedIds: Set<string> tracks done tasks; advance skips already-done
- Backward jump via choice → un-completes the target (allows replay)
- X button = skip WITHOUT marking complete
- Choice routing:
{type:'next'} | {type:'jump_to_sort_order', sort_order} — sort_order matched against task.metadata.grand_plan_sort_order
ScrollingPlanFlow (scrolling)
- All steps rendered on a single snap-scrollable page (
snap-y snap-mandatory)
- Numbered progress pills at top (click-to-jump), amber = active, emerald = past, faint = future
IntersectionObserver (threshold: [0, 0.25, 0.5, 0.75, 1], root = container) tracks active step; gates state update at ratio > 0.4 to prevent flicker
- Per-step auto-advance:
duration_seconds (default 30s) → scrollToStep(i+1) unless user is scrolling
- User-scroll detection: any
scroll event flips userScrollingRef = true with 1500ms debounce — pauses auto-advance
- Scrim
bg-black/50 (stronger than CuratedPlanFlow)
- Uses its own inline
StepContent (NOT MiniTaskView) — no countdown ring, no completedIds
JATKA (non-last) = scroll, LOPETA (last) = finish + close
MiniTaskContentConfig (per-step data)
interface MiniTaskContentConfig {
title: string;
body: string;
icon?: string;
duration_seconds: number;
choices?: MiniTaskChoice[];
verse_ref?: string;
closing_text?: string;
image_url?: string;
show_break_button?: boolean;
}
Plan Completion
- Last task / LOPETA → flow calls
onAllTasksCompleted() then onClose() synchronously
CuratedPlanCinema.handleAllTasksCompleted:
- Sets
completedRef.current = true BEFORE the async RPC (close fires synchronously after)
- Calls
supabase.rpc('complete_grand_plan', { p_grand_plan_id: planId })
- Invalidates
["grand-plans"]
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.
For full details: See references/info-cinema.md
Key Concepts
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.
For full details: See references/prayer-room.md
Key Concepts
- Route:
/rukoushuone → PrayerRoomPage → PrayerRoomScreen(initialConfig?, onClose). Passing initialConfig opens straight into "room" view (skips setup) — this is how every "launch" entry point works.
- Entry helpers:
useTodayPrayerRoom opens stored room → fallback most recent → fresh "Päivän rukoushuone"
- 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
references/audio-cue-format.md - Detailed cue timing specification
references/elevenlabs-api.md - ElevenLabs API reference
references/discipleship-cinema.md - Full discipleship cinema mode documentation (components, hooks, flows, gotchas)
references/reading-plan-transition.md - State flow for "Next Day" in reading plan cinema mode
references/info-cinema.md - Info Cinema (Q&A / topic info-palaset): one-page snap-scroll card flow, shared useSnapScrollSteps, finalSlide wrap-up, contentKey remount, dual-variant InfoView
references/prayer-room.md - Prayer Room (Rukoushuone): CinemaShell consumer with realtime sync, PTT, WebRTC audio mesh, invitations
references/curated-plans.md - Curated grand plans: progression modes (continuous / linear / scrolling), flow routing, completion RPC, MiniTask data model
references/mobile-ui.md - Cinema Mode mobile UI: compact controls (🔊 + ⚙ sheets), adaptive verse typography (clamp+vw, ≤380px tier), native fullscreen portal pattern, gotchas
references/learnings.md - Bug patterns and fixes
Docs/cinema/CINEMA-OS.md - Cinema OS / Command Center: persistent shell + Intent/Registry/Navigation, two-level back, deep links, history, KeepAlive/Frame State, Search Cinema, phasing (NOT under references/ — it's a shared system doc)
Cross-cutting learnings: See .claude/LEARNINGS.md → "CSS/Layout" section for framer-motion patterns and animation gotchas.