Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 112 additions & 1 deletion src/broadcast-output.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,14 @@ const OUTPUT_ID = new URLSearchParams(window.location.search).get("output") ?? "
interface BroadcastPayload {
theme: BroadcastTheme
verse: VerseRenderData | null
idleFadeMs?: number
}

/** Fallback if a payload arrives without idleFadeMs (older event shape).
* Kept in sync with the Idle Fade Timeout default in settings-store.ts. */
const DEFAULT_IDLE_FADE_MS = 30_000
const FADE_DURATION_MS = 800

function BroadcastCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const latestData = useRef<BroadcastPayload | null>(null)
Expand All @@ -44,6 +50,15 @@ function BroadcastCanvas() {
const ndiCanvasRef = useRef<HTMLCanvasElement | null>(null)
const lastPushRef = useRef(0)
const pushingRef = useRef(false)
const alphaRef = useRef(1)
const targetAlphaRef = useRef(1)
const rafRef = useRef<number | null>(null)
const lastFrameTimeRef = useRef<number | null>(null)
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// presentedAt of the verse the fade timer was last armed for; undefined =
// nothing shown. Only a CHANGED stamp re-arms the timer, so incidental
// re-emits (theme edits, output syncs) can't keep a stale verse alive.
const lastPresentStampRef = useRef<number | null | undefined>(undefined)
const pushNdiBurstRef = useRef<(() => void) | null>(null)

const logDebug = useCallback((message: string, meta?: unknown) => {
Expand All @@ -64,6 +79,7 @@ function BroadcastCanvas() {
const data = latestData.current
if (!data) {
// Black screen when no data
ctx.globalAlpha = 1
ctx.fillStyle = "#000"
ctx.fillRect(0, 0, canvas.width, canvas.height)
return
Expand All @@ -72,17 +88,84 @@ function BroadcastCanvas() {
const { theme, verse } = data
canvas.width = theme.resolution.width
canvas.height = theme.resolution.height

// Clear to black at full opacity first, then draw the scene at the
// current fade alpha, so fading blends toward black rather than
// ghosting over whatever the canvas previously held.
ctx.globalAlpha = 1
ctx.fillStyle = "#000"
ctx.fillRect(0, 0, canvas.width, canvas.height)
ctx.globalAlpha = alphaRef.current

const result = renderVerse(ctx, theme, verse, {
scale: 1,
imageCache: themeImageCache(),
})
ctx.globalAlpha = 1
if (!result) {
ctx.fillStyle = "#000"
ctx.fillRect(0, 0, canvas.width, canvas.height)
logDebug("renderVerse returned null; drew fallback frame")
}
}, [logDebug])

// Animate alphaRef toward targetAlphaRef, redrawing each frame. Reads
// targetAlphaRef live on every tick, so retargeting mid-fade (e.g. a new
// verse arrives while fading out) smoothly reverses direction instead of
// finishing the stale animation first.
const animateFade = useCallback(() => {
if (rafRef.current !== null) return // loop already running
lastFrameTimeRef.current = null

const step = (now: number) => {
const last = lastFrameTimeRef.current ?? now
const dt = now - last
lastFrameTimeRef.current = now

const target = targetAlphaRef.current
const diff = target - alphaRef.current
if (Math.abs(diff) < 0.01) {
alphaRef.current = target
draw()
rafRef.current = null
lastFrameTimeRef.current = null
return
}

alphaRef.current += (dt / FADE_DURATION_MS) * diff
draw()
rafRef.current = requestAnimationFrame(step)
}
rafRef.current = requestAnimationFrame(step)
}, [draw])

const setFadeTarget = useCallback(
(target: number) => {
targetAlphaRef.current = target
animateFade()
},
[animateFade]
)

const clearIdleTimer = useCallback(() => {
if (idleTimerRef.current !== null) {
clearTimeout(idleTimerRef.current)
idleTimerRef.current = null
}
}, [])

const armIdleTimer = useCallback(
(idleFadeMs: number) => {
clearIdleTimer()
idleTimerRef.current = setTimeout(() => {
idleTimerRef.current = null
logDebug("Idle timeout reached; fading display to black")
setFadeTarget(0)
}, idleFadeMs)
},
[clearIdleTimer, logDebug, setFadeTarget]
)

// Redraw once a theme's images land. The burst matters: without it NDI
// receivers keep the flat fallback frame until the 2s keepalive fires.
const preloadThemeAssets = useCallback((theme: BroadcastTheme) => {
Expand Down Expand Up @@ -182,6 +265,23 @@ function BroadcastCanvas() {
hasVerse: Boolean(event.payload.verse),
themeId: event.payload.theme.id,
})
if (event.payload.verse) {
// A fresh present (new verse, re-detected same verse, or keepalive)
// carries a new presentedAt stamp — only then fade in and re-arm.
// An unchanged stamp is an incidental re-emit (theme edit, output
// sync): redraw, but leave the fade timer running. A missing stamp
// (older payload shape) always re-arms, matching prior behavior.
const stamp = event.payload.verse.presentedAt ?? null
if (stamp === null || stamp !== lastPresentStampRef.current) {
lastPresentStampRef.current = stamp
setFadeTarget(1)
armIdleTimer(event.payload.idleFadeMs ?? DEFAULT_IDLE_FADE_MS)
}
} else {
lastPresentStampRef.current = undefined
clearIdleTimer()
setFadeTarget(0)
}
draw()
pushNdiBurst()
})
Expand Down Expand Up @@ -228,8 +328,19 @@ function BroadcastCanvas() {
unsubscribeFonts()
unlisten.then((fn) => fn())
unlistenNdiConfig.then((fn) => fn())
clearIdleTimer()
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current)
}
}, [draw, logDebug, preloadThemeAssets, pushNdiFrame, pushNdiBurst])
}, [
draw,
logDebug,
preloadThemeAssets,
pushNdiFrame,
pushNdiBurst,
setFadeTarget,
armIdleTimer,
clearIdleTimer,
])

