@lincyaw/addon-lecture 0.0.0-stage → 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +31 -2
  3. package/bin/presmium-lecture.mjs +12 -0
  4. package/package.json +68 -3
  5. package/src/client/ChapterList.tsx +50 -0
  6. package/src/client/LectureView.css +21 -0
  7. package/src/client/LectureView.tsx +84 -0
  8. package/src/client/PlayLectureButton.tsx +21 -0
  9. package/src/client/PlayerPanel.css +120 -0
  10. package/src/client/PlayerPanel.tsx +194 -0
  11. package/src/client/RecorderPanel.css +46 -0
  12. package/src/client/RecorderPanel.tsx +282 -0
  13. package/src/client/TranscriptList.tsx +28 -0
  14. package/src/client/clock.ts +169 -0
  15. package/src/client/env.d.ts +7 -0
  16. package/src/client/media.ts +86 -0
  17. package/src/client/playback.ts +140 -0
  18. package/src/client/player.ts +202 -0
  19. package/src/client/plugin.ts +33 -0
  20. package/src/client/pointer-ink.ts +143 -0
  21. package/src/client/production-playback.ts +188 -0
  22. package/src/client/recorder.ts +245 -0
  23. package/src/client/source.ts +109 -0
  24. package/src/client/state.ts +45 -0
  25. package/src/client/viewer.ts +37 -0
  26. package/src/domain/audience.ts +108 -0
  27. package/src/domain/caption-timing.ts +59 -0
  28. package/src/domain/captions.ts +59 -0
  29. package/src/domain/config.ts +67 -0
  30. package/src/domain/drift.ts +159 -0
  31. package/src/domain/editing.ts +112 -0
  32. package/src/domain/format.ts +13 -0
  33. package/src/domain/index.ts +50 -0
  34. package/src/domain/messages.ts +324 -0
  35. package/src/domain/narration.ts +160 -0
  36. package/src/domain/plan.ts +319 -0
  37. package/src/domain/production-audio.ts +39 -0
  38. package/src/domain/production.ts +136 -0
  39. package/src/domain/recording.ts +130 -0
  40. package/src/domain/script-track.ts +176 -0
  41. package/src/domain/timing.ts +107 -0
  42. package/src/domain/track.ts +302 -0
  43. package/src/domain/translation.ts +34 -0
  44. package/src/domain/vtt.ts +68 -0
  45. package/src/domain/words.ts +114 -0
  46. package/src/domain/writeback.ts +129 -0
  47. package/src/node/audio-authoring.ts +53 -0
  48. package/src/node/check.ts +93 -0
  49. package/src/node/cli.ts +82 -0
  50. package/src/node/clips.ts +10 -0
  51. package/src/node/engines.ts +43 -0
  52. package/src/node/export.ts +331 -0
  53. package/src/node/ffmpeg.ts +76 -0
  54. package/src/node/latest-ir.ts +54 -0
  55. package/src/node/plan.ts +35 -0
  56. package/src/node/presmium.ts +59 -0
  57. package/src/node/production-assets.ts +112 -0
  58. package/src/node/production.ts +102 -0
  59. package/src/node/renderer.ts +182 -0
  60. package/src/node/roles.ts +23 -0
  61. package/src/node/routes/production.ts +51 -0
  62. package/src/node/routes/recordings.ts +122 -0
  63. package/src/node/routes/serve-file.ts +38 -0
  64. package/src/node/routes/served-deck.ts +61 -0
  65. package/src/node/routes/server.ts +12 -0
  66. package/src/node/routes/tracks.ts +98 -0
  67. package/src/node/routes/voiceover.ts +23 -0
  68. package/src/node/store.ts +192 -0
  69. package/src/node/synthesize.ts +190 -0
  70. package/src/node/tools.ts +170 -0
  71. package/src/node/transcribe.ts +53 -0
  72. package/src/node/voice-generation.ts +15 -0
  73. package/src/node/voice-source.ts +34 -0
  74. package/src/node/voiceover-execution.ts +223 -0
