@lincyaw/addon-lecture 1.0.0-rc.12 → 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.
Files changed (2) hide show
  1. package/package.json +9 -9
  2. 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.12",
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/compiler": "^1.0.0-rc.12",
45
- "@lincyaw/host": "^1.0.0-rc.12",
46
- "@lincyaw/kernel": "^1.0.0-rc.12"
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.12",
61
- "@lincyaw/host": "1.0.0-rc.12",
62
- "@lincyaw/compiler": "1.0.0-rc.12",
63
- "@lincyaw/viewer": "1.0.0-rc.12",
64
- "@lincyaw/kernel": "1.0.0-rc.12"
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": {
@@ -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 media: { el: HTMLAudioElement, start: number, duration: number, sourceStartMs: number }[]
90
+ private readonly voices: { el: HTMLAudioElement, clips: Clip[] }[] = []
83
91
 
84
92
  constructor(duration: number, media: ClockMedia[]) {
85
93
  super(duration)
86
- this.media = media.map(({ url, start, duration: length, sourceStartMs = 0, volume = 1, muted = false }) => {
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
- el.volume = volume
92
- el.muted = muted
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
- return this.media.find(m => t >= m.start && t < m.start + m.duration)
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.media) el.pause()
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 m of this.media) {
130
- if (t >= m.start && t < m.start + m.duration) m.el.currentTime = (m.sourceStartMs + t - m.start) / 1000
131
- else m.el.pause()
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.media) el.playbackRate = rate
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 m of this.media) {
145
- if (!this.playing.getSnapshot() || t < m.start || t >= m.start + m.duration) {
146
- if (!m.el.paused) m.el.pause()
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
- const offset = (m.sourceStartMs + t - m.start) / 1000
150
- if (m.el.paused || Math.abs(m.el.currentTime - offset) * 1000 > DRIFT_MS) m.el.currentTime = offset
151
- m.el.playbackRate = this.rate.getSnapshot()
152
- if (m.el.paused) void m.el.play().then(() => {
153
- const at = super.now()
154
- if (!this.playing.getSnapshot() || at < m.start || at >= m.start + m.duration) m.el.pause()
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.media) {
195
+ for (const { el } of this.voices) {
171
196
  el.pause()
172
197
  el.onerror = null
173
198
  const url = el.src