@lincyaw/addon-lecture 1.0.0-rc.11 → 1.0.0-rc.13
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/package.json +9 -9
- package/src/client/clock.ts +47 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lincyaw/addon-lecture",
|
|
3
|
-
"version": "1.0.0-rc.
|
|
3
|
+
"version": "1.0.0-rc.13",
|
|
4
4
|
"description": "Presmium plugin: lecture tracks: recording, playback, scripted lectures with speech, and video export (presmium-lecture)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
"playwright-chromium": "^1.40.0",
|
|
42
42
|
"react": "^18.3.1",
|
|
43
43
|
"react-dom": "^18.3.1",
|
|
44
|
-
"@lincyaw/
|
|
45
|
-
"@lincyaw/
|
|
46
|
-
"@lincyaw/kernel": "^1.0.0-rc.
|
|
44
|
+
"@lincyaw/host": "^1.0.0-rc.13",
|
|
45
|
+
"@lincyaw/compiler": "^1.0.0-rc.13",
|
|
46
|
+
"@lincyaw/kernel": "^1.0.0-rc.13"
|
|
47
47
|
},
|
|
48
48
|
"peerDependenciesMeta": {
|
|
49
49
|
"playwright-chromium": {
|
|
@@ -57,11 +57,11 @@
|
|
|
57
57
|
"@types/react": "18.3.28",
|
|
58
58
|
"react-dom": "18.3.1",
|
|
59
59
|
"@types/react-dom": "18.3.7",
|
|
60
|
-
"@lincyaw/cli": "1.0.0-rc.
|
|
61
|
-
"@lincyaw/compiler": "1.0.0-rc.
|
|
62
|
-
"@lincyaw/
|
|
63
|
-
"@lincyaw/
|
|
64
|
-
"@lincyaw/host": "1.0.0-rc.
|
|
60
|
+
"@lincyaw/cli": "1.0.0-rc.13",
|
|
61
|
+
"@lincyaw/compiler": "1.0.0-rc.13",
|
|
62
|
+
"@lincyaw/kernel": "1.0.0-rc.13",
|
|
63
|
+
"@lincyaw/viewer": "1.0.0-rc.13",
|
|
64
|
+
"@lincyaw/host": "1.0.0-rc.13"
|
|
65
65
|
},
|
|
66
66
|
"presmium": {
|
|
67
67
|
"preview": {
|
package/src/client/clock.ts
CHANGED
|
@@ -78,20 +78,32 @@ export interface ClockMedia {
|
|
|
78
78
|
|
|
79
79
|
const DRIFT_MS = 250
|
|
80
80
|
|
|
81
|
+
/** A part of an audio file on the timeline: from `start` for `duration`, playing the file from `sourceStartMs`. */
|
|
82
|
+
interface Clip { start: number, duration: number, sourceStartMs: number, volume: number, muted: boolean }
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Plays the timeline's audio with one element per file: a clip is where on the timeline a part of its file plays,
|
|
86
|
+
* so clips that continue one another (a score cut at every step) run on in one element, without a pause, a seek or a
|
|
87
|
+
* click at their boundary. A file gets another element only for clips of it that overlap in time.
|
|
88
|
+
*/
|
|
81
89
|
export class MediaClock extends SilentClock {
|
|
82
|
-
private readonly
|
|
90
|
+
private readonly voices: { el: HTMLAudioElement, clips: Clip[] }[] = []
|
|
83
91
|
|
|
84
92
|
constructor(duration: number, media: ClockMedia[]) {
|
|
85
93
|
super(duration)
|
|
86
|
-
|
|
94
|
+
for (const { url, start, duration: length, sourceStartMs = 0, volume = 1, muted = false } of [...media].sort((a, b) => a.start - b.start)) {
|
|
95
|
+
const clip = { start, duration: length, sourceStartMs, volume, muted }
|
|
96
|
+
const free = this.voices.find(voice => voice.el.src === new URL(url, location.href).href && voice.clips.at(-1)!.start + voice.clips.at(-1)!.duration <= start)
|
|
97
|
+
if (free) {
|
|
98
|
+
free.clips.push(clip)
|
|
99
|
+
continue
|
|
100
|
+
}
|
|
87
101
|
const el = new Audio()
|
|
88
102
|
el.preload = 'auto'
|
|
89
103
|
el.onerror = () => this.fail(new Error([url, el.error?.message].filter(Boolean).join(': ')))
|
|
90
104
|
el.src = url
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return { el, start, duration: length, sourceStartMs }
|
|
94
|
-
})
|
|
105
|
+
this.voices.push({ el, clips: [clip] })
|
|
106
|
+
}
|
|
95
107
|
}
|
|
96
108
|
|
|
97
109
|
private fail(error: Error): void {
|
|
@@ -99,8 +111,16 @@ export class MediaClock extends SilentClock {
|
|
|
99
111
|
this.error.set(error)
|
|
100
112
|
}
|
|
101
113
|
|
|
114
|
+
private static clipAt(clips: readonly Clip[], t: number): Clip | undefined {
|
|
115
|
+
return clips.find(clip => t >= clip.start && t < clip.start + clip.duration)
|
|
116
|
+
}
|
|
117
|
+
|
|
102
118
|
private mediaAt(t: number) {
|
|
103
|
-
|
|
119
|
+
for (const voice of this.voices) {
|
|
120
|
+
const clip = MediaClock.clipAt(voice.clips, t)
|
|
121
|
+
if (clip) return { el: voice.el, ...clip }
|
|
122
|
+
}
|
|
123
|
+
return undefined
|
|
104
124
|
}
|
|
105
125
|
|
|
106
126
|
override now(): number {
|
|
@@ -121,37 +141,42 @@ export class MediaClock extends SilentClock {
|
|
|
121
141
|
|
|
122
142
|
override pause(): void {
|
|
123
143
|
super.pause()
|
|
124
|
-
for (const { el } of this.
|
|
144
|
+
for (const { el } of this.voices) el.pause()
|
|
125
145
|
}
|
|
126
146
|
|
|
127
147
|
override seek(t: number): void {
|
|
128
148
|
super.seek(t)
|
|
129
|
-
for (const
|
|
130
|
-
|
|
131
|
-
|
|
149
|
+
for (const { el, clips } of this.voices) {
|
|
150
|
+
const clip = MediaClock.clipAt(clips, t)
|
|
151
|
+
if (clip) el.currentTime = (clip.sourceStartMs + t - clip.start) / 1000
|
|
152
|
+
else el.pause()
|
|
132
153
|
}
|
|
133
154
|
this.tick()
|
|
134
155
|
}
|
|
135
156
|
|
|
136
157
|
override setRate(rate: number): void {
|
|
137
158
|
super.setRate(rate)
|
|
138
|
-
for (const { el } of this.
|
|
159
|
+
for (const { el } of this.voices) el.playbackRate = rate
|
|
139
160
|
}
|
|
140
161
|
|
|
141
162
|
override tick(): void {
|
|
142
163
|
super.tick()
|
|
143
164
|
const t = this.now()
|
|
144
|
-
for (const
|
|
145
|
-
|
|
146
|
-
|
|
165
|
+
for (const { el, clips } of this.voices) {
|
|
166
|
+
const clip = this.playing.getSnapshot() ? MediaClock.clipAt(clips, t) : undefined
|
|
167
|
+
if (!clip) {
|
|
168
|
+
if (!el.paused) el.pause()
|
|
147
169
|
continue
|
|
148
170
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
171
|
+
el.volume = clip.volume
|
|
172
|
+
el.muted = clip.muted
|
|
173
|
+
// Where the file should be now; the element is moved only when it is elsewhere (a jump, or a clip that does not
|
|
174
|
+
// continue the last one), never at the boundary between clips that continue each other.
|
|
175
|
+
const offset = (clip.sourceStartMs + t - clip.start) / 1000
|
|
176
|
+
if (Math.abs(el.currentTime - offset) * 1000 > DRIFT_MS) el.currentTime = offset
|
|
177
|
+
el.playbackRate = this.rate.getSnapshot()
|
|
178
|
+
if (el.paused) void el.play().then(() => {
|
|
179
|
+
if (!this.playing.getSnapshot() || !MediaClock.clipAt(clips, super.now())) el.pause()
|
|
155
180
|
}).catch((error: unknown) => {
|
|
156
181
|
if (error instanceof DOMException && error.name === 'AbortError') return
|
|
157
182
|
// Autoplay without the reader's gesture: not a failure, playback waits for their click.
|
|
@@ -167,7 +192,7 @@ export class MediaClock extends SilentClock {
|
|
|
167
192
|
|
|
168
193
|
override dispose(): void {
|
|
169
194
|
super.dispose()
|
|
170
|
-
for (const { el } of this.
|
|
195
|
+
for (const { el } of this.voices) {
|
|
171
196
|
el.pause()
|
|
172
197
|
el.onerror = null
|
|
173
198
|
const url = el.src
|