@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,169 @@
1
+ import type { Store } from '@lincyaw/host'
2
+ import { createStore } from '@lincyaw/host'
3
+
4
+ export interface TrackClock {
5
+ now(): number
6
+ readonly playing: Readonly<Store<boolean>>
7
+ readonly rate: Readonly<Store<number>>
8
+ readonly error: Readonly<Store<Error | undefined>>
9
+ play(): void
10
+ pause(): void
11
+ seek(t: number): void
12
+ setRate(rate: number): void
13
+ tick(): void
14
+ dispose(): void
15
+ }
16
+
17
+ export class SilentClock implements TrackClock {
18
+ readonly playing = createStore(false)
19
+ readonly rate = createStore(1)
20
+ readonly error = createStore<Error | undefined>(undefined)
21
+ protected at = 0
22
+ protected since = performance.now()
23
+ protected readonly duration: number
24
+
25
+ constructor(duration: number) {
26
+ this.duration = duration
27
+ }
28
+
29
+ now(): number {
30
+ const t = this.playing.getSnapshot() ? this.at + (performance.now() - this.since) * this.rate.getSnapshot() : this.at
31
+ return Math.min(t, this.duration)
32
+ }
33
+
34
+ play(): void {
35
+ if (this.playing.getSnapshot()) return
36
+ if (this.at >= this.duration) this.at = 0
37
+ this.since = performance.now()
38
+ this.playing.set(true)
39
+ }
40
+
41
+ pause(): void {
42
+ this.at = this.now()
43
+ this.playing.set(false)
44
+ }
45
+
46
+ seek(t: number): void {
47
+ this.at = Math.max(0, Math.min(t, this.duration))
48
+ this.since = performance.now()
49
+ }
50
+
51
+ setRate(rate: number): void {
52
+ this.at = this.now()
53
+ this.since = performance.now()
54
+ this.rate.set(rate)
55
+ }
56
+
57
+ tick(): void {
58
+ if (this.playing.getSnapshot() && this.now() >= this.duration) this.pause()
59
+ }
60
+
61
+ dispose(): void {
62
+ this.pause()
63
+ }
64
+ }
65
+
66
+ export interface ClockMedia {
67
+ url: string
68
+ start: number
69
+ duration: number
70
+ sourceStartMs?: number
71
+ volume?: number
72
+ muted?: boolean
73
+ }
74
+
75
+ const DRIFT_MS = 250
76
+
77
+ export class MediaClock extends SilentClock {
78
+ private readonly media: { el: HTMLAudioElement, start: number, duration: number, sourceStartMs: number }[]
79
+
80
+ constructor(duration: number, media: ClockMedia[]) {
81
+ super(duration)
82
+ this.media = media.map(({ url, start, duration: length, sourceStartMs = 0, volume = 1, muted = false }) => {
83
+ const el = new Audio()
84
+ el.preload = 'auto'
85
+ el.onerror = () => this.fail(new Error([url, el.error?.message].filter(Boolean).join(': ')))
86
+ el.src = url
87
+ el.volume = volume
88
+ el.muted = muted
89
+ return { el, start, duration: length, sourceStartMs }
90
+ })
91
+ }
92
+
93
+ private fail(error: Error): void {
94
+ this.pause()
95
+ this.error.set(error)
96
+ }
97
+
98
+ private mediaAt(t: number) {
99
+ return this.media.find(m => t >= m.start && t < m.start + m.duration)
100
+ }
101
+
102
+ override now(): number {
103
+ const current = this.playing.getSnapshot() ? this.mediaAt(super.now()) : undefined
104
+ if (current && !current.el.paused && current.el.readyState >= 2) {
105
+ const t = Math.min(current.start + current.duration, current.start + current.el.currentTime * 1000 - current.sourceStartMs)
106
+ this.at = Math.min(t, this.duration)
107
+ this.since = performance.now()
108
+ return this.at
109
+ }
110
+ return super.now()
111
+ }
112
+
113
+ override play(): void {
114
+ super.play()
115
+ this.tick()
116
+ }
117
+
118
+ override pause(): void {
119
+ super.pause()
120
+ for (const { el } of this.media) el.pause()
121
+ }
122
+
123
+ override seek(t: number): void {
124
+ super.seek(t)
125
+ for (const m of this.media) {
126
+ if (t >= m.start && t < m.start + m.duration) m.el.currentTime = (m.sourceStartMs + t - m.start) / 1000
127
+ else m.el.pause()
128
+ }
129
+ this.tick()
130
+ }
131
+
132
+ override setRate(rate: number): void {
133
+ super.setRate(rate)
134
+ for (const { el } of this.media) el.playbackRate = rate
135
+ }
136
+
137
+ override tick(): void {
138
+ super.tick()
139
+ const t = this.now()
140
+ for (const m of this.media) {
141
+ if (!this.playing.getSnapshot() || t < m.start || t >= m.start + m.duration) {
142
+ if (!m.el.paused) m.el.pause()
143
+ continue
144
+ }
145
+ const offset = (m.sourceStartMs + t - m.start) / 1000
146
+ if (m.el.paused || Math.abs(m.el.currentTime - offset) * 1000 > DRIFT_MS) m.el.currentTime = offset
147
+ m.el.playbackRate = this.rate.getSnapshot()
148
+ if (m.el.paused) void m.el.play().then(() => {
149
+ const at = super.now()
150
+ if (!this.playing.getSnapshot() || at < m.start || at >= m.start + m.duration) m.el.pause()
151
+ }).catch((error: unknown) => {
152
+ if (error instanceof DOMException && error.name === 'AbortError') return
153
+ this.fail(error instanceof Error ? error : new Error(String(error)))
154
+ })
155
+ }
156
+ }
157
+
158
+ override dispose(): void {
159
+ super.dispose()
160
+ for (const { el } of this.media) {
161
+ el.pause()
162
+ el.onerror = null
163
+ const url = el.src
164
+ el.removeAttribute('src')
165
+ el.load()
166
+ if (url.startsWith('blob:')) URL.revokeObjectURL(url)
167
+ }
168
+ }
169
+ }
@@ -0,0 +1,7 @@
1
+ interface ImportMetaEnv {
2
+ BASE_URL: string
3
+ }
4
+
5
+ interface ImportMeta {
6
+ readonly env: ImportMetaEnv
7
+ }
@@ -0,0 +1,86 @@
1
+ import { canEncodeAudio, getFirstEncodableVideoCodec, MediaStreamAudioTrackSource, MediaStreamVideoTrackSource, OggOutputFormat, Output, Quality, StreamTarget, WebMOutputFormat } from 'mediabunny'
2
+
3
+ export interface Chunk {
4
+ data: Uint8Array<ArrayBuffer>
5
+ position: number
6
+ }
7
+
8
+ export type Sink = (chunk: Chunk) => void
9
+
10
+ export interface Capture {
11
+ ext: 'ogg' | 'webm' | 'mp4'
12
+ stop(): Promise<void>
13
+ failed: Promise<never>
14
+ }
15
+
16
+ const SPEECH = new Quality({ bitrate: 24_000 })
17
+
18
+ function target(sink: Sink): StreamTarget {
19
+ return new StreamTarget(new WritableStream({ write: chunk => sink({ data: chunk.data, position: chunk.position }) }))
20
+ }
21
+
22
+ async function withOutput(output: Output, clone: MediaStreamTrack, source: MediaStreamAudioTrackSource | MediaStreamVideoTrackSource, ext: Capture['ext']): Promise<Capture> {
23
+ if (source instanceof MediaStreamAudioTrackSource) output.addAudioTrack(source)
24
+ else output.addVideoTrack(source)
25
+ const failed = source.errorPromise.then(() => new Promise<never>(() => {}))
26
+ await output.start()
27
+ return {
28
+ ext,
29
+ failed,
30
+ async stop() {
31
+ await output.finalize()
32
+ clone.stop()
33
+ },
34
+ }
35
+ }
36
+
37
+ function withRecorder(clone: MediaStreamTrack, sink: Sink): Capture | undefined {
38
+ if (typeof MediaRecorder === 'undefined') return undefined
39
+ const type = ['audio/ogg;codecs=opus', 'audio/webm;codecs=opus', 'audio/mp4'].find(t => MediaRecorder.isTypeSupported(t))
40
+ if (!type) return undefined
41
+ const recorder = new MediaRecorder(new MediaStream([clone]), { mimeType: type, audioBitsPerSecond: 24_000 })
42
+ let position = 0
43
+ let writing = Promise.resolve()
44
+ recorder.ondataavailable = (event) => {
45
+ writing = writing.then(async () => {
46
+ const data = new Uint8Array(await event.data.arrayBuffer())
47
+ sink({ data, position })
48
+ position += data.byteLength
49
+ })
50
+ }
51
+ const failed = new Promise<never>((_, fail) => {
52
+ recorder.onerror = event => fail(new Error(String((event as ErrorEvent).message ?? 'MediaRecorder failed')))
53
+ })
54
+ recorder.start(1000)
55
+ return {
56
+ ext: type.startsWith('audio/ogg') ? 'ogg' : type.startsWith('audio/webm') ? 'webm' : 'mp4',
57
+ failed,
58
+ stop: () => new Promise((done) => {
59
+ recorder.onstop = () => void writing.then(() => {
60
+ clone.stop()
61
+ done()
62
+ })
63
+ recorder.stop()
64
+ }),
65
+ }
66
+ }
67
+
68
+ export async function captureAudio(track: MediaStreamTrack, sink: Sink): Promise<Capture | undefined> {
69
+ const clone = track.clone()
70
+ if (await canEncodeAudio('opus').catch(() => false)) {
71
+ const output = new Output({ format: new OggOutputFormat({ maximumPageDuration: 1 }), target: target(sink) })
72
+ return withOutput(output, clone, new MediaStreamAudioTrackSource(clone as MediaStreamAudioTrack, { codec: 'opus', quality: SPEECH }), 'ogg')
73
+ }
74
+ const fallback = withRecorder(clone, sink)
75
+ if (!fallback) clone.stop()
76
+ return fallback
77
+ }
78
+
79
+ export async function captureVideo(track: MediaStreamTrack, sink: Sink): Promise<Capture | undefined> {
80
+ const { width, height } = track.getSettings()
81
+ const codec = await getFirstEncodableVideoCodec(['vp9', 'vp8', 'av1'], { width, height }).catch(() => null)
82
+ if (!codec) return undefined
83
+ const clone = track.clone()
84
+ const output = new Output({ format: new WebMOutputFormat({ minimumClusterDuration: 1 }), target: target(sink) })
85
+ return withOutput(output, clone, new MediaStreamVideoTrackSource(clone as MediaStreamVideoTrack, { codec, quality: new Quality('medium') }), 'webm')
86
+ }
@@ -0,0 +1,140 @@
1
+ import { OPERATIONS, PLAYBACK_CONTROL, PRODUCTION_PLAYBACK, STEP_CLOCK, useMessages, useServer, useService, useStore } from '@lincyaw/host'
2
+ import type { Operations, StepClock } from '@lincyaw/host'
3
+ import { errorMessage, PREVIEW_REVISION, sameProductionBinding } from '@lincyaw/kernel'
4
+ import type { ProductionBinding, ProductionPlan } from '@lincyaw/kernel'
5
+ import { flushSync } from 'react-dom'
6
+ import { useEffect, useRef, useState } from 'react'
7
+ import { audioSegments, LECTURE_ID, LECTURE_MESSAGES, SCRIPT_TRACK, timedEvents, trackDuration } from '../domain/index.ts'
8
+ import type { LectureTrack } from '../domain/index.ts'
9
+ import type { ClockMedia, TrackClock } from './clock.ts'
10
+ import { MediaClock, SilentClock } from './clock.ts'
11
+ import { LecturePlayer } from './player.ts'
12
+ import type { LectureSource } from './source.ts'
13
+ import { lectureSource } from './source.ts'
14
+ import { ProductionPlaybackController, settleProductionPlayback } from './production-playback.ts'
15
+ import { exporting, play, playing, useViewer } from './state.ts'
16
+
17
+ async function createClock(track: LectureTrack, source: LectureSource, signal?: AbortSignal): Promise<TrackClock> {
18
+ const length = trackDuration(track)
19
+ const segments = audioSegments(track)
20
+ if (!segments.length) return new SilentClock(length)
21
+ const loaded = await Promise.allSettled(segments.map(async (segment): Promise<ClockMedia> => {
22
+ if (track.binding && source.productionMedia && !segment.clipId) throw new Error('production-clip-unavailable')
23
+ const url = track.binding && source.productionMedia && segment.clipId ? await source.productionMedia(track.binding, segment.clipId, signal) : await source.media(track.id, segment.src)
24
+ return { url, start: segment.start, duration: segment.duration, sourceStartMs: segment.sourceStartMs, volume: segment.volume, muted: segment.muted }
25
+ }))
26
+ const media = loaded.flatMap(result => result.status === 'fulfilled' ? [result.value] : [])
27
+ const failed = loaded.find(result => result.status === 'rejected')
28
+ if (failed || signal?.aborted) { for (const { url } of media) if (url.startsWith('blob:')) URL.revokeObjectURL(url); if (failed) throw failed.reason; signal?.throwIfAborted() }
29
+ return new MediaClock(length, media)
30
+ }
31
+ function installExportHook(player: LecturePlayer, clock: StepClock | undefined): void {
32
+ const frame = () => new Promise<void>(done => requestAnimationFrame(() => done()))
33
+ Object.assign(window, { presmiumLecture: { duration: player.duration, async advance(at: number) {
34
+ flushSync(() => player.advanceTo(at)); await clock?.settled(); await frame(); await frame()
35
+ } } })
36
+ }
37
+ export function useLecturePlayback() {
38
+ const viewer = useViewer()
39
+ const doc = useStore(viewer.doc)
40
+ const server = useServer()
41
+ const operations = useService(OPERATIONS)
42
+ const stepClock = useService(STEP_CLOCK)
43
+ const playback = useService(PLAYBACK_CONTROL)
44
+ const provided = useService(PRODUCTION_PLAYBACK)
45
+ const production = provided instanceof ProductionPlaybackController ? provided : undefined
46
+ const t = useMessages(LECTURE_MESSAGES)
47
+ const [player, setPlayer] = useState<LecturePlayer>()
48
+ const [status, setStatus] = useState<'loading' | 'missing' | 'ready' | 'failed' | 'estimated' | 'unavailable'>()
49
+ const [failure, setFailure] = useState<string>()
50
+ const [language, setLanguage] = useState<string>()
51
+ const current = useRef<LecturePlayer>()
52
+ const generation = useRef(0)
53
+ const releaseClock = useRef<() => void>(() => {})
54
+ const releaseProduction = useRef<() => void>(() => {})
55
+ const clear = () => { releaseProduction.current(); releaseProduction.current = () => {}; current.current?.dispose(); current.current = undefined; releaseClock.current(); releaseClock.current = () => {} }
56
+ const start = async (track: LectureTrack, source: LectureSource, ops: Operations, load: number, at = 0, plan?: ProductionPlan, signal?: AbortSignal, origin?: { section: string, step: number }) => {
57
+ const clock = await createClock(track, source, signal)
58
+ if (load !== generation.current || signal?.aborted) { clock.dispose(); signal?.throwIfAborted(); return }
59
+ clear()
60
+ const next = new LecturePlayer(track, doc, ops, clock, playback, Boolean(plan))
61
+ current.current = next
62
+ releaseClock.current = stepClock?.drive({ elapsed: next.stepTime, recording: exporting }) ?? (() => {})
63
+ const seek = () => {
64
+ if (origin && next.seekStep(origin.section, origin.step)) return
65
+ if (origin && plan) { clear(); throw new Error('production-binding-stale') }
66
+ next.seek(at)
67
+ }
68
+ if (plan) flushSync(seek)
69
+ else seek()
70
+ if (plan && production) {
71
+ await settleProductionPlayback(stepClock, signal)
72
+ if (load !== generation.current || signal?.aborted) { signal?.throwIfAborted(); return }
73
+ releaseProduction.current = production.attach(plan, next, stepClock)
74
+ }
75
+ setPlayer(next); setLanguage(old => old ?? track.lang); setStatus('ready')
76
+ if (exporting) installExportHook(next, stepClock)
77
+ }
78
+ useEffect(() => {
79
+ clear(); setPlayer(undefined); setFailure(undefined)
80
+ if (!operations) { setStatus(undefined); return }
81
+ const source = lectureSource(server, doc.config.presmium[LECTURE_ID])
82
+ const currentBinding = (binding: ProductionBinding) => viewer.doc.getSnapshot() === doc && doc.source?.documentId === binding.source.documentId && doc.source.version === binding.source.version && doc.source.fingerprint === binding.source.fingerprint && doc.meta[PREVIEW_REVISION] === binding.irRevision
83
+ const loadTrack = async (id: string | undefined, binding?: ProductionBinding, signal?: AbortSignal) => {
84
+ const load = ++generation.current
85
+ const check = () => { signal?.throwIfAborted(); if (load !== generation.current || viewer.doc.getSnapshot() !== doc) throw new Error('production-binding-stale') }
86
+ if (binding && !currentBinding(binding)) throw new Error('production-binding-stale')
87
+ current.current?.pause(); setFailure(undefined)
88
+ if (!id) { clear(); setPlayer(undefined); setStatus(undefined); return }
89
+ setStatus('loading')
90
+ let plan: ProductionPlan | undefined
91
+ if (binding) {
92
+ if (!source.plan || !production) throw new Error('production-playback-unavailable')
93
+ plan = await source.plan({ source: binding.source, irRevision: binding.irRevision }, signal); check()
94
+ if (!sameProductionBinding(plan.binding, binding)) throw new Error('production-binding-stale')
95
+ clear(); setPlayer(undefined); production.loading(binding)
96
+ if (!plan.canPreview) {
97
+ releaseProduction.current = production.attach(plan)
98
+ setStatus(plan.timing === 'estimated' ? 'estimated' : 'unavailable')
99
+ return
100
+ }
101
+ }
102
+ const track = await source.track(id, plan?.binding.planId, signal); check()
103
+ if (!track) { clear(); setPlayer(undefined); setStatus('missing'); throw new Error('production-track-unavailable') }
104
+ if (binding && (!track.binding || !sameProductionBinding(track.binding, binding))) throw new Error('production-binding-stale')
105
+ // An estimate remains a caption fallback for ordinary readers; explicit production loads inspect its plan.
106
+ if (!binding && !exporting && track.id === SCRIPT_TRACK && track.utterances.length > 0 && track.utterances.every(utterance => Boolean(utterance.unsaid)) && audioSegments(track).length === 0) { play(undefined); return }
107
+ const position = viewer.position.getSnapshot()
108
+ const at = exporting ? 0 : timedEvents(track).find(event => event.type === 'go' && event.data.section === position.section && Number(event.data.step ?? 0) === position.step)?.t ?? 0
109
+ await start(track, source, operations, load, at, plan, signal, exporting ? undefined : position); check()
110
+ }
111
+ let selecting = false
112
+ const select = () => {
113
+ if (selecting) return
114
+ const request = generation.current + 1
115
+ void loadTrack(playing.getSnapshot()).catch(error => { if (generation.current === request) { setFailure(errorMessage(error)); setStatus('failed') } })
116
+ }
117
+ const stopSelect = playing.subscribe(select)
118
+ const stopLoader = production?.register({ current: currentBinding, async load(binding, signal) {
119
+ try {
120
+ await loadTrack(SCRIPT_TRACK, binding, signal)
121
+ selecting = true; play(SCRIPT_TRACK); selecting = false
122
+ } catch (error) {
123
+ if (!signal.aborted && currentBinding(binding)) { setFailure(errorMessage(error)); setStatus('failed') }
124
+ throw error
125
+ }
126
+ } })
127
+ select()
128
+ return () => { generation.current++; stopSelect(); stopLoader?.(); clear() }
129
+ }, [operations, doc, stepClock, playback, server.available, production])
130
+ // Cancel permission synchronously when IR changes, before a late parent grant can run.
131
+ useEffect(() => viewer.doc.subscribe(() => current.current?.pause()), [viewer])
132
+ useEffect(() => () => { play(undefined) }, [])
133
+ const clockError = useStore<Error | undefined>(player?.clock.error, undefined)
134
+ function reloadTrack(track: LectureTrack): void {
135
+ if (!operations || track.id !== playing.getSnapshot()) return
136
+ void start(track, lectureSource(server, doc.config.presmium[LECTURE_ID]), operations, ++generation.current, current.current?.time.getSnapshot() ?? 0).catch(error => setFailure(errorMessage(error)))
137
+ }
138
+ const clockFailure = clockError?.message === 'playback-timeout' ? t.player.playbackTimeout : clockError?.message
139
+ return { player, status, failure: failure ?? clockFailure, language, setLanguage, reloadTrack }
140
+ }
@@ -0,0 +1,202 @@
1
+ import type { Operations, PlaybackControl, PlaybackLease } from '@lincyaw/host'
2
+ import type { IRDocument } from '@lincyaw/kernel'
3
+ import { createStore } from '@lincyaw/host'
4
+ import { eventDrift, lastIndexAt, timedEvents, trackDuration } from '../domain/index.ts'
5
+ import type { LectureTrack, TimedEvent } from '../domain/index.ts'
6
+ import type { TrackClock } from './clock.ts'
7
+
8
+ export class LecturePlayer {
9
+ readonly time = createStore(0)
10
+ /** The time since the lecture reached its current step (ms); undefined while the viewer tries the page out. */
11
+ readonly stepTime = createStore<number | undefined>(0)
12
+ readonly trying = createStore(false)
13
+ readonly waitingFor = createStore<string | undefined>(undefined)
14
+ readonly cursor = createStore<{ sectionId: string, step: number } | undefined>(undefined)
15
+ /** Event-identity selection can expose an empty interval while paused; time seeking and playback cannot. */
16
+ readonly positionMode = createStore<'time' | 'step'>('time')
17
+ readonly duration: number
18
+ readonly events: TimedEvent[]
19
+ readonly dropped: number
20
+
21
+ readonly track: LectureTrack
22
+ readonly clock: TrackClock
23
+ private readonly operations: Operations
24
+ // When the lecture reached the position of each event: a `go` that only changes the view (recorded
25
+ // as the presenter switched views) keeps the step's time, as the page's own clock does.
26
+ private readonly reached: number[]
27
+ private applied = -1
28
+ private frame = 0
29
+ private readonly unsubscribe: () => void
30
+ private readonly playback?: PlaybackLease
31
+ private disposed = false
32
+ private generation = 0
33
+ private readonly bufferMissing: boolean
34
+
35
+ constructor(track: LectureTrack, doc: IRDocument, operations: Operations, clock: TrackClock, playback?: PlaybackControl, bufferMissing = false) {
36
+ this.track = track
37
+ this.clock = clock
38
+ this.operations = operations
39
+ this.bufferMissing = bufferMissing
40
+ const drift = eventDrift(track, doc)
41
+ const kept = timedEvents(track).filter(event => drift[event.index]?.grade !== 'lost')
42
+ this.dropped = track.events.length - kept.length
43
+ this.events = kept.map((event) => {
44
+ const data = drift[event.index]?.data ?? event.data
45
+ if (event.type !== 'go') return { ...event, data }
46
+ const { view: _view, ...position } = data
47
+ return { ...event, data: position }
48
+ })
49
+ let position: string | undefined
50
+ let since = 0
51
+ this.reached = this.events.map((event) => {
52
+ const at = event.type === 'go' ? `${String(event.data.section)}\u0000${String(event.data.step)}` : position
53
+ if (at !== position) [position, since] = [at, event.t]
54
+ return since
55
+ })
56
+ this.duration = Math.max(trackDuration(track), this.events.at(-1)?.t ?? 0)
57
+ this.unsubscribe = operations.subscribe(operation => {
58
+ if (operation.type === 'go' && typeof operation.data.section === 'string') {
59
+ const step = Number(operation.data.step ?? 0)
60
+ if (this.seekStep(operation.data.section, step)) return
61
+ }
62
+ this.tryIt()
63
+ })
64
+ this.playback = playback?.register(() => this.pause())
65
+ }
66
+
67
+ get playing(): boolean {
68
+ return this.clock.playing.getSnapshot()
69
+ }
70
+
71
+ async play(): Promise<boolean> {
72
+ if (this.disposed) return false
73
+ if (this.playing) return true
74
+ const generation = ++this.generation
75
+ try {
76
+ if (this.playback && !await this.playback.requestStart()) return false
77
+ if (this.disposed || generation !== this.generation) return false
78
+ const at = this.clock.now()
79
+ if (this.trying.getSnapshot() || at >= this.duration) {
80
+ // Restore the timeline without cancelling the permission just granted
81
+ // for this start. Explicit Back/Seek still pause through their own API.
82
+ this.trying.set(false)
83
+ this.clock.seek(at >= this.duration ? 0 : at)
84
+ this.fold(this.clock.now())
85
+ }
86
+ if (this.disposed || generation !== this.generation) return false
87
+ this.positionMode.set('time')
88
+ this.clock.error.set(undefined)
89
+ if (this.bufferAt(this.clock.now(), this.clock.now())) return true
90
+ this.clock.play()
91
+ this.loop(generation)
92
+ return this.clock.playing.getSnapshot()
93
+ } catch (error) {
94
+ if (!this.disposed && generation === this.generation) { this.pause(); this.clock.error.set(error instanceof Error ? error : new Error(String(error))) }
95
+ return false
96
+ }
97
+ }
98
+
99
+ pause(): void {
100
+ this.waitingFor.set(undefined)
101
+ this.generation++
102
+ this.playback?.cancel()
103
+ cancelAnimationFrame(this.frame)
104
+ this.frame = 0
105
+ this.clock.pause()
106
+ this.setTime(this.clock.now())
107
+ }
108
+
109
+ seek(t: number): void {
110
+ this.pause()
111
+ this.positionMode.set('time')
112
+ this.trying.set(false)
113
+ this.clock.seek(t)
114
+ this.fold(this.clock.now())
115
+ }
116
+
117
+ /** A step is an event boundary, not just a timestamp: zero-length neighbours stay individually selectable. */
118
+ seekStep(sectionId: string, step: number): boolean {
119
+ if (this.disposed) return false
120
+ const target = this.events.findIndex(event => event.type === 'go' && event.data.section === sectionId && Number(event.data.step ?? 0) === step)
121
+ if (target < 0) return false
122
+ this.pause()
123
+ this.trying.set(false)
124
+ const event = this.events[target]!
125
+ this.clock.seek(event.t)
126
+ this.fold(event.t, target)
127
+ this.positionMode.set('step')
128
+ return true
129
+ }
130
+
131
+ setRate(rate: number): void {
132
+ this.clock.setRate(rate)
133
+ }
134
+
135
+ tryIt(): void {
136
+ this.pause()
137
+ if (this.trying.getSnapshot()) return
138
+ this.trying.set(true)
139
+ this.stepTime.set(undefined)
140
+ }
141
+
142
+ back(): void {
143
+ this.seek(this.clock.now())
144
+ }
145
+
146
+ dispose(): void {
147
+ this.disposed = true
148
+ this.playback?.dispose()
149
+ cancelAnimationFrame(this.frame)
150
+ this.unsubscribe()
151
+ this.clock.dispose()
152
+ this.operations.clear()
153
+ }
154
+
155
+ private fold(t: number, boundary?: number): void {
156
+ this.operations.clear()
157
+ this.cursor.set(undefined)
158
+ const last = boundary ?? Math.max(lastIndexAt(this.events, t), this.events.findIndex(e => e.type === 'go'))
159
+ for (let i = 0; i <= last; i++) this.apply(this.events[i]!, i < last)
160
+ this.applied = last
161
+ this.setTime(t)
162
+ }
163
+
164
+ private setTime(t: number): void {
165
+ this.time.set(t)
166
+ this.stepTime.set(this.trying.getSnapshot() ? undefined : t - (this.reached[this.applied] ?? 0))
167
+ }
168
+
169
+ private apply(event: TimedEvent, seeking: boolean): void {
170
+ this.operations.replay({ type: event.type, data: event.data }, seeking)
171
+ if (event.type === 'go' && typeof event.data.section === 'string') this.cursor.set({ sectionId: event.data.section, step: Number(event.data.step ?? 0) })
172
+ }
173
+
174
+ advanceTo(t: number): void {
175
+ this.positionMode.set('time')
176
+ while (this.applied + 1 < this.events.length && this.events[this.applied + 1]!.t <= t) this.apply(this.events[++this.applied]!, false)
177
+ this.setTime(t)
178
+ }
179
+
180
+ private loop(generation: number): void {
181
+ cancelAnimationFrame(this.frame)
182
+ const step = () => {
183
+ if (generation !== this.generation || !this.clock.playing.getSnapshot()) { this.frame = 0; return }
184
+ const before = this.time.getSnapshot(), now = this.clock.now()
185
+ if (this.bufferAt(before, now)) { this.frame = 0; return }
186
+ this.clock.tick()
187
+ this.advanceTo(this.clock.now())
188
+ this.frame = generation === this.generation && this.clock.playing.getSnapshot() ? requestAnimationFrame(step) : 0
189
+ }
190
+ this.frame = requestAnimationFrame(step)
191
+ }
192
+
193
+ /** Never let an estimated missing interval run silently in the author playback. */
194
+ private bufferAt(from: number, to: number): boolean {
195
+ if (!this.bufferMissing) return false
196
+ const missing = this.track.utterances.find(unit => unit.unsaid && unit.t + unit.d > from && unit.t <= to)
197
+ if (!missing) return false
198
+ const at = Math.max(from, missing.t)
199
+ this.pause(); this.clock.seek(at); this.advanceTo(at); this.waitingFor.set(missing.id)
200
+ return true
201
+ }
202
+ }
@@ -0,0 +1,33 @@
1
+ import { OPERATIONS, PRODUCTION_PLAYBACK } from '@lincyaw/host'
2
+ import type { PluginDefinition, Unsubscribe } from '@lincyaw/kernel'
3
+ import { version } from '../../package.json'
4
+ import { LECTURE_ID } from '../domain/index.ts'
5
+ import { applyInk, applyPoint, clearInks, clearPointer } from './pointer-ink.ts'
6
+ import { readLecturesFlag } from './state.ts'
7
+ import { ProductionPlaybackController } from './production-playback.ts'
8
+
9
+ let unregister: Unsubscribe[] = []
10
+
11
+ const plugin: PluginDefinition = {
12
+ // dispose releases everything setup made; a paused preview starts the same definition again.
13
+ restartable: true,
14
+ manifest: { id: LECTURE_ID, name: { 'zh-CN': '讲授', en: 'Lecture' }, version, kernel: '^1.0.0', provides: [PRODUCTION_PLAYBACK], requires: [{ token: OPERATIONS, optional: true }] },
15
+ setup(ctx) {
16
+ readLecturesFlag(ctx.config)
17
+ const operations = ctx.tryGet(OPERATIONS)
18
+ unregister = operations
19
+ ? [operations.onReplay('point', { apply: applyPoint, clear: clearPointer }), operations.onReplay('ink', { apply: applyInk, clear: clearInks })]
20
+ : []
21
+ const production = new ProductionPlaybackController()
22
+ ctx.provide(PRODUCTION_PLAYBACK, production)
23
+ unregister.push(() => production.dispose())
24
+ },
25
+ dispose() {
26
+ unregister.forEach(off => off())
27
+ unregister = []
28
+ clearPointer()
29
+ clearInks()
30
+ },
31
+ }
32
+
33
+ export default plugin