// Slow keepalive: push one frame every 2s if idle (prevents NDI receivers from dropping the source)
useEffect(() => {
Expand Down
28 changes: 24 additions & 4 deletions src/components/panels/live-output-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useEffect } from "react"
import { useEffect, useRef, useState } from "react"
import { PanelHeader } from "@/components/ui/panel-header"
import { CanvasVerse } from "@/components/ui/canvas-verse"
import { Switch } from "@/components/ui/switch"
import { cn } from "@/lib/utils"
import { useBroadcastStore, useBibleStore } from "@/stores"
import { useBroadcastStore, useBibleStore, useSettingsStore } from "@/stores"
import { presentVerse, toVerseRenderData } from "@/hooks/use-broadcast"
import { bibleActions } from "@/hooks/use-bible"

Expand All @@ -13,6 +13,7 @@ export function LiveOutputPanel() {
const liveVerse = useBroadcastStore((s) => s.liveVerse)
const themes = useBroadcastStore((s) => s.themes)
const activeThemeId = useBroadcastStore((s) => s.activeThemeId)
const idleFadeMs = useSettingsStore((s) => s.idleFadeMs)
const activeTranslationId = useBibleStore((s) => s.activeTranslationId)

const activeTheme = themes.find((t) => t.id === activeThemeId) ?? themes[0]
Expand All @@ -21,6 +22,24 @@ export function LiveOutputPanel() {
// follows the preview selection, so detections can't override the operator.
const verseData = isLive ? liveVerse : null

// Mirror the broadcast output's idle fade in the in-app preview.
const [faded, setFaded] = useState(false)
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)

useEffect(() => {
if (idleTimerRef.current) {
clearTimeout(idleTimerRef.current)
idleTimerRef.current = null
}
setFaded(false)
if (verseData) {
idleTimerRef.current = setTimeout(() => setFaded(true), idleFadeMs)
}
return () => {
if (idleTimerRef.current) clearTimeout(idleTimerRef.current)
}
}, [verseData, idleFadeMs])

// Refetch the live verse when the translation changes so the live output
// text follows the new translation. Updates the live output only — the
// preview keeps whatever the operator is browsing.
Expand Down Expand Up @@ -99,8 +118,9 @@ export function LiveOutputPanel() {

<div
className={cn(
"flex min-h-0 flex-1 items-center justify-center p-3 transition-opacity",
!isLive && "opacity-40"
"flex min-h-0 flex-1 items-center justify-center p-3 transition-opacity duration-700",
!isLive && "opacity-40",
faded && "opacity-0"
)}
>
<CanvasVerse theme={activeTheme} verse={verseData} />
Expand Down
5 changes: 5 additions & 0 deletions src/components/panels/transcript-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { bibleActions } from "@/hooks/use-bible"
import { presentVerse } from "@/hooks/use-broadcast"
import { mark, observeLongTasks } from "@/lib/latency-marks"
import { pickAutoPresentTarget } from "@/lib/auto-present-target"
import { keepLiveVerseAlive } from "@/lib/verse-keepalive"
import type { DetectionResult, ReadingAdvance } from "@/types"

/**
Expand Down Expand Up @@ -89,6 +90,10 @@ export function TranscriptPanel() {
useDetectionStore.getState().addDetections(detections)
mark(`addDetections done src=${detections[0]?.source ?? "?"}`)

// Candidates still related to the on-air verse keep it from
// idle-fading; unrelated speech lets the fade clock run out.
keepLiveVerseAlive(detections)

// Auto-navigate book search + select verse for preview/live. Picks the
// most confident direct hit and skips operator-dismissed references —
// see pickAutoPresentTarget for why.
Expand Down
27 changes: 27 additions & 0 deletions src/components/settings-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -366,9 +366,12 @@ function DisplayModeSection() {
setAutoMode,
confidenceThreshold,
setConfidenceThreshold,
idleFadeMs,
setIdleFadeMs,
} = useSettingsStore()

const thresholdPercent = Math.round(confidenceThreshold * 100)
const idleFadeSeconds = Math.round(idleFadeMs / 1000)

return (
<div className="flex flex-col gap-6">
Expand Down Expand Up @@ -443,6 +446,30 @@ function DisplayModeSection() {
</p>
</div>
)}

{/* Idle fade timeout */}
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<label className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Idle Fade Timeout
</label>
<span className="text-xs tabular-nums text-muted-foreground">
{idleFadeSeconds}s
</span>
</div>
<Slider
min={10}
max={120}
step={5}
value={[idleFadeSeconds]}
onValueChange={([v]) => setIdleFadeMs(v * 1000)}
/>
<p className="text-[0.625rem] text-muted-foreground">
If the sermon stops relating to the displayed verse — no
re-detection or mention of it, and nothing new presented — for this
long, the broadcast display fades back to black on its own.
</p>
</div>
</div>
)
}
Expand Down
1 change: 1 addition & 0 deletions src/hooks/use-broadcast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export function toVerseRenderData(verse: Verse, translation: string): VerseRende
return {
reference: `${verse.book_name} ${verse.chapter}:${verse.verse} (${translation})`,
segments: [{ verseNumber: verse.verse, text: verse.text }],
presentedAt: Date.now(),
}
}

