@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.
- package/LICENSE +202 -0
- package/README.md +31 -2
- package/bin/presmium-lecture.mjs +12 -0
- package/package.json +68 -3
- package/src/client/ChapterList.tsx +50 -0
- package/src/client/LectureView.css +21 -0
- package/src/client/LectureView.tsx +84 -0
- package/src/client/PlayLectureButton.tsx +21 -0
- package/src/client/PlayerPanel.css +120 -0
- package/src/client/PlayerPanel.tsx +194 -0
- package/src/client/RecorderPanel.css +46 -0
- package/src/client/RecorderPanel.tsx +282 -0
- package/src/client/TranscriptList.tsx +28 -0
- package/src/client/clock.ts +169 -0
- package/src/client/env.d.ts +7 -0
- package/src/client/media.ts +86 -0
- package/src/client/playback.ts +140 -0
- package/src/client/player.ts +202 -0
- package/src/client/plugin.ts +33 -0
- package/src/client/pointer-ink.ts +143 -0
- package/src/client/production-playback.ts +188 -0
- package/src/client/recorder.ts +245 -0
- package/src/client/source.ts +109 -0
- package/src/client/state.ts +45 -0
- package/src/client/viewer.ts +37 -0
- package/src/domain/audience.ts +108 -0
- package/src/domain/caption-timing.ts +59 -0
- package/src/domain/captions.ts +59 -0
- package/src/domain/config.ts +67 -0
- package/src/domain/drift.ts +159 -0
- package/src/domain/editing.ts +112 -0
- package/src/domain/format.ts +13 -0
- package/src/domain/index.ts +50 -0
- package/src/domain/messages.ts +324 -0
- package/src/domain/narration.ts +160 -0
- package/src/domain/plan.ts +319 -0
- package/src/domain/production-audio.ts +39 -0
- package/src/domain/production.ts +136 -0
- package/src/domain/recording.ts +130 -0
- package/src/domain/script-track.ts +176 -0
- package/src/domain/timing.ts +107 -0
- package/src/domain/track.ts +302 -0
- package/src/domain/translation.ts +34 -0
- package/src/domain/vtt.ts +68 -0
- package/src/domain/words.ts +114 -0
- package/src/domain/writeback.ts +129 -0
- package/src/node/audio-authoring.ts +53 -0
- package/src/node/check.ts +93 -0
- package/src/node/cli.ts +82 -0
- package/src/node/clips.ts +10 -0
- package/src/node/engines.ts +43 -0
- package/src/node/export.ts +331 -0
- package/src/node/ffmpeg.ts +76 -0
- package/src/node/latest-ir.ts +54 -0
- package/src/node/plan.ts +35 -0
- package/src/node/presmium.ts +59 -0
- package/src/node/production-assets.ts +112 -0
- package/src/node/production.ts +102 -0
- package/src/node/renderer.ts +182 -0
- package/src/node/roles.ts +23 -0
- package/src/node/routes/production.ts +51 -0
- package/src/node/routes/recordings.ts +122 -0
- package/src/node/routes/serve-file.ts +38 -0
- package/src/node/routes/served-deck.ts +61 -0
- package/src/node/routes/server.ts +12 -0
- package/src/node/routes/tracks.ts +98 -0
- package/src/node/routes/voiceover.ts +23 -0
- package/src/node/store.ts +192 -0
- package/src/node/synthesize.ts +190 -0
- package/src/node/tools.ts +170 -0
- package/src/node/transcribe.ts +53 -0
- package/src/node/voice-generation.ts +15 -0
- package/src/node/voice-source.ts +34 -0
- 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
|
+
}
|