@lincyaw/addon-lecture 1.0.0-rc.1 → 1.0.0-rc.11

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincyaw/addon-lecture",
3
- "version": "1.0.0-rc.1",
3
+ "version": "1.0.0-rc.11",
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/host": "^1.0.0-rc.1",
45
- "@lincyaw/kernel": "^1.0.0-rc.1",
46
- "@lincyaw/compiler": "^1.0.0-rc.1"
44
+ "@lincyaw/compiler": "^1.0.0-rc.11",
45
+ "@lincyaw/host": "^1.0.0-rc.11",
46
+ "@lincyaw/kernel": "^1.0.0-rc.11"
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.1",
61
- "@lincyaw/compiler": "1.0.0-rc.1",
62
- "@lincyaw/kernel": "1.0.0-rc.1",
63
- "@lincyaw/viewer": "1.0.0-rc.1",
64
- "@lincyaw/host": "1.0.0-rc.1"
60
+ "@lincyaw/cli": "1.0.0-rc.11",
61
+ "@lincyaw/compiler": "1.0.0-rc.11",
62
+ "@lincyaw/viewer": "1.0.0-rc.11",
63
+ "@lincyaw/kernel": "1.0.0-rc.11",
64
+ "@lincyaw/host": "1.0.0-rc.11"
65
65
  },