Expand Down
64 changes: 64 additions & 0 deletions src/lib/verse-keepalive.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest"
import { isRelatedDetection } from "./verse-keepalive"
import type { DetectionResult } from "@/types"

const GEN_1_1 = { book_number: 1, chapter: 1, verse: 1 }

function detection(overrides: Partial<DetectionResult>): DetectionResult {
return {
verse_ref: "Genesis 1:1",
verse_text: "In the beginning God created the heaven and the earth.",
book_name: "Genesis",
book_number: 1,
chapter: 1,
verse: 1,
confidence: 0.9,
source: "direct",
auto_queued: false,
transcript_snippet: "",
is_chapter_only: false,
...overrides,
}
}

describe("isRelatedDetection", () => {
it("matches an exact direct re-detection of the displayed verse", () => {
expect(isRelatedDetection(GEN_1_1, [detection({})])).toBe(true)
})

it("matches a semantic candidate for the displayed verse", () => {
expect(
isRelatedDetection(GEN_1_1, [detection({ source: "semantic", confidence: 0.4 })])
).toBe(true)
})

it("matches a chapter-only mention of the verse's book and chapter", () => {
expect(
isRelatedDetection(GEN_1_1, [detection({ is_chapter_only: true, verse: 5 })])
).toBe(true)
})

it("does not match a different verse in the same chapter", () => {
expect(isRelatedDetection(GEN_1_1, [detection({ verse: 3, verse_ref: "Genesis 1:3" })])).toBe(
false
)
})

it("does not match a different chapter or book", () => {
expect(isRelatedDetection(GEN_1_1, [detection({ chapter: 2 })])).toBe(false)
expect(isRelatedDetection(GEN_1_1, [detection({ book_number: 43 })])).toBe(false)
})

it("returns false for an empty batch", () => {
expect(isRelatedDetection(GEN_1_1, [])).toBe(false)
})

it("matches when at least one candidate in a mixed batch relates", () => {
expect(
isRelatedDetection(GEN_1_1, [
detection({ book_number: 43, chapter: 3, verse: 16, verse_ref: "John 3:16" }),
detection({ source: "semantic" }),
])
).toBe(true)
})
})
Loading