@@ -0,0 +1,194 @@
1
+ import { PmCaption, PmDrawer, PmIconButton, PmMenu, PmTabs, PRODUCTION_PLAYBACK, TIMED_CAPTIONS, useMessages, useServer, useService, useStore } from '@lincyaw/host'
2
+ import type { MenuItem } from '@lincyaw/host'
3
+ import { isPreviewEmbedded } from '@lincyaw/kernel'
4
+ import { useEffect, useMemo, useState } from 'react'
5
+ import type { CSSProperties } from 'react'
6
+ import {
7
+ captionLanguages,
8
+ captionGroups,
9
+ captionGroupAt,
10
+ captionText,
11
+ clockText,
12
+ isStale,
13
+ LECTURE_ID,
14
+ LECTURE_MESSAGES,
15
+ SCRIPT_TRACK,
16
+ trackChapters,
17
+ utteranceAt,
18
+ } from '../domain/index.ts'
19
+ import type { Utterance } from '../domain/index.ts'
20
+ import ChapterList from './ChapterList.tsx'
21
+ import { useLecturePlayback } from './playback.ts'
22
+ import { lectureSource } from './source.ts'
23
+ import { PLAYBACK_RATES, ProductionPlaybackController } from './production-playback.ts'
24
+ import {
25
+ autoplay,
26
+ burnedCaptions,
27
+ exporting,
28
+ keyboardPlayer,
29
+ play,
30
+ playing,
31
+ useLecturesAvailable,
32
+ useViewer,
33
+ } from './state.ts'
34
+ import TranscriptList from './TranscriptList.tsx'
35
+ import './PlayerPanel.css'
36
+ /** `&autoplay` starts the first lecture that becomes ready on this page, once. */
37
+ let autoplayPending = autoplay
38
+ export default function PlayerPanel(_props: { view?: string }) {
39
+ const t = useMessages(LECTURE_MESSAGES),
40
+ viewer = useViewer(),
41
+ server = useServer()
42
+ const available = useLecturesAvailable(),
43
+ id = useStore(playing),
44
+ doc = useStore(viewer.doc)
45
+ const { player, status, failure, language, setLanguage } =
46
+ useLecturePlayback()
47
+ const timedCaptions = useService(TIMED_CAPTIONS)
48
+ const production = useService(PRODUCTION_PLAYBACK)
49
+ const controlled = useStore(production instanceof ProductionPlaybackController ? production.engaged : undefined, false)
50
+ useEffect(() => player && status === 'ready' ? timedCaptions?.claim() : undefined, [player, status, timedCaptions])
51
+ useEffect(() => {
52
+ if (!player || status !== 'ready' || failure || controlled || exporting) return
53
+ keyboardPlayer.set(player)
54
+ if (autoplayPending) {
55
+ autoplayPending = false
56
+ void player.play()
57
+ }
58
+ return () => { if (keyboardPlayer.getSnapshot() === player) keyboardPlayer.set(undefined) }
59
+ }, [player, status, failure, controlled])
60
+ const time = useStore(player?.time, 0),
61
+ clockPlaying = useStore(player?.clock.playing, false),
62
+ rate = useStore(player?.clock.rate, 1)
63
+ const groupedCaptions = useMemo(() => new Map(player?.track.utterances.map(utterance => [utterance.id, captionGroups(utterance)])), [player?.track])
64
+ const cursor = useStore(player?.cursor), positionMode = useStore(player?.positionMode, 'time')
65
+ const [compact, setCompact] = useState(() => matchMedia('(max-width: 479px)').matches)
66
+ useEffect(() => {
67
+ const media = matchMedia('(max-width: 479px)')
68
+ const changed = () => setCompact(media.matches)
69
+ media.addEventListener('change', changed)
70
+ return () => media.removeEventListener('change', changed)
71
+ }, [])
72
+ const [captions, setCaptions] = useState(false),
73
+ [panel, setPanel] = useState<'transcript' | 'chapters'>()
74
+ useEffect(() => {
75
+ let active = true
76
+ if (
77
+ playing.getSnapshot() ||
78
+ exporting ||
79
+ (!server.available && (isPreviewEmbedded() || !available))
80
+ )
81
+ return
82
+ void lectureSource(server, doc.config.presmium[LECTURE_ID])
83
+ .list()
84
+ .then((tracks) => {
85
+ if (active && !playing.getSnapshot() && !(production instanceof ProductionPlaybackController && production.engaged.getSnapshot()) && tracks.some(track => track.id === SCRIPT_TRACK))
86
+ play(SCRIPT_TRACK)
87
+ })
88
+ .catch((error) =>
89
+ console.warn(
90
+ '[presmium] the presentation could not list its lectures',
91
+ error,
92
+ ),
93
+ )
94
+ return () => {
95
+ active = false
96
+ }
97
+ }, [server.available, available, doc, production])
98
+ const state = !player
99
+ ? undefined
100
+ : clockPlaying
101
+ ? 'playing'
102
+ : 'paused'
103
+ const titles = new Map(
104
+ doc.sections.map((section) => [section.id, section.title]),
105
+ )
106
+ const chapters = player
107
+ ? trackChapters(player.events, (key) => titles.get(key))
108
+ : []
109
+ const languages = player ? captionLanguages(player.track) : []
110
+ const timed = player ? utteranceAt(player.track, time) : undefined
111
+ const boundary = player && cursor ? player.events.findIndex(event => event.type === 'go' && event.data.section === cursor.sectionId && Number(event.data.step ?? 0) === cursor.step) : -1
112
+ const start = player?.events[boundary]?.t, end = player?.events.slice(boundary + 1).find(event => event.type === 'go')?.t ?? player?.duration
113
+ // A positive utterance may continue through a cue into another step. Preserve that real timing.
114
+ const continuing = timed && cursor && timed.d > 0 && timed.section === cursor.sectionId && (timed.step ?? 0) <= cursor.step
115
+ const selectedZero = player && cursor && positionMode === 'step' && !clockPlaying && boundary >= 0 && start === end && time === start && !continuing
116
+ const selectedCaptions = selectedZero ? player.track.utterances.filter(utterance => utterance.section === cursor.sectionId && (utterance.step ?? 0) === cursor.step && utterance.d === 0 && utterance.t === time) : undefined
117
+ const current = selectedZero ? selectedCaptions?.[0] : timed
118
+ const voice = player?.track.voice
119
+ const unsaid = player?.track.utterances.filter((u) => u.unsaid) ?? []
120
+ const synthetic =
121
+ voice?.kind === 'synthetic' && voice.provider
122
+ ? t.player.syntheticBy(voice.provider)
123
+ : t.player.synthetic
124
+ const lang = burnedCaptions ?? language
125
+ const text = (u: Utterance) =>
126
+ lang && lang !== player?.track.lang
127
+ ? (u.translations?.[lang]?.text ?? u.text)
128
+ : captionGroupAt(groupedCaptions.get(u.id) ?? [], time)?.text ?? u.text
129
+ const manual = player?.track.captionOverrides?.find(own => own.startMs <= time && time < own.endMs), manualText = manual && captionGroupAt(manual.groups, time)?.text
130
+ const originalLanguage = !lang || lang === player?.track.lang
131
+ const caption = manual && originalLanguage ? manualText : selectedCaptions ? selectedCaptions.map(text).join(' ') : current ? text(current) : undefined
132
+ const burned =
133
+ burnedCaptions && player && (current || manual)
134
+ ? burnedCaptions === player.track.lang ? manual ? manualText : current && text(current) : current && captionText(player.track, current, burnedCaptions)
135
+ : undefined
136
+ const stale = new Set(
137
+ player && lang && lang !== player.track.lang
138
+ ? player.track.utterances.filter((u) => isStale(u, lang)).map((u) => u.id)
139
+ : [],
140
+ )
141
+ if (controlled) return player && (current || manual) && caption ? <PmCaption overlay label={t.player.captions} lines={[caption]} lang={lang} /> : null
142
+ if (!id) return null
143
+ if (exporting) return player ? <div className="pm-lecture-layer">
144
+ <div className="pm-lecture-export" data-time={Math.round(time)} data-clip={current?.clip}>
145
+ {voice?.kind === 'synthetic' && <span className="pm-lecture-player__ai pm-ui-badge pm-ui-badge--accent pm-ui-badge--pill">{synthetic}</span>}
146
+ </div>
147
+ {burned && <p className="pm-lecture-caption" lang={burnedCaptions}>{burned}</p>}
148
+ </div> : null
149
+ const problem = failure ? t.player.failed(failure) : status === 'missing' ? t.player.missing(id) : undefined
150
+ if (!player || problem) return <div className="pm-lecture-player__status" role={problem ? 'alert' : 'status'}>
151
+ <span>{problem ?? t.player.loading}</span>
152
+ {problem && <PmIconButton small icon="close" label={t.player.close} onClick={() => play(undefined)} />}
153
+ </div>
154
+ const options: MenuItem[] = [
155
+ ...(compact ? [{ id: 'previous', label: t.player.previous, icon: 'chevron-left' as const }, { id: 'next', label: t.player.next, icon: 'chevron-right' as const }] : []),
156
+ ...(compact && viewer.options.openPresenter ? [{ id: 'presenter', label: t.player.presenter, icon: 'slides' as const }] : []),
157
+ { id: 'transcript', label: t.player.transcript, icon: 'document', checked: panel === 'transcript' },
158
+ { id: 'chapters', label: t.player.chapters, icon: 'overview', checked: panel === 'chapters' },
159
+ ...PLAYBACK_RATES.map((value, index) => ({ id: `rate:${value}`, label: `${t.player.rate} · ${value}×`, checked: rate === value, separator: index === 0 })),
160
+ ...(languages.length > 1 ? languages.map((value, index) => ({ id: `language:${value}`, label: `${t.player.language} · ${value}`, checked: lang === value, separator: index === 0 })) : []),
161
+ ...(player.dropped ? [{ id: 'dropped', label: t.player.dropped(player.dropped), disabled: true, separator: true }] : []),
162
+ ...(unsaid.length ? [{ id: 'unsaid', label: t.player.unsaid(unsaid.length), disabled: true, separator: true }] : []),
163
+ { id: 'close', label: t.player.close, icon: 'close', separator: true },
164
+ ]
165
+ const selectOption = (option: string) => {
166
+ if (option === 'transcript' || option === 'chapters') setPanel(value => value === option ? undefined : option)
167
+ else if (option === 'previous') viewer.prev()
168
+ else if (option === 'next') viewer.next()
169
+ else if (option === 'presenter') viewer.options.openPresenter?.(viewer.position.getSnapshot())
170
+ else if (option.startsWith('rate:')) player.setRate(Number(option.slice(5)))
171
+ else if (option.startsWith('language:')) setLanguage(option.slice(9))
172
+ else if (option === 'close') play(undefined)
173
+ }
174
+ return <>
175
+ {(captions || current?.unsaid) && (current || manual) && caption && <PmCaption overlay label={t.player.captions} lines={[caption]} lang={lang}
176
+ data-stale={current && stale.has(current.id) ? 'true' : undefined} hint={current && stale.has(current.id) ? t.player.stale : undefined} />}
177
+ {panel && <PmDrawer open label={panel === 'transcript' ? t.player.transcript : t.player.chapters} onClose={() => setPanel(undefined)}>
178
+ <PmTabs value={panel} onValueChange={value => setPanel(value as 'transcript' | 'chapters')}
179
+ tabs={[{ value: 'transcript', label: t.player.transcript }, { value: 'chapters', label: t.player.chapters }]} label={t.player.sideTabs} />
180
+ {panel === 'transcript' ? <TranscriptList track={player.track} language={lang} current={current?.id} stale={stale} onSeek={at => player.seek(at)} />
181
+ : <ChapterList chapters={chapters} events={player.events} time={time} onSeek={at => player.seek(at)} />}
182
+ </PmDrawer>}
183
+ <div className="pm-lecture-player" role="region" aria-label={t.player.region} data-state={state} data-time={Math.round(time)} data-clip={current?.clip} data-unsaid={unsaid.length || undefined}>
184
+ <PmIconButton small className="pm-lecture-player__play" icon={clockPlaying ? 'pause' : 'play'} label={clockPlaying ? t.player.pause : t.player.play}
185
+ hint={`${clockPlaying ? t.player.pause : t.player.play} · Space`} aria-keyshortcuts="Space"
186
+ onClick={() => player.playing ? player.pause() : void player.play()} />
187
+ <span className="pm-lecture-player__time" title={t.player.time(clockText(time), clockText(player.duration))}>{clockText(time)}<span className="pm-lecture-player__time-total"> / {clockText(player.duration)}</span></span>
188
+ <input className="pm-lecture-player__seek pm-ui-range" style={{ '--pm-range-progress': `${player.duration ? Math.max(0, Math.min(100, time / player.duration * 100)) : 0}%` } as CSSProperties} type="range" min="0" max={player.duration} step="100" value={Math.round(time)}
189
+ aria-label={t.player.seek} aria-valuetext={t.player.time(clockText(time), clockText(player.duration))} onChange={event => player.seek(Number(event.target.value))} />
190
+ <PmIconButton small icon="captions" label={t.player.captions} aria-pressed={captions} onClick={() => setCaptions(value => !value)} />
191
+ <PmMenu label={t.player.more} items={options} placement="above-end" onSelect={selectOption} />
192
+ </div>
193
+ </>
194
+ }
@@ -0,0 +1,46 @@
1
+ /* Recording is for presenting, not for the preview of a workbench. */
2
+ html.pm-in-workbench .pm-lecture-recorder {
3
+ display: none;
4
+ }
5
+
6
+ .pm-lecture-recorder {
7
+ position: absolute;
8
+ top: 0.5rem;
9
+ right: 0.5rem;
10
+ z-index: 5;
11
+ box-sizing: border-box;
12
+ max-width: calc(100% - 1rem);
13
+ padding: var(--pm-space-2) var(--pm-space-3);
14
+ border: var(--pm-size-hairline) solid var(--pm-color-line);
15
+ border-radius: var(--pm-radius-card);
16
+ background: var(--pm-color-surface);
17
+ color: var(--pm-color-ink);
18
+ box-shadow: var(--pm-shadow-float);
19
+ font-family: var(--pm-font-sans);
20
+ font-size: var(--pm-text-sm);
21
+ }
22
+ .pm-lecture-recorder__row {
23
+ display: flex;
24
+ flex-wrap: wrap;
25
+ align-items: center;
26
+ gap: 0.4rem;
27
+ }
28
+ .pm-lecture-recorder__field {
29
+ display: inline-flex;
30
+ align-items: center;
31
+ gap: 0.25rem;
32
+ }
33
+ .pm-lecture-recorder__status {
34
+ margin: 0.25rem 0 0;
35
+ }
36
+ .pm-lecture-recorder__dot {
37
+ margin-inline-end: 0.35em;
38
+ color: var(--pm-color-danger);
39
+ }
40
+ .pm-lecture-surface {
41
+ position: fixed;
42
+ inset: 0 0 var(--pm-overlay-bar, 3rem);
43
+ z-index: -1;
44
+ cursor: crosshair;
45
+ touch-action: none;
46
+ }
@@ -0,0 +1,282 @@
1
+ import {
2
+ blockAt,
3
+ linesText,
4
+ OPERATIONS,
5
+ PmIcon,
6
+ useMessages,
7
+ useServer,
8
+ useService,
9
+ useStore,
10
+ } from '@lincyaw/host'
11
+ import type { BlockPoint } from '@lincyaw/host'
12
+ import { useEffect, useRef, useState } from 'react'
13
+ import { clockText, LECTURE_MESSAGES, simplifyPath } from '../domain/index.ts'
14
+ import type { PointerEvent as ReactPointerEvent } from 'react'
15
+ import type { PathPoint } from '../domain/index.ts'
16
+ import { applyInk } from './pointer-ink.ts'
17
+ import { LectureRecorder } from './recorder.ts'
18
+ import { serverSource } from './source.ts'
19
+ import { playing, refreshLectures, useViewer } from './state.ts'
20
+ import './RecorderPanel.css'
21
+ const cls = 'pm-lecture-button pm-ui-button pm-ui-button--small'
22
+ export default function RecorderPanel(_props: { view?: string }) {
23
+ const t = useMessages(LECTURE_MESSAGES),
24
+ viewer = useViewer(),
25
+ server = useServer(),
26
+ operations = useService(OPERATIONS)
27
+ const id = useStore(playing),
28
+ member = useStore(server.member, undefined)
29
+ const [recorder, setRecorder] = useState<LectureRecorder>(),
30
+ [trackIds, setTrackIds] = useState<string[]>([]),
31
+ [trackId, setTrackId] = useState(''),
32
+ [camera, setCamera] = useState(false),
33
+ [screen, setScreen] = useState(false),
34
+ [tool, setTool] = useState<'laser' | 'marker'>()
35
+ const state = useStore(recorder?.state, undefined),
36
+ elapsed = useStore(recorder?.elapsed, 0),
37
+ failure = useStore(recorder?.error, undefined)
38
+ const shown = server.available && !id,
39
+ recording = state === 'recording',
40
+ busy = ['starting', 'recording', 'saving'].includes(state ?? '')
41
+ const surface = useRef<HTMLDivElement>(null),
42
+ stroke = useRef<BlockPoint>()
43
+ const loadTrackIds = async () =>
44
+ setTrackIds(
45
+ (
46
+ await serverSource(server)
47
+ .list()
48
+ .catch(() => [])
49
+ ).map((item) => item.id),
50
+ )
51
+ useEffect(() => {
52
+ if (shown) void loadTrackIds()
53
+ }, [shown])
54
+ async function start() {
55
+ if (!operations || busy) return
56
+ const next = new LectureRecorder({
57
+ server,
58
+ operations,
59
+ doc: () => viewer.doc.getSnapshot(),
60
+ position: () => viewer.position.getSnapshot(),
61
+ speaker: member?.name,
62
+ camera,
63
+ screen,
64
+ track: trackId || undefined,
65
+ })
66
+ setRecorder(next)
67
+ await next.start()
68
+ }
69
+ async function stop() {
70
+ await recorder?.stop()
71
+ await Promise.all([loadTrackIds(), refreshLectures()])
72
+ }
73
+ useEffect(() => {
74
+ let path:
75
+ | { block: string; line?: number; from: number; points: PathPoint[] }
76
+ | undefined
77
+ let sampled = 0,
78
+ idle: ReturnType<typeof setTimeout> | undefined
79
+ function flush() {
80
+ clearTimeout(idle)
81
+ const done = path
82
+ path = undefined
83
+ if (!done || !operations) return
84
+ const data = {
85
+ block: done.block,
86
+ path: simplifyPath(done.points),
87
+ ...(done.line ? { line: done.line } : {}),
88
+ }
89
+ recorder?.notePointerPathStart(data, done.from)
90
+ operations.report({ type: 'point', data })
91
+ }
92
+ function pointer(event: PointerEvent) {
93
+ if (!recording || event.timeStamp - sampled < 50) return
94
+ sampled = event.timeStamp
95
+ const at = blockAt(event.clientX, event.clientY, {
96
+ skip: surface.current ?? undefined,
97
+ within: '.pm-present',
98
+ })
99
+ if (!at || (path && path.block !== at.key)) flush()
100
+ if (!at) return
101
+ path ??= { block: at.key, from: event.timeStamp, points: [] }
102
+ path.points.push([event.timeStamp - path.from, at.x, at.y])
103
+ if (at.line) path.line = at.line
104
+ clearTimeout(idle)
105
+ idle = setTimeout(flush, 400)
106
+ if (event.timeStamp - path.from >= 1000) flush()
107
+ }
108
+ window.addEventListener('pointermove', pointer, { passive: true })
109
+ return () => {
110
+ window.removeEventListener('pointermove', pointer)
111
+ flush()
112
+ }
113
+ }, [recording, recorder, operations])
114
+ useEffect(
115
+ () => () => {
116
+ void recorder?.stop()
117
+ },
118
+ [recorder],
119
+ )
120
+ function onUp(event: ReactPointerEvent) {
121
+ const start = stroke.current
122
+ stroke.current = undefined
123
+ if (!start || !tool) return
124
+ const box = start.element.getBoundingClientRect()
125
+ const clamp = (n: number) =>
126
+ Math.round(Math.max(0, Math.min(1, n)) * 1000) / 1000
127
+ const last = blockAt(event.clientX, event.clientY, {
128
+ skip: surface.current ?? undefined,
129
+ within: '.pm-present',
130
+ })
131
+ const lines =
132
+ start.line && last && last.key === start.key && last.line
133
+ ? linesText(
134
+ Array.from(
135
+ { length: Math.abs(last.line - start.line) + 1 },
136
+ (_, k) => Math.min(start.line!, last.line!) + k,
137
+ ),
138
+ )
139
+ : undefined
140
+ const data = {
141
+ block: start.key,
142
+ tool,
143
+ ...(lines
144
+ ? { lines }
145
+ : {
146
+ range: [
147
+ clamp(start.x),
148
+ clamp(start.y),
149
+ clamp((event.clientX - box.left) / box.width),
150
+ clamp((event.clientY - box.top) / box.height),
151
+ ],
152
+ }),
153
+ }
154
+ applyInk(data, false)
155
+ operations?.report({ type: 'ink', data })
156
+ }
157
+ function clear() {
158
+ const data = { clear: true }
159
+ applyInk(data, false)
160
+ operations?.report({ type: 'ink', data })
161
+ }
162
+ const status =
163
+ state === 'recording'
164
+ ? t.recorder.recording(clockText(elapsed))
165
+ : state === 'saving'
166
+ ? t.recorder.saving
167
+ : state === 'saved'
168
+ ? t.recorder.saved(recorder?.id ?? '')
169
+ : state === 'failed'
170
+ ? t.recorder.failed(failure ?? '')
171
+ : ''
172
+ return shown ? (
173
+ <div
174
+ className="pm-lecture-recorder"
175
+ role="region"
176
+ aria-label={t.recorder.region}
177
+ data-state={state}
178
+ >
179
+ {tool && (
180
+ <div
181
+ ref={surface}
182
+ className="pm-lecture-surface"
183
+ data-tool={tool}
184
+ onPointerDown={(event) => {
185
+ stroke.current = blockAt(event.clientX, event.clientY, {
186
+ skip: surface.current ?? undefined,
187
+ within: '.pm-present',
188
+ })
189
+ }}
190
+ onPointerUp={onUp}
191
+ />
192
+ )}
193
+ <div className="pm-lecture-recorder__row">
194
+ {!busy ? (
195
+ <>
196
+ {!!trackIds.length && (
197
+ <label className="pm-lecture-recorder__field">
198
+ {t.recorder.track}
199
+ <select
200
+ value={trackId}
201
+ onChange={(e) => setTrackId(e.target.value)}
202
+ className="pm-ui-select"
203
+ >
204
+ <option value="">{t.recorder.newTrack}</option>
205
+ {trackIds.map((value) => (
206
+ <option key={value} value={value}>
207
+ {value}
208
+ </option>
209
+ ))}
210
+ </select>
211
+ </label>
212
+ )}
213
+ <label className="pm-lecture-recorder__field">
214
+ <input
215
+ checked={camera}
216
+ onChange={(e) => setCamera(e.target.checked)}
217
+ className="pm-ui-checkbox"
218
+ type="checkbox"
219
+ />{' '}
220
+ {t.recorder.camera}
221
+ </label>
222
+ <label className="pm-lecture-recorder__field">
223
+ <input
224
+ checked={screen}
225
+ onChange={(e) => setScreen(e.target.checked)}
226
+ className="pm-ui-checkbox"
227
+ type="checkbox"
228
+ />{' '}
229
+ {t.recorder.screen}
230
+ </label>
231
+ <button
232
+ type="button"
233
+ className={`${cls} pm-ui-button--primary pm-lecture-recorder__record`}
234
+ onClick={() => void start()}
235
+ >
236
+ {trackId ? t.recorder.rerecord : t.recorder.record}
237
+ </button>
238
+ </>
239
+ ) : recording ? (
240
+ <button
241
+ type="button"
242
+ className={`${cls} pm-ui-button--danger pm-lecture-recorder__stop`}
243
+ onClick={() => void stop()}
244
+ >
245
+ {t.recorder.stop}
246
+ </button>
247
+ ) : null}
248
+ <button
249
+ type="button"
250
+ className={cls}
251
+ aria-pressed={tool === 'laser'}
252
+ onClick={() =>
253
+ setTool((value) => (value === 'laser' ? undefined : 'laser'))
254
+ }
255
+ >
256
+ {t.recorder.laser}
257
+ </button>
258
+ <button
259
+ type="button"
260
+ className={cls}
261
+ aria-pressed={tool === 'marker'}
262
+ onClick={() =>
263
+ setTool((value) => (value === 'marker' ? undefined : 'marker'))
264
+ }
265
+ >
266
+ {t.recorder.marker}
267
+ </button>
268
+ <button type="button" className={cls} onClick={clear}>
269
+ {t.recorder.clear}
270
+ </button>
271
+ </div>
272
+ {status && (
273
+ <p className="pm-lecture-recorder__status" role="status">
274
+ {recording && (
275
+ <PmIcon className="pm-lecture-recorder__dot" name="record" />
276
+ )}
277
+ {status}
278
+ </p>
279
+ )}
280
+ </div>
281
+ ) : null
282
+ }
@@ -0,0 +1,28 @@
1
+ import { PmIcon, useMessages } from '@lincyaw/host'
2
+ import { clockText, LECTURE_MESSAGES, textPieces } from '../domain/index.ts'
3
+ import type { LectureTrack, Utterance } from '../domain/index.ts'
4
+
5
+ /** Published captions are a playback view. Authoring belongs to the document transaction API. */
6
+ export default function TranscriptList({ track, language, current, stale, onSeek }: {
7
+ track: LectureTrack
8
+ language?: string
9
+ current?: string
10
+ stale: ReadonlySet<string>
11
+ onSeek(t: number): void
12
+ }) {
13
+ const t = useMessages(LECTURE_MESSAGES)
14
+ const text = (utterance: Utterance) => language && language !== track.lang ? utterance.translations?.[language]?.text ?? utterance.text : utterance.text
15
+ return (
16
+ <ol className="pm-lecture-transcript" lang={language}>
17
+ {track.utterances.map(utterance => (
18
+ <li key={utterance.id} className="pm-lecture-transcript__item" data-utterance={utterance.id} data-stale={stale.has(utterance.id) ? 'true' : undefined} aria-current={current === utterance.id ? 'true' : undefined}>
19
+ <button type="button" className="pm-lecture-transcript__time" aria-label={t.player.jump(clockText(utterance.t))} onClick={() => onSeek(utterance.t)}>{clockText(utterance.t)}</button>
20
+ <span className="pm-lecture-transcript__text" onClick={() => onSeek(utterance.t)}>
21
+ {language === track.lang ? textPieces(utterance)?.map((piece, index) => piece.t !== undefined ? <span key={index} className="pm-lecture-word" onClick={event => { event.stopPropagation(); onSeek(piece.t!) }}>{piece.text}</span> : piece.text) ?? text(utterance) : text(utterance)}
22
+ </span>
23
+ {stale.has(utterance.id) && <span className="pm-lecture-stale" role="img" title={t.player.stale} aria-label={t.player.stale}><PmIcon name="warning" /></span>}
24
+ </li>
25
+ ))}
26
+ </ol>
27
+ )
28
+ }