66
66
  "presmium": {
67
67
  "preview": {
@@ -118,3 +118,36 @@ html.pm-lecture-exporting .pm-slide-fit__canvas {
118
118
  display: none;
119
119
  }
120
120
  }
121
+
122
+ /* A lecture the browser would not autoplay waits here, centred on the slide, for the reader's click. */
123
+ .pm-lecture-start {
124
+ position: fixed;
125
+ inset: 0;
126
+ z-index: var(--pm-z-lecture-player);
127
+ display: grid;
128
+ place-items: center;
129
+ pointer-events: none;
130
+ }
131
+
132
+ .pm-lecture-start__button {
133
+ display: grid;
134
+ place-items: center;
135
+ width: var(--pm-size-start);
136
+ height: var(--pm-size-start);
137
+ border: 0;
138
+ border-radius: var(--pm-radius-pill);
139
+ background: color-mix(in srgb, var(--pm-color-ink) 78%, transparent);
140
+ color: var(--pm-color-bg);
141
+ box-shadow: var(--pm-shadow-float);
142
+ cursor: pointer;
143
+ pointer-events: auto;
144
+ }
145
+
146
+ .pm-lecture-start__button:hover {
147
+ background: var(--pm-color-ink);
148
+ }
149
+
150
+ .pm-lecture-start__button .pm-ui-icon {
151
+ width: var(--pm-size-start-icon);
152
+ height: var(--pm-size-start-icon);
153
+ }
@@ -1,4 +1,4 @@
1
- import { PmCaption, PmDrawer, PmIconButton, PmMenu, PmTabs, PRODUCTION_PLAYBACK, TIMED_CAPTIONS, useMessages, useServer, useService, useStore } from '@lincyaw/host'
1
+ import { PmCaption, PmDrawer, PmIcon, PmIconButton, PmMenu, PmTabs, PRODUCTION_PLAYBACK, TIMED_CAPTIONS, useMessages, useServer, useService, useStore } from '@lincyaw/host'
2
2
  import type { MenuItem } from '@lincyaw/host'
3
3
  import { isPreviewEmbedded } from '@lincyaw/kernel'
4
4
  import { useEffect, useMemo, useState } from 'react'
@@ -47,7 +47,8 @@ export default function PlayerPanel(_props: { view?: string }) {
47
47
  const timedCaptions = useService(TIMED_CAPTIONS)
48
48
  const production = useService(PRODUCTION_PLAYBACK)
49
49
  const controlled = useStore(production instanceof ProductionPlaybackController ? production.engaged : undefined, false)
50
- useEffect(() => player && status === 'ready' ? timedCaptions?.claim() : undefined, [player, status, timedCaptions])
50
+ // The lecture times the captions from the moment it is asked for: while it loads, the slides show none of their own.
51
+ useEffect(() => id && !failure && status !== 'missing' ? timedCaptions?.claim() : undefined, [id, failure, status, timedCaptions])
51
52
  useEffect(() => {
52
53
  if (!player || status !== 'ready' || failure || controlled || exporting) return
53
54
  keyboardPlayer.set(player)
@@ -57,6 +58,8 @@ export default function PlayerPanel(_props: { view?: string }) {
57
58
  }
58
59
  return () => { if (keyboardPlayer.getSnapshot() === player) keyboardPlayer.set(undefined) }
59
60
  }, [player, status, failure, controlled])
61
+ // The browser would not start the sound without the reader: a large Play waits for their click.
62
+ const blocked = useStore(player?.clock.blocked, false)
60
63
  const time = useStore(player?.time, 0),
61
64
  clockPlaying = useStore(player?.clock.playing, false),
62
65
  rate = useStore(player?.clock.rate, 1)
@@ -172,6 +175,7 @@ export default function PlayerPanel(_props: { view?: string }) {
172
175
  else if (option === 'close') play(undefined)
173
176
  }
174
177
  return <>
178
+ {blocked && <div className="pm-lecture-start"><button type="button" className="pm-lecture-start__button" aria-label={t.player.play} onClick={() => void player.play()}><PmIcon name="play" /></button></div>}
175
179
  {(captions || current?.unsaid) && (current || manual) && caption && <PmCaption overlay label={t.player.captions} lines={[caption]} lang={lang}
176
180
  data-stale={current && stale.has(current.id) ? 'true' : undefined} hint={current && stale.has(current.id) ? t.player.stale : undefined} />}
177
181
  {panel && <PmDrawer open label={panel === 'transcript' ? t.player.transcript : t.player.chapters} onClose={() => setPanel(undefined)}>
@@ -6,6 +6,8 @@ export interface TrackClock {
6
6
  readonly playing: Readonly<Store<boolean>>
7
7
  readonly rate: Readonly<Store<number>>
8
8
  readonly error: Readonly<Store<Error | undefined>>
9
+ /** The browser refused to start the sound before the reader acted on this page (autoplay): playback waits for them. */
10
+ readonly blocked: Readonly<Store<boolean>>
9
11
  play(): void
10
12
  pause(): void
11
13
  seek(t: number): void
@@ -18,6 +20,7 @@ export class SilentClock implements TrackClock {
18
20
  readonly playing = createStore(false)
19
21
  readonly rate = createStore(1)
20
22
  readonly error = createStore<Error | undefined>(undefined)
23
+ readonly blocked = createStore(false)
21
24
  protected at = 0
22
25
  protected since = performance.now()
23
26
  protected readonly duration: number
@@ -33,6 +36,7 @@ export class SilentClock implements TrackClock {
33
36
 
34
37
  play(): void {
35
38
  if (this.playing.getSnapshot()) return
39
+ this.blocked.set(false)
36
40
  if (this.at >= this.duration) this.at = 0
37
41
  this.since = performance.now()
38
42
  this.playing.set(true)
@@ -150,6 +154,12 @@ export class MediaClock extends SilentClock {
150
154
  if (!this.playing.getSnapshot() || at < m.start || at >= m.start + m.duration) m.el.pause()
151
155
  }).catch((error: unknown) => {
152
156
  if (error instanceof DOMException && error.name === 'AbortError') return
157
+ // Autoplay without the reader's gesture: not a failure, playback waits for their click.
158
+ if (error instanceof DOMException && error.name === 'NotAllowedError') {
159
+ this.pause()
160
+ this.blocked.set(true)
161
+ return
162
+ }
153
163
  this.fail(error instanceof Error ? error : new Error(String(error)))
154
164
  })
155
165
  }
@@ -18,14 +18,25 @@ async function createClock(track: LectureTrack, source: LectureSource, signal?:
18
18
  const length = trackDuration(track)
19
19
  const segments = audioSegments(track)
20
20
  if (!segments.length) return new SilentClock(length)
21
+ // Clips cut from one file (a score split per step) share one download.
22
+ const files = new Map<string, Promise<string>>()
23
+ const fileOf = (segment: typeof segments[number]) => {
24
+ const key = segment.sha256 ?? segment.assetId ?? segment.src
25
+ let url = files.get(key)
26
+ if (!url) {
27
+ url = track.binding && source.productionMedia && segment.clipId ? source.productionMedia(track.binding, segment.clipId, signal) : source.media(track.id, segment.src)
28
+ files.set(key, url)
29
+ }
30
+ return url
31
+ }
21
32
  const loaded = await Promise.allSettled(segments.map(async (segment): Promise<ClockMedia> => {
22
33
  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)
34
+ const url = await fileOf(segment)
24
35
  return { url, start: segment.start, duration: segment.duration, sourceStartMs: segment.sourceStartMs, volume: segment.volume, muted: segment.muted }
25
36
  }))
26
37
  const media = loaded.flatMap(result => result.status === 'fulfilled' ? [result.value] : [])
27
38
  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() }
39
+ if (failed || signal?.aborted) { for (const url of new Set(media.map(item => item.url))) if (url.startsWith('blob:')) URL.revokeObjectURL(url); if (failed) throw failed.reason; signal?.throwIfAborted() }
29
40
  return new MediaClock(length, media)
30
41
  }
31
42
  function installExportHook(player: LecturePlayer, clock: StepClock | undefined): void {
@@ -289,6 +289,9 @@ export function trackDuration(track: LectureTrack): number {
289
289
  export interface AudioSegment {
290
290
  clipId?: string
291
291
  src: string
292
+ /** The file a clip is cut from, when known: clips of one file share one download. */
293
+ sha256?: string
294
+ assetId?: string
292
295
  start: number
293
296
  duration: number
294
297
  sourceStartMs?: number
@@ -298,5 +301,5 @@ export interface AudioSegment {
298
301
 
299
302
  export function audioSegments(track: LectureTrack): AudioSegment[] {
300
303
  const said = track.utterances.filter(u => u.clip).map(u => ({ src: `${CLIPS_DIR}/${u.clip}`, start: u.t, duration: u.d, ...u.clipId ? { clipId: u.clipId } : {} }))
301
- return [...said, ...(track.audio ?? []).map(a => ({ src: a.src, start: a.start * 1000, duration: a.duration * 1000, ...a.clipId ? { clipId: a.clipId } : {}, ...a.sourceStartMs !== undefined ? { sourceStartMs: a.sourceStartMs } : {}, ...a.volume !== undefined ? { volume: a.volume } : {}, ...a.muted !== undefined ? { muted: a.muted } : {} }))]
304
+ return [...said, ...(track.audio ?? []).map(a => ({ src: a.src, start: a.start * 1000, duration: a.duration * 1000, ...a.clipId ? { clipId: a.clipId } : {}, ...a.sha256 ? { sha256: a.sha256 } : {}, ...a.assetId ? { assetId: a.assetId } : {}, ...a.sourceStartMs !== undefined ? { sourceStartMs: a.sourceStartMs } : {}, ...a.volume !== undefined ? { volume: a.volume } : {}, ...a.muted !== undefined ? { muted: a.muted } : {} }))]
302
305
  }
@@ -1,14 +1,18 @@
1
+ import { createHash } from 'node:crypto'
1
2
  import { errorMessage, jsonResponse } from '@lincyaw/kernel'
2
3
  import type { ProductionMediaQuery, ProductionPlanQuery, Route } from '@lincyaw/kernel'
3
4
  import { productionAsset, productionMedia, productionSnapshot } from '../production.ts'
4
5
  import { ProductionAssetRefusal } from '../production-assets.ts'
5
6
  import { canonicalSource, VoiceSourceRefusal } from '../voice-source.ts'
6
- import { serveFile } from './serve-file.ts'
7
+ import { MEDIA_CACHE, notModified, serveFile } from './serve-file.ts'
7
8
  import { RouteRefusal } from './served-deck.ts'
8
9
  import type { ServedDeck } from './served-deck.ts'
9
10
 
10
11
  function audioBytes(bytes: Uint8Array<ArrayBuffer>, mediaType: string, request: Request): Response {
11
- const size = bytes.byteLength, headers = { 'Content-Type': mediaType, 'Cache-Control': 'no-store', 'Accept-Ranges': 'bytes' }
12
+ const etag = `"${createHash('sha256').update(bytes).digest('base64url').slice(0, 22)}"`
13
+ const size = bytes.byteLength, headers = { 'Content-Type': mediaType, 'Cache-Control': MEDIA_CACHE, 'Accept-Ranges': 'bytes', ETag: etag }
14
+ const unchanged = notModified(request, etag, headers)
15
+ if (unchanged) return unchanged
12
16
  const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.get('range') ?? '')
13
17
  if (!range || !range[1] && !range[2]) return new Response(request.method === 'HEAD' ? null : bytes, { headers: { ...headers, 'Content-Length': String(size) } })
14
18
  const start = range[1] ? Number(range[1]) : Math.max(0, size - Number(range[2]))
@@ -14,16 +14,29 @@ const TYPES: Record<string, string> = {
14
14
  '.mp3': 'audio/mpeg',
15
15
  }
16
16
 
17
+ /**
18
+ * Media may change while the author works, so the browser keeps it but asks again each time (`no-cache`): an unchanged
19
+ * file answers 304 by its tag, and a reader who plays the same piece again, or several clips of one file, loads it once.
20
+ */
21
+ export const MEDIA_CACHE = 'private, no-cache'
22
+
23
+ export function notModified(request: Request, etag: string, headers: Record<string, string>): Response | undefined {
24
+ return request.headers.get('if-none-match')?.split(',').some(tag => tag.trim() === etag) ? new Response(null, { status: 304, headers: { ...headers, ETag: etag } }) : undefined
25
+ }
26
+
17
27
  export function serveFile(deckDir: string, file: string | undefined, request: Request): Response {
18
28
  const fd = file === undefined ? undefined : openInDeck(deckDir, file)
19
29
  if (fd === undefined) return problemResponse(404, 'no such file')
20
- const { size } = fstatSync(fd)
21
- const headers = { 'Content-Type': TYPES[extname(file!)] ?? 'application/octet-stream', 'Accept-Ranges': 'bytes', 'Cache-Control': 'no-store' }
30
+ const { size, mtimeMs } = fstatSync(fd)
31
+ const etag = `"${size.toString(36)}-${Math.round(mtimeMs).toString(36)}"`
32
+ const headers = { 'Content-Type': TYPES[extname(file!)] ?? 'application/octet-stream', 'Accept-Ranges': 'bytes', 'Cache-Control': MEDIA_CACHE, ETag: etag }
22
33
  const body = (start: number, end: number) => Readable.toWeb(createReadStream('', { fd, start, end })) as ReadableStream<Uint8Array>
23
34
  const empty = (response: Response) => {
24
35
  closeSync(fd)
25
36
  return response
26
37
  }
38
+ const unchanged = notModified(request, etag, headers)
39
+ if (unchanged) return empty(unchanged)
27
40
  const range = /^bytes=(\d*)-(\d*)$/.exec(request.headers.get('range') ?? '')
28
41
  if (!range || (!range[1] && !range[2])) {
29
42
  if (size === 0 || request.method === 'HEAD') return empty(new Response(null, { headers: { ...headers, 'Content-Length': String(size) } }))