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()(fromcomponents/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'snav.back()(and ESC, shell X, fullscreen-exit) runs interceptors LIFO first, then pops the OS stack. Generalizes TopicCinema's oldpopOrClose. Question/Curated have NO internal layers β no interceptor. - Only the top frame is mounted (Phase 1).
nav.replacePRESERVES 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-osso the/cinemalanding (CinemaLandingPage) is untouched.CinemaShellalready portals todocument.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):
// 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.
πicon βCinemaMusicPopover(Raamatun versio + lukuÀÀniCinemaVersionVoiceSection, 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):
// Extracted from CinemaReaderScreen, manages:
// 1. Task queue state (queue, index, completedIds, overlays)
// 2. Auto-open logic (initialTask, autoStart, task selector)
// 3. Reading plan verse loading (loadReadingPlanReferences)
// 4. Kooste references loading (loadKoosteReferences)
// 5. Task completion persistence (persistTaskCompletion helper)
// 6. Quiz insertion after reading plan completion
// 7. Task routing, jumping, skipping
// 8. Mini-task choice routing
// 9. Prayer message handling
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") useTodayTasksnormalizes practices + plans + prayers βUnifiedTodayTask[]useDiscipleshipTasksconvenience 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:
DiscipleshipTransitionOverlaywith progress bar + free navigation DiscipleshipVerseBaron 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}, noonDiscipleshipToggle) - Skips reading plan memory quizzes (
!props.autoStartguard on quiz insertion) - Auto-skips transition overlays between tasks (useEffect at line ~1285-1290)
- When all tasks done: calls
onAllTasksCompletedand closes
Reading Plan Completion
handleComplete calls mark_reading_day_complete RPC when a reading plan finishes. Key details:
- Completion-type plans: RPC advances
current_dayimmediately (e.g. 5β6) - Dashboard fix:
get_today_dashboardcheckscompleted_at::date = CURRENT_DATEfor completion-type plans (notcurrent_daywhich already advanced) - Client-side fix:
useTodayTaskscheckscompleted_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+ cardbg-black/60 backdrop-blur-md max-w-md - Uses
<MiniTaskView>(SVG circular countdown timer, pause viaDiscipleshipPomodoroButton) 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 againsttask.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 atratio > 0.4to prevent flicker- Per-step auto-advance:
duration_seconds(default 30s) βscrollToStep(i+1)unless user is scrolling - User-scroll detection: any
scrollevent flipsuserScrollingRef = truewith 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; // lucide icon via getPracticeIcon
duration_seconds: number; // timer (CuratedPlanFlow) OR auto-scroll delay (ScrollingPlanFlow)
choices?: MiniTaskChoice[]; // if present β hides JATKA in MiniTaskView (choice-to-advance)
verse_ref?: string; // resolved via fetchVerseTexts
closing_text?: string;
image_url?: string;
show_break_button?: boolean; // default true
}
Plan Completion
- Last task / LOPETA β flow calls
onAllTasksCompleted()thenonClose()synchronously CuratedPlanCinema.handleAllTasksCompleted:- Sets
completedRef.current = trueBEFORE the async RPC (close fires synchronously after) - Calls
supabase.rpc('complete_grand_plan', { p_grand_plan_id: planId }) - Invalidates
["grand-plans"]
- Sets
handleCloseβonClose(), thenif completedRef && completionRedirectUrlβnavigate(url)after 300ms
Critical Gotchas
continuousandlinearare identical in code. Only"scrolling"branches β the other two both fall through toCuratedPlanFlow. Branch INSIDE the flow if you need them to differ, don't split the router.- ScrollingPlanFlow reimplements step UI. Inline
StepContentis a simpler cousin ofMiniTaskView(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 byInfoFlow. ScrollingPlanFlow still carries its own duplicate inlineIntersectionObserver+ timer. A snap-scroll fix in one does not reach the other β prefer migrating ScrollingPlanFlow onto the hook over editing both. Tuning constants (0.4threshold,1500msuser-scroll debounce) are deliberately matched; don't "clean up" either copy. - Completion order is load-bearing.
completedRef.current = truemust run BEFORE theawait complete_grand_planRPC β flow callsonAllTasksCompletedthenonClosesynchronously, andhandleClosereads the ref immediately. - Two RPCs on mount.
useCuratedPlanCinemafiresget_grand_plan_itemsANDget_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_planshas no match β silentcontinuousdefault. Worth checking first when a plan "won't scroll". - ScrollingPlanFlow doesn't invoke
complete_grand_plan. That'sCuratedPlanCinema's job. A new flow must callonAllTasksCompleted()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(theFilmCTA, 3 variants) βInfoCinema(wrapper:CinemaShell hideControls+ music/settings sheets, mirrorsCuratedPlanCinema) βInfoFlow(the snap-scroll) βInfoView variant="scroll"(one block card).- Mechanics come from the shared
useSnapScrollStepshook β InfoFlow does NOT reimplement the IntersectionObserver. Per-card auto-advance from each block'sduration_seconds(0 = none); user-scroll pauses it; last card never auto-advances. finalSliderender-prop: optional extra snap-step after the blocks (indexblocks.length), e.g.CinemaQuestionWrapUp(feedback bar + related questions). Gets{ closeCinema, isActive }. Left-rail marker is aHelpCircle(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.contentKeyremount pattern:InfoCinemapassescontentKey(thequestionId) 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-plankey={cinema-day-N}.InfoViewis dual-variant:variant="scroll"(cinema) vs the pageInfoTextCard. Same block, two renderers β edit the one the task asks for (seequestions_answersmanifest domain).- Renders nothing with 0 published+non-hidden blocks (CTA hidden,
InfoCinemareturns 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). PassinginitialConfigopens straight into"room"view (skips setup) β this is how every "launch" entry point works. - Entry helpers:
useTodayPrayerRoomopens stored room β fallback most recent β fresh "PΓ€ivΓ€n rukoushuone" - Prayer-calendar Cinema launch (
CalendarDetailView,/rukouskalenterit?cal=<id>): aFilm"Cinema" button navigatesnavigate("/rukoushuone", { state: { config } })with the full today-room sources on (useMyCalendar/useSubscribedCalendars: true) pluscalendarId(guarantees this calendar's prayers are present even if unsubscribed + enables per-day nav) and the transientstartCalendarIdfield.PrayerRoomScreenruns a one-shot effect that jumpscurrentPrayerIndexto the first prayer whosecalendarId === startCalendarIdonce the aggregated list has loaded it β so the room opens on that calendar but the rest stays arrow-navigable.startCalendarIdis UI-only β NOT persisted (theprayer_roomsmutations 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>)useSubscribedCalendarsminusmutedCalendarIds(sub-<id>)config.calendarIdextra browsing calendar (cal-<id>)
- Ad-hoc
- Legacy rooms normalize
useMyCalendar/useSubscribedCalendarstotrue; new rooms default toggles tofalsein 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, viagetScheduledWeekdays), with the currently-browsed day as the single active marker β green, not amber, and no past/future colouring (unlike InfoFlow/ScrollingPlanFlow). Driven bycalendarDayOffsets[currentPrayer.calendarId](works for subscribed calendars too, not justconfig.calendarId). Moves with ArrowUp/Down + header chevrons; host can click a marker to jump (offset delta βshiftCurrentCalendarDay+setCurrentPrayerIndex(0)).useCalendarRailModelis a pureuseMemoplaced after the realtime hooks (no hook-order risk). - Verses: OSIS is canonical removal key; adds and removes auto-persist when
config.idexists
Realtime Multiplayer
Channel: prayer-room:<roomId> via Supabase Realtime.
- Presence (keyed by user.id):
displayName,avatarUrlfetched fromprofilesbefore 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 orautoGrantNexton release/leave; events:raise_hand,grant_talk,release_talk - WebRTC audio mesh: STUN-only (
stun:stun.l.google.com:19302), 2β5 users, oneRTCPeerConnection+<audio>per peer, signaling over the same channel;micOpen = activeSpeakerId === userId && pttPressedtoggles track enable - Channel ownership:
usePrayerRoomSyncowns subscribe/unsubscribe.usePushToTalkanduseWebRTCAudioattach 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 shadcnAlertDialogorDialogβ both auto-portal via Radix todocument.bodyand become invisible inside the fullscreen element.asDialog/inlineprops on child dialogs exist for this. Seereferences/learnings.mdβ "Radix Dialog/AlertDialog Auto-Portals" for the full pattern. - Z-index stack: CinemaShell base < Header/BottomBar
z-[10002]< inline modalsz-[10003]. Don't invent new values. - Title pill is rendered by PrayerRoomHeader, not CinemaShell. Pass NO
titleprop 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. calendarNamealready includes "Rukouskalenteri" prefix. It comes from the DBnamecolumn (e.g."Rukouskalenteri Suomi"). Don't prependprayerRoom.header.calendarPrefixagain β that produced the duplicate "Rukouskalenteri Rukouskalenteri Suomi" bug. TheCalendaricon next to the name carries the semantics.- Prayer typography matches
.cinema-verse-text. PrayerRoomContent's prayer body usesclamp(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 havemax-w-2xl mx-autoor any narrower cap that would clip this lane. src/hooks/usePrayerRoom.tsis unused. PrayerRoomScreen manages state inline; do not "refactor to use the hook" without migrating sync/PTT/WebRTC plumbing too.prayer_rooms/prayer_room_invitationsnot 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 β useelevenlabs:{voiceId}format
Debug Audio Sync
Add interval logging in useCinemaAudio to trace findCurrentCue() output.
References
references/audio-cue-format.md- Detailed cue timing specificationreferences/elevenlabs-api.md- ElevenLabs API referencereferences/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 modereferences/info-cinema.md- Info Cinema (Q&A / topic info-palaset): one-page snap-scroll card flow, shareduseSnapScrollSteps,finalSlidewrap-up,contentKeyremount, dual-variant InfoViewreferences/prayer-room.md- Prayer Room (Rukoushuone): CinemaShell consumer with realtime sync, PTT, WebRTC audio mesh, invitationsreferences/curated-plans.md- Curated grand plans: progression modes (continuous / linear / scrolling), flow routing, completion RPC, MiniTask data modelreferences/mobile-ui.md- Cinema Mode mobile UI: compact controls (π + β sheets), adaptive verse typography (clamp+vw, β€380px tier), native fullscreen portal pattern, gotchasreferences/learnings.md- Bug patterns and fixesDocs/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.