@pathmx/player 0.5.0

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/client.ts ADDED
@@ -0,0 +1,1047 @@
1
+ import "./play.css"
2
+ import "./notes.css"
3
+ import type { SourceDescription } from "@pathmx/core"
4
+ import { actionsForBeat, type PlayAction } from "./actions.ts"
5
+ import {
6
+ createPlayControls,
7
+ MOBILE_CONTROLS_QUERY,
8
+ type PlayIntensity,
9
+ type PlayMode,
10
+ type PlayThemeChoice,
11
+ } from "./controls.tsx"
12
+ import { installPlayerGestures } from "./gestures.ts"
13
+ import { createPlayGrid } from "./grid.ts"
14
+ import { measurePlayGuide } from "./guide.ts"
15
+ import { installPlayerKeyboard, returnKeyboardToDocument } from "./keyboard.ts"
16
+ import { applyBlockVariants } from "./variants.ts"
17
+ import { applyPlayMediaFrames } from "./media.ts"
18
+ import { installPlayLinkContinuation } from "./navigation.ts"
19
+ import {
20
+ createPlayPreferenceStore,
21
+ type PlayPreferences,
22
+ } from "./preferences.ts"
23
+ import type { PlayProgressBlock } from "./progress.tsx"
24
+ import {
25
+ extractPlayRoute,
26
+ playNoteForBlock,
27
+ type Beat,
28
+ type PlayRoute,
29
+ } from "./route.ts"
30
+ import {
31
+ createPlayScrollSync,
32
+ syncPresentationSnapTargets,
33
+ type PlayScrollBehavior,
34
+ } from "./scroll.ts"
35
+ import { isPlayTheme, sourcePlayTheme } from "./themes.ts"
36
+
37
+ const QUIET_DELAY = 1800
38
+ const preferenceStore = createPlayPreferenceStore()
39
+ let preferences = preferenceStore.snapshot()
40
+
41
+ function rememberPreferences(next: Partial<PlayPreferences>) {
42
+ preferences = preferenceStore.update(next)
43
+ }
44
+
45
+ function requestedState() {
46
+ const url = new URL(location.href)
47
+ const value = url.searchParams.get("play")
48
+ if (value === null) return
49
+ const mode: PlayMode = value === "presentation" ? "presentation" : "focus"
50
+ const requestedIntensity = url.searchParams.get("intensity")
51
+ const intensity: PlayIntensity =
52
+ requestedIntensity === "low" ||
53
+ requestedIntensity === "medium" ||
54
+ requestedIntensity === "high"
55
+ ? requestedIntensity
56
+ : preferences.intensity
57
+ const requestedTheme = url.searchParams.get("play-theme")
58
+ const theme: PlayThemeChoice =
59
+ requestedTheme === null
60
+ ? preferences.theme
61
+ : isPlayTheme(requestedTheme)
62
+ ? requestedTheme
63
+ : "source"
64
+ if (requestedTheme && !isPlayTheme(requestedTheme)) {
65
+ console.warn(
66
+ `[pathmx-play] unavailable theme "${requestedTheme}"; using Source default`,
67
+ )
68
+ }
69
+ return {
70
+ mode,
71
+ intensity,
72
+ theme,
73
+ guide: url.searchParams.has("play-guide")
74
+ ? url.searchParams.get("play-guide") !== "off"
75
+ : preferences.guide,
76
+ notes: url.searchParams.has("play-notes")
77
+ ? url.searchParams.get("play-notes") !== "off"
78
+ : preferences.notes,
79
+ }
80
+ }
81
+
82
+ function nearestElementIndex(elements: readonly HTMLElement[], ratio: number) {
83
+ const playhead = innerHeight * ratio
84
+ let nearest = 0
85
+ let distance = Number.POSITIVE_INFINITY
86
+ elements.forEach((element, index) => {
87
+ const rect = element.getBoundingClientRect()
88
+ const bottom = rect.top + rect.height
89
+ const nextDistance =
90
+ rect.top <= playhead && playhead <= bottom
91
+ ? 0
92
+ : Math.min(Math.abs(rect.top - playhead), Math.abs(bottom - playhead))
93
+ if (nextDistance < distance) {
94
+ nearest = index
95
+ distance = nextDistance
96
+ }
97
+ })
98
+ return nearest
99
+ }
100
+
101
+ function nearestBeatIndex(route: PlayRoute, currentIndex?: number) {
102
+ if (scrollY <= 32) return 0
103
+ const targets = [...new Set(route.beats.map((beat) => beat.target))]
104
+ const target = targets[nearestElementIndex(targets, 0.42)]
105
+ if (!target) return 0
106
+ const current =
107
+ currentIndex === undefined ? undefined : route.beats[currentIndex]
108
+ if (current && currentIndex !== undefined && current.target === target) {
109
+ return currentIndex
110
+ }
111
+ const index = route.beats.findIndex((beat) => beat.target === target)
112
+ return index >= 0 ? index : 0
113
+ }
114
+
115
+ function nearestBlockBeatIndex(route: PlayRoute, currentIndex: number) {
116
+ const blocks = [...new Set(route.beats.map((beat) => beat.block))]
117
+ const block = blocks[nearestElementIndex(blocks, 0.5)]
118
+ const current = route.beats[currentIndex]
119
+ if (current?.block === block) return currentIndex
120
+ const index = route.beats.findIndex((beat) => beat.block === block)
121
+ return index >= 0 ? index : currentIndex
122
+ }
123
+
124
+ function requestedFragment() {
125
+ if (!location.hash) return
126
+ try {
127
+ return decodeURIComponent(location.hash.slice(1)) || undefined
128
+ } catch {
129
+ return
130
+ }
131
+ }
132
+
133
+ function beatIndexForFragment(route: PlayRoute, fragment?: string) {
134
+ if (!fragment) return
135
+ const index = route.beats.findIndex(
136
+ (beat) =>
137
+ beat.id === fragment ||
138
+ beat.fragment === fragment ||
139
+ beat.element.id === fragment ||
140
+ beat.target.id === fragment,
141
+ )
142
+ return index >= 0 ? index : undefined
143
+ }
144
+
145
+ function currentPlayerAppRoot() {
146
+ return document.querySelector<HTMLElement>(
147
+ '[data-component="player:app"]',
148
+ )
149
+ }
150
+
151
+ function descriptionFrom(element: HTMLElement) {
152
+ return JSON.parse(
153
+ element.dataset.description ?? "null",
154
+ ) as SourceDescription | null
155
+ }
156
+
157
+ function extractCurrentPlayRoute() {
158
+ const document = window.document.querySelector<HTMLElement>(".pmx-document")
159
+ if (document) applyPlayMediaFrames(document)
160
+ return extractPlayRoute()
161
+ }
162
+
163
+ let route: PlayRoute | undefined
164
+ let mode: PlayMode | undefined
165
+ let intensity = preferences.intensity
166
+ let theme = preferences.theme
167
+ let guide = preferences.guide
168
+ let notes = preferences.notes
169
+ let autoHide = preferences.autoHide
170
+ let expanded = false
171
+ let activeIndex = -1
172
+ let seenThrough = -1
173
+ let pendingFragment: string | undefined
174
+ let ownedFragment: string | undefined
175
+ let activeActions: PlayAction[] = []
176
+ let previousAriaCurrent: string | null | undefined
177
+ let activeAriaElement: HTMLElement | undefined
178
+ let quietTimer: number | undefined
179
+ let guideFrameRequest = 0
180
+ let progressBlocks: readonly PlayProgressBlock[] = []
181
+ let previousScrollRestoration: ScrollRestoration | undefined
182
+
183
+ const initialAppRoot = currentPlayerAppRoot()
184
+ if (!initialAppRoot) throw new Error("Missing authored Player App Component.")
185
+ let appRoot = initialAppRoot
186
+ const description = descriptionFrom(appRoot)
187
+ if (!description) throw new Error("Missing Player Source description.")
188
+ let sourceDescription: SourceDescription = description
189
+
190
+ type PlayHistoryBehavior = "replace" | "push" | "none"
191
+ type PlayHistoryEntry = Readonly<{ sourceId: string; beatId: string }>
192
+ const PLAY_HISTORY_KEY = "pmxPlay"
193
+
194
+ function claimPresentationScroll() {
195
+ if (previousScrollRestoration === undefined) {
196
+ previousScrollRestoration = history.scrollRestoration
197
+ }
198
+ history.scrollRestoration = "manual"
199
+ }
200
+
201
+ function releasePresentationScroll() {
202
+ if (previousScrollRestoration === undefined) return
203
+ history.scrollRestoration = previousScrollRestoration
204
+ previousScrollRestoration = undefined
205
+ }
206
+
207
+ function playHistoryEntry(state: unknown): PlayHistoryEntry | undefined {
208
+ if (!state || typeof state !== "object" || Array.isArray(state)) return
209
+ const entry = (state as Record<string, unknown>)[PLAY_HISTORY_KEY]
210
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) return
211
+ const sourceId = (entry as Record<string, unknown>).sourceId
212
+ const beatId = (entry as Record<string, unknown>).beatId
213
+ if (typeof sourceId !== "string" || typeof beatId !== "string") return
214
+ return { sourceId, beatId }
215
+ }
216
+
217
+ function playHistoryState(active: Beat) {
218
+ const current = history.state
219
+ const state =
220
+ current && typeof current === "object" && !Array.isArray(current)
221
+ ? { ...current }
222
+ : {}
223
+ return {
224
+ ...state,
225
+ [PLAY_HISTORY_KEY]: {
226
+ sourceId: sourceDescription.source.id,
227
+ beatId: active.id,
228
+ },
229
+ }
230
+ }
231
+
232
+ function syncBeatHistory(active: Beat, behavior: PlayHistoryBehavior) {
233
+ if (behavior === "none") return
234
+ const url = new URL(location.href)
235
+ if (active.fragment) {
236
+ url.hash = encodeURIComponent(active.fragment)
237
+ ownedFragment = active.fragment
238
+ } else if (requestedFragment() === ownedFragment) {
239
+ url.hash = ""
240
+ ownedFragment = undefined
241
+ }
242
+ const state = playHistoryState(active)
243
+ if (behavior === "push") history.pushState(state, "", url)
244
+ else history.replaceState(state, "", url)
245
+ }
246
+
247
+ installPlayLinkContinuation(
248
+ () => (mode ? { mode, intensity, theme, guide, notes } : undefined),
249
+ (fragment) => navigateToBeatFragment(fragment, "push"),
250
+ )
251
+
252
+ function activateAction(index: number) {
253
+ activeActions[index]?.activate()
254
+ }
255
+
256
+ function usesMobileControls() {
257
+ return matchMedia(MOBILE_CONTROLS_QUERY).matches
258
+ }
259
+
260
+ function clearQuietTimer() {
261
+ if (quietTimer !== undefined) window.clearTimeout(quietTimer)
262
+ quietTimer = undefined
263
+ }
264
+
265
+ function scheduleQuiet() {
266
+ clearQuietTimer()
267
+ if (!mode || !autoHide || expanded || usesMobileControls()) return
268
+ quietTimer = window.setTimeout(() => controls.setVisible(false), QUIET_DELAY)
269
+ }
270
+
271
+ function revealControls() {
272
+ if (!mode) return
273
+ controls.setVisible(true)
274
+ scheduleQuiet()
275
+ }
276
+
277
+ const controls = createPlayControls(appRoot, sourceDescription, {
278
+ start: () => startPreferredPlay(),
279
+ previous: () => move(-1),
280
+ next: () => move(1),
281
+ setMode,
282
+ setIntensity,
283
+ setTheme,
284
+ setGuide,
285
+ setNotes,
286
+ setGrid,
287
+ setAutoHide,
288
+ setExpanded(nextExpanded) {
289
+ expanded = nextExpanded
290
+ revealControls()
291
+ },
292
+ exit: () => deactivate(),
293
+ activateAction,
294
+ })
295
+
296
+ controls.setAutoHide(autoHide)
297
+ controls.setNotes(notes)
298
+
299
+ const scrollSync = createPlayScrollSync({
300
+ start() {
301
+ document.documentElement.setAttribute("data-pmx-play-scrolling", "")
302
+ controls.setGuideFrame(undefined)
303
+ },
304
+ settle() {
305
+ document.documentElement.removeAttribute("data-pmx-play-scrolling")
306
+ adoptScrollPosition()
307
+ updateGuideFrame()
308
+ },
309
+ track(tracking) {
310
+ document.documentElement.toggleAttribute(
311
+ "data-pmx-play-guide-tracking",
312
+ tracking,
313
+ )
314
+ if (!tracking) updateGuideFrame()
315
+ },
316
+ })
317
+
318
+ const grid = createPlayGrid({
319
+ route: () => route,
320
+ activeIndex: () => activeIndex,
321
+ seenThrough: () => seenThrough,
322
+ select: selectGridBlock,
323
+ })
324
+
325
+ function deriveProgressBlocks(playRoute: PlayRoute) {
326
+ const blocks: PlayProgressBlock[] = []
327
+ let currentBlock: HTMLElement | undefined
328
+ playRoute.beats.forEach((beat, index) => {
329
+ const current = blocks.at(-1)
330
+ if (current && currentBlock === beat.block) {
331
+ blocks[blocks.length - 1] = { ...current, total: current.total + 1 }
332
+ return
333
+ }
334
+ currentBlock = beat.block
335
+ blocks.push({ start: index, total: 1 })
336
+ })
337
+ return blocks
338
+ }
339
+
340
+ function updateUrl(nextMode: PlayMode | undefined) {
341
+ const url = new URL(location.href)
342
+ if (nextMode) {
343
+ url.searchParams.set("play", nextMode)
344
+ url.searchParams.set("intensity", intensity)
345
+ if (theme === "source") url.searchParams.delete("play-theme")
346
+ else url.searchParams.set("play-theme", theme)
347
+ if (guide) url.searchParams.delete("play-guide")
348
+ else url.searchParams.set("play-guide", "off")
349
+ if (notes) url.searchParams.delete("play-notes")
350
+ else url.searchParams.set("play-notes", "off")
351
+ } else {
352
+ url.searchParams.delete("play")
353
+ url.searchParams.delete("intensity")
354
+ url.searchParams.delete("play-theme")
355
+ url.searchParams.delete("play-guide")
356
+ url.searchParams.delete("play-notes")
357
+ }
358
+ history.replaceState(history.state, "", url)
359
+ }
360
+
361
+ function effectiveTheme() {
362
+ return theme === "source" ? sourcePlayTheme(sourceDescription) : theme
363
+ }
364
+
365
+ function applyTheme() {
366
+ if (!route) return
367
+ route.document.dataset.pmxPlayTheme = effectiveTheme()
368
+ controls.setTheme(theme)
369
+ }
370
+
371
+ function clearRoutePresentation() {
372
+ if (!route) return
373
+ syncPresentationSnapTargets(route.document, false)
374
+ route.document.removeAttribute("data-pmx-play")
375
+ route.document.removeAttribute("data-pmx-play-intensity")
376
+ route.document.removeAttribute("data-pmx-play-theme")
377
+ for (const beat of route.beats) {
378
+ beat.element.removeAttribute("data-pmx-play-state")
379
+ beat.element.removeAttribute("data-pmx-play-row-active")
380
+ beat.block.removeAttribute("data-pmx-play-block")
381
+ beat.target.removeAttribute("data-pmx-play-target-active")
382
+ }
383
+ for (const block of route.document.querySelectorAll<HTMLElement>(
384
+ ":scope > section[data-pmx-block]",
385
+ )) {
386
+ block.removeAttribute("data-pmx-play-variant")
387
+ }
388
+ controls.setGuideFrame(undefined)
389
+ if (activeAriaElement) {
390
+ if (previousAriaCurrent === null) {
391
+ activeAriaElement.removeAttribute("aria-current")
392
+ } else if (previousAriaCurrent !== undefined) {
393
+ activeAriaElement.setAttribute("aria-current", previousAriaCurrent)
394
+ }
395
+ }
396
+ activeAriaElement = undefined
397
+ previousAriaCurrent = undefined
398
+ }
399
+
400
+ function applyMode(nextMode: PlayMode, reposition = true) {
401
+ if (!route) return
402
+ if (nextMode === "presentation") claimPresentationScroll()
403
+ else releasePresentationScroll()
404
+ mode = nextMode
405
+ route.document.dataset.pmxPlay = nextMode
406
+ syncPresentationSnapTargets(route.document, nextMode === "presentation")
407
+ document.documentElement.dataset.pmxPlayMode = nextMode
408
+ controls.setMode(nextMode)
409
+ applyTheme()
410
+ updateUrl(nextMode)
411
+ if (reposition) present(!grid.isOpen(), 0, false)
412
+ }
413
+
414
+ function setMode(nextMode: PlayMode) {
415
+ rememberPreferences({ mode: nextMode })
416
+ applyMode(nextMode)
417
+ }
418
+
419
+ function setIntensity(nextIntensity: PlayIntensity) {
420
+ if (!route || !mode) return
421
+ intensity = nextIntensity
422
+ route.document.dataset.pmxPlayIntensity = nextIntensity
423
+ controls.setIntensity(nextIntensity)
424
+ rememberPreferences({ intensity: nextIntensity })
425
+ updateUrl(mode)
426
+ }
427
+
428
+ function adjustIntensity(offset: -1 | 1) {
429
+ const choices: PlayIntensity[] = ["low", "medium", "high"]
430
+ const index = choices.indexOf(intensity)
431
+ setIntensity(
432
+ choices[Math.max(0, Math.min(choices.length - 1, index + offset))]!,
433
+ )
434
+ }
435
+
436
+ function toggleMode() {
437
+ if (mode) setMode(mode === "focus" ? "presentation" : "focus")
438
+ }
439
+
440
+ function setTheme(nextTheme: PlayThemeChoice) {
441
+ if (!route || !mode) return
442
+ theme = nextTheme
443
+ rememberPreferences({ theme: nextTheme })
444
+ applyTheme()
445
+ updateUrl(mode)
446
+ }
447
+
448
+ function setGuide(enabled: boolean) {
449
+ guide = enabled
450
+ rememberPreferences({ guide: enabled })
451
+ controls.setGuide(enabled)
452
+ if (route && mode) {
453
+ updateUrl(mode)
454
+ updateGuideFrame()
455
+ }
456
+ }
457
+
458
+ function setNotes(enabled: boolean) {
459
+ notes = enabled
460
+ rememberPreferences({ notes: enabled })
461
+ controls.setNotes(enabled)
462
+ if (route && mode) updateUrl(mode)
463
+ }
464
+
465
+ function setGrid(open: boolean, restoreActive = true) {
466
+ if ((open && (!route || !mode)) || open === grid.isOpen()) return
467
+ grid.setOpen(open)
468
+ controls.setGrid(open)
469
+ controls.setGuideFrame(undefined)
470
+ if (open) {
471
+ scrollSync.setEnabled(false)
472
+ return
473
+ }
474
+ returnKeyboardToDocument()
475
+ scrollSync.setEnabled(true)
476
+ if (restoreActive) {
477
+ requestAnimationFrame(() => {
478
+ if (route && mode && !grid.isOpen()) present(true, 0, false, "instant")
479
+ })
480
+ }
481
+ }
482
+
483
+ function selectGridBlock(beatIndex: number) {
484
+ setGrid(false, false)
485
+ requestAnimationFrame(() => {
486
+ if (!route || !mode || grid.isOpen()) return
487
+ if (beatIndex === activeIndex) present(true, 0, false, "instant")
488
+ else selectBeat(beatIndex, true, "instant")
489
+ })
490
+ }
491
+
492
+ function setAutoHide(enabled: boolean) {
493
+ autoHide = enabled
494
+ rememberPreferences({ autoHide: enabled })
495
+ controls.setAutoHide(enabled)
496
+ controls.setVisible(true)
497
+ scheduleQuiet()
498
+ }
499
+
500
+ function updateGuideFrame() {
501
+ cancelAnimationFrame(guideFrameRequest)
502
+ guideFrameRequest = requestAnimationFrame(() => {
503
+ const active = route?.beats[activeIndex]
504
+ controls.setGuideFrame(
505
+ active && mode && guide && !grid.isOpen()
506
+ ? measurePlayGuide(active)
507
+ : undefined,
508
+ )
509
+ })
510
+ }
511
+
512
+ function updatePlayerLayout() {
513
+ if (route) {
514
+ syncPresentationSnapTargets(route.document, mode === "presentation")
515
+ }
516
+ updateGuideFrame()
517
+ }
518
+
519
+ const guideResizeObserver =
520
+ typeof ResizeObserver === "undefined"
521
+ ? undefined
522
+ : new ResizeObserver(() => updateGuideFrame())
523
+
524
+ function observeGuideLayout(active?: Beat) {
525
+ guideResizeObserver?.disconnect()
526
+ if (!active) return
527
+ guideResizeObserver?.observe(active.target)
528
+ for (const child of active.block.children) {
529
+ if (child instanceof HTMLElement && child !== active.target) {
530
+ guideResizeObserver?.observe(child)
531
+ }
532
+ }
533
+ }
534
+
535
+ function applyElementStates(active: Beat) {
536
+ if (!route) return
537
+ const byElement = new Map<HTMLElement, number[]>()
538
+ const byBlock = new Map<HTMLElement, number[]>()
539
+ route.beats.forEach((beat, index) => {
540
+ const indexes = byElement.get(beat.element) ?? []
541
+ indexes.push(index)
542
+ byElement.set(beat.element, indexes)
543
+ const blockIndexes = byBlock.get(beat.block) ?? []
544
+ blockIndexes.push(index)
545
+ byBlock.set(beat.block, blockIndexes)
546
+ beat.target.removeAttribute("data-pmx-play-target-active")
547
+ beat.element.removeAttribute("data-pmx-play-row-active")
548
+ })
549
+ for (const [block, indexes] of byBlock) {
550
+ block.dataset.pmxPlayBlock =
551
+ block === active.block
552
+ ? "active"
553
+ : Math.min(...indexes) <= seenThrough
554
+ ? "seen"
555
+ : "upcoming"
556
+ }
557
+ for (const [element, indexes] of byElement) {
558
+ element.dataset.pmxPlayState = indexes.includes(activeIndex)
559
+ ? "active"
560
+ : Math.max(...indexes) <= seenThrough
561
+ ? "seen"
562
+ : "upcoming"
563
+ }
564
+ active.target.setAttribute("data-pmx-play-target-active", "")
565
+ if (active.member?.type === "table-row") {
566
+ active.element.setAttribute("data-pmx-play-row-active", "")
567
+ }
568
+ }
569
+
570
+ function present(
571
+ scroll = true,
572
+ direction = 0,
573
+ announce = true,
574
+ scrollBehavior: PlayScrollBehavior = "smooth",
575
+ historyBehavior: PlayHistoryBehavior = "replace",
576
+ ) {
577
+ if (!route || !mode || activeIndex < 0) return
578
+ const active = route.beats[activeIndex]
579
+ if (!active) return
580
+ seenThrough = Math.max(seenThrough, activeIndex)
581
+ syncBeatHistory(active, historyBehavior)
582
+ applyElementStates(active)
583
+
584
+ if (activeAriaElement !== active.element) {
585
+ if (activeAriaElement) {
586
+ if (previousAriaCurrent === null) {
587
+ activeAriaElement.removeAttribute("aria-current")
588
+ } else if (previousAriaCurrent !== undefined) {
589
+ activeAriaElement.setAttribute("aria-current", previousAriaCurrent)
590
+ }
591
+ }
592
+ activeAriaElement = active.element
593
+ previousAriaCurrent = active.element.getAttribute("aria-current")
594
+ active.element.setAttribute("aria-current", "step")
595
+ }
596
+
597
+ activeActions = actionsForBeat(
598
+ active,
599
+ activeIndex === route.beats.length - 1 ? sourceDescription.actions : [],
600
+ )
601
+ controls.setActions(activeActions)
602
+ controls.setProgress(
603
+ activeIndex,
604
+ route.beats.length,
605
+ active.label,
606
+ progressBlocks,
607
+ )
608
+ controls.setNote(playNoteForBlock(active.block))
609
+ observeGuideLayout(active)
610
+ updateGuideFrame()
611
+ revealControls()
612
+
613
+ if (announce) {
614
+ active.element.dispatchEvent(
615
+ new CustomEvent("pmx:beatenter", {
616
+ bubbles: true,
617
+ detail: {
618
+ id: active.id,
619
+ index: activeIndex,
620
+ direction,
621
+ mode,
622
+ intensity,
623
+ member: active.member,
624
+ },
625
+ }),
626
+ )
627
+ }
628
+
629
+ if (scroll) {
630
+ scrollSync.navigate(active.target, active.block, mode, scrollBehavior)
631
+ }
632
+ }
633
+
634
+ function reanchorPresentation(beatId: string | undefined) {
635
+ scrollSync.setEnabled(false)
636
+ requestAnimationFrame(() => {
637
+ requestAnimationFrame(() => {
638
+ if (
639
+ mode === "presentation" &&
640
+ route?.beats[activeIndex]?.id === beatId
641
+ ) {
642
+ present(true, 0, false, "instant", "none")
643
+ }
644
+ requestAnimationFrame(() => {
645
+ if (mode) scrollSync.setEnabled(true)
646
+ })
647
+ })
648
+ })
649
+ }
650
+
651
+ function activate(
652
+ nextMode: PlayMode,
653
+ nextIntensity: PlayIntensity,
654
+ nextTheme: PlayThemeChoice = "source",
655
+ nextGuide = true,
656
+ nextNotes = true,
657
+ scrollBehavior: PlayScrollBehavior = "smooth",
658
+ position: "nearest" | "start" = "nearest",
659
+ ) {
660
+ const nextRoute = extractCurrentPlayRoute()
661
+ if (!nextRoute) return
662
+ clearRoutePresentation()
663
+ route = nextRoute
664
+ progressBlocks = deriveProgressBlocks(nextRoute)
665
+ mode = nextMode
666
+ intensity = nextIntensity
667
+ theme = nextTheme
668
+ guide = nextGuide
669
+ notes = nextNotes
670
+ rememberPreferences({
671
+ mode: nextMode,
672
+ intensity: nextIntensity,
673
+ theme: nextTheme,
674
+ guide: nextGuide,
675
+ notes: nextNotes,
676
+ })
677
+ applyBlockVariants(route, sourceDescription)
678
+ const fragment = requestedFragment()
679
+ const fragmentIndex = beatIndexForFragment(route, fragment)
680
+ pendingFragment =
681
+ fragment && fragmentIndex === undefined ? fragment : undefined
682
+ activeIndex =
683
+ fragmentIndex ?? (position === "start" ? 0 : nearestBeatIndex(route))
684
+ seenThrough = activeIndex
685
+ const active = route.beats[activeIndex]
686
+ const activationTop = active?.target.getBoundingClientRect().top
687
+ document.documentElement.setAttribute("data-pmx-play-active", "")
688
+ controls.setPlaying(true)
689
+ controls.setVisible(true)
690
+ controls.setGuide(guide)
691
+ controls.setNotes(notes)
692
+ route.document.dataset.pmxPlayIntensity = intensity
693
+ controls.setIntensity(intensity)
694
+ applyMode(nextMode, false)
695
+ present(nextMode === "presentation", 0, true, scrollBehavior)
696
+ returnKeyboardToDocument()
697
+ scheduleQuiet()
698
+ if (nextMode === "focus" && active && activationTop !== undefined) {
699
+ requestAnimationFrame(() => {
700
+ if (mode !== "focus" || !active.target.isConnected) return
701
+ scrollSync.preserve(active.target, activationTop)
702
+ updateGuideFrame()
703
+ requestAnimationFrame(() => {
704
+ if (mode === "focus") scrollSync.setEnabled(true)
705
+ })
706
+ })
707
+ return
708
+ }
709
+ if (
710
+ nextMode === "presentation" &&
711
+ (fragmentIndex !== undefined || position === "start") &&
712
+ document.readyState !== "complete"
713
+ ) {
714
+ window.addEventListener(
715
+ "load",
716
+ () => reanchorPresentation(active?.id),
717
+ { once: true },
718
+ )
719
+ scrollSync.setEnabled(false)
720
+ return
721
+ }
722
+ scrollSync.setEnabled(true)
723
+ }
724
+
725
+ function startPreferredPlay() {
726
+ activate(
727
+ preferences.mode,
728
+ preferences.intensity,
729
+ preferences.theme,
730
+ preferences.guide,
731
+ preferences.notes,
732
+ )
733
+ }
734
+
735
+ function restoreDocumentAnchor(target: HTMLElement, viewportTop: number) {
736
+ requestAnimationFrame(() => {
737
+ if (!target.isConnected || mode) return
738
+ const shift = target.getBoundingClientRect().top - viewportTop
739
+ if (Math.abs(shift) > 0.5) {
740
+ window.scrollBy({ top: shift, behavior: "instant" })
741
+ }
742
+ })
743
+ }
744
+
745
+ function deactivate(updateLocation = true) {
746
+ if (grid.isOpen()) setGrid(false, false)
747
+ const anchor = route?.beats[activeIndex]?.target
748
+ const anchorTop = anchor
749
+ ? Math.max(
750
+ innerHeight * 0.12,
751
+ Math.min(innerHeight * 0.78, anchor.getBoundingClientRect().top),
752
+ )
753
+ : undefined
754
+ clearRoutePresentation()
755
+ clearQuietTimer()
756
+ document.documentElement.removeAttribute("data-pmx-play-active")
757
+ document.documentElement.removeAttribute("data-pmx-play-mode")
758
+ document.documentElement.removeAttribute("data-pmx-play-scrolling")
759
+ document.documentElement.removeAttribute("data-pmx-play-guide-tracking")
760
+ scrollSync.setEnabled(false)
761
+ observeGuideLayout()
762
+ route = undefined
763
+ progressBlocks = []
764
+ mode = undefined
765
+ activeIndex = -1
766
+ seenThrough = -1
767
+ pendingFragment = undefined
768
+ ownedFragment = undefined
769
+ activeActions = []
770
+ expanded = false
771
+ controls.setActions([])
772
+ controls.setNote(undefined)
773
+ controls.setPlaying(false)
774
+ controls.setVisible(true)
775
+ releasePresentationScroll()
776
+ if (updateLocation) updateUrl(undefined)
777
+ if (anchor && anchorTop !== undefined)
778
+ restoreDocumentAnchor(anchor, anchorTop)
779
+ }
780
+
781
+ function selectBeat(
782
+ nextIndex: number,
783
+ scroll = true,
784
+ scrollBehavior: PlayScrollBehavior = "smooth",
785
+ historyBehavior: PlayHistoryBehavior = "replace",
786
+ ) {
787
+ if (!route || !mode) return
788
+ pendingFragment = undefined
789
+ nextIndex = Math.max(0, Math.min(route.beats.length - 1, nextIndex))
790
+ if (nextIndex === activeIndex) return
791
+ const current = route.beats[activeIndex]
792
+ const next = route.beats[nextIndex]
793
+ if (!next) return
794
+ const direction = Math.sign(nextIndex - activeIndex)
795
+ const shouldScroll =
796
+ scroll &&
797
+ (!current || current.block !== next.block || current.target !== next.target)
798
+ activeIndex = nextIndex
799
+ present(shouldScroll, direction, true, scrollBehavior, historyBehavior)
800
+ }
801
+
802
+ function navigateToBeatFragment(
803
+ fragment: string,
804
+ historyBehavior: PlayHistoryBehavior,
805
+ ) {
806
+ if (!route || !mode) return false
807
+ const nextIndex = beatIndexForFragment(route, fragment)
808
+ if (nextIndex === undefined) return false
809
+ pendingFragment = undefined
810
+ if (historyBehavior === "none") {
811
+ const nextFragment = route.beats[nextIndex]?.fragment
812
+ ownedFragment =
813
+ nextFragment === requestedFragment() ? nextFragment : undefined
814
+ }
815
+ if (nextIndex === activeIndex) {
816
+ present(true, 0, false, "instant", historyBehavior)
817
+ } else {
818
+ selectBeat(nextIndex, true, "instant", historyBehavior)
819
+ }
820
+ returnKeyboardToDocument()
821
+ return true
822
+ }
823
+
824
+ function restoreBeatHistory(event: PopStateEvent) {
825
+ if (!route || !mode) return
826
+ const entry = playHistoryEntry(event.state)
827
+ if (entry?.sourceId === sourceDescription.source.id) {
828
+ const nextIndex = route.beats.findIndex((beat) => beat.id === entry.beatId)
829
+ if (nextIndex >= 0) {
830
+ const next = route.beats[nextIndex]
831
+ const nextFragment = next?.fragment
832
+ pendingFragment = undefined
833
+ ownedFragment =
834
+ nextFragment === requestedFragment() ? nextFragment : undefined
835
+ if (nextIndex === activeIndex) {
836
+ present(true, 0, false, "instant", "none")
837
+ } else {
838
+ selectBeat(nextIndex, true, "instant", "none")
839
+ }
840
+ return
841
+ }
842
+ }
843
+ const fragment = requestedFragment()
844
+ if (fragment) navigateToBeatFragment(fragment, "none")
845
+ }
846
+
847
+ function move(change: number, scrollBehavior: PlayScrollBehavior = "smooth") {
848
+ selectBeat(activeIndex + change, true, scrollBehavior)
849
+ }
850
+
851
+ function moveBlock(
852
+ change: number,
853
+ scrollBehavior: PlayScrollBehavior = "smooth",
854
+ ) {
855
+ if (!route || !mode) return
856
+ const current = route.beats[activeIndex]
857
+ if (!current) return
858
+ const blocks = [...new Set(route.beats.map((beat) => beat.block))]
859
+ const blockIndex = blocks.indexOf(current.block)
860
+ const nextBlock = blocks[blockIndex + change]
861
+ if (!nextBlock) return
862
+ const nextIndex = route.beats.findIndex((beat) => beat.block === nextBlock)
863
+ if (nextIndex >= 0) selectBeat(nextIndex, true, scrollBehavior)
864
+ }
865
+
866
+ function adoptScrollPosition(announce = true) {
867
+ if (!route || !mode) return
868
+ const nextIndex =
869
+ mode === "presentation"
870
+ ? nearestBlockBeatIndex(route, activeIndex)
871
+ : nearestBeatIndex(route, activeIndex)
872
+ if (nextIndex === activeIndex) return
873
+ pendingFragment = undefined
874
+ const direction = Math.sign(nextIndex - activeIndex)
875
+ activeIndex = nextIndex
876
+ present(false, direction, announce)
877
+ }
878
+
879
+ function refreshRoute(type: "update" | "navigation" = "update") {
880
+ if (!route || !mode) return
881
+ const navigated = type === "navigation"
882
+ if (navigated) ownedFragment = undefined
883
+ const active = route.beats[activeIndex]
884
+ const previousId = navigated ? undefined : active?.id
885
+ const restoreGrid = grid.isOpen()
886
+ if (restoreGrid) setGrid(false, false)
887
+ clearRoutePresentation()
888
+ route = extractCurrentPlayRoute()
889
+ if (!route) {
890
+ deactivate()
891
+ return
892
+ }
893
+ progressBlocks = deriveProgressBlocks(route)
894
+ applyBlockVariants(route, sourceDescription)
895
+ const fragment = navigated ? requestedFragment() : pendingFragment
896
+ const fragmentIndex = beatIndexForFragment(route, fragment)
897
+ const resolvedFragment = fragmentIndex !== undefined
898
+ if (resolvedFragment) pendingFragment = undefined
899
+ else if (navigated) pendingFragment = fragment
900
+ const surviving = previousId
901
+ ? route.beats.findIndex((beat) => beat.id === previousId)
902
+ : -1
903
+ activeIndex = resolvedFragment
904
+ ? fragmentIndex
905
+ : surviving >= 0
906
+ ? surviving
907
+ : navigated
908
+ ? 0
909
+ : nearestBeatIndex(route)
910
+ seenThrough = navigated
911
+ ? activeIndex
912
+ : Math.max(activeIndex, Math.min(seenThrough, route.beats.length - 1))
913
+ route.document.dataset.pmxPlay = mode
914
+ route.document.dataset.pmxPlayIntensity = intensity
915
+ applyTheme()
916
+ present(
917
+ navigated || resolvedFragment,
918
+ 0,
919
+ resolvedFragment,
920
+ navigated || resolvedFragment ? "instant" : "smooth",
921
+ )
922
+ if (restoreGrid) setGrid(true)
923
+ }
924
+
925
+ function reconcileLocation(event?: Event) {
926
+ const nextAppRoot = currentPlayerAppRoot()
927
+ if (!nextAppRoot) {
928
+ if (mode) deactivate(false)
929
+ controls.unmount()
930
+ return
931
+ }
932
+ appRoot = nextAppRoot
933
+ const nextDescription = descriptionFrom(appRoot)
934
+ if (nextDescription) {
935
+ sourceDescription = nextDescription
936
+ controls.setDescription(nextDescription)
937
+ }
938
+ controls.mount()
939
+ const requested = requestedState()
940
+ if (!requested) {
941
+ if (mode) deactivate(false)
942
+ return
943
+ }
944
+ if (!mode) {
945
+ activate(
946
+ requested.mode,
947
+ requested.intensity,
948
+ requested.theme,
949
+ requested.guide,
950
+ requested.notes,
951
+ "instant",
952
+ "start",
953
+ )
954
+ return
955
+ }
956
+ const renderType =
957
+ event instanceof CustomEvent
958
+ ? (event.detail as { type?: string } | undefined)?.type
959
+ : undefined
960
+ const navigated = renderType === "navigation" || renderType === "traverse"
961
+ refreshRoute(navigated ? "navigation" : "update")
962
+ if (mode !== requested.mode) setMode(requested.mode)
963
+ if (intensity !== requested.intensity) setIntensity(requested.intensity)
964
+ if (theme !== requested.theme) setTheme(requested.theme)
965
+ if (guide !== requested.guide) setGuide(requested.guide)
966
+ if (notes !== requested.notes) setNotes(requested.notes)
967
+ }
968
+
969
+ installPlayerKeyboard({
970
+ active: () => mode !== undefined,
971
+ start: startPreferredPlay,
972
+ controlsExpanded: () => expanded,
973
+ gridOpen: grid.isOpen,
974
+ actionCount: () => activeActions.length,
975
+ activateAction,
976
+ adjustIntensity,
977
+ move,
978
+ moveBlock,
979
+ moveGrid: grid.move,
980
+ selectGrid: grid.selectFocused,
981
+ toggleGrid: () => setGrid(!grid.isOpen()),
982
+ toggleMode,
983
+ exit: () => deactivate(),
984
+ })
985
+
986
+ installPlayerGestures({
987
+ enabled: () => mode === "presentation" && !expanded && !grid.isOpen(),
988
+ surface: () => route?.document,
989
+ moveBeat: (offset) => {
990
+ adoptScrollPosition(false)
991
+ move(offset, "instant")
992
+ },
993
+ moveBlock: (offset) => {
994
+ adoptScrollPosition(false)
995
+ moveBlock(offset, "instant")
996
+ },
997
+ })
998
+
999
+ document.addEventListener("click", (event) => {
1000
+ if (
1001
+ event.defaultPrevented ||
1002
+ !route ||
1003
+ !mode ||
1004
+ !(event.target instanceof Element)
1005
+ )
1006
+ return
1007
+ const element = event.target.closest<HTMLElement>("[data-pmx-beat]")
1008
+ if (!element) return
1009
+ const index = route.beats.findIndex((beat) => beat.element === element)
1010
+ if (index >= 0) selectBeat(index, false)
1011
+ })
1012
+
1013
+ document.addEventListener("pointermove", revealControls, { passive: true })
1014
+ document.addEventListener("pointerdown", revealControls, { passive: true })
1015
+ document.addEventListener("keydown", revealControls, { capture: true })
1016
+ document.addEventListener("pmx:render", reconcileLocation)
1017
+ document.addEventListener("pmx:beats", () => refreshRoute())
1018
+ window.addEventListener("popstate", restoreBeatHistory)
1019
+ window.addEventListener("hashchange", () => {
1020
+ const fragment = requestedFragment()
1021
+ if (fragment) navigateToBeatFragment(fragment, "none")
1022
+ })
1023
+ window.addEventListener("resize", updatePlayerLayout, { passive: true })
1024
+ window.addEventListener("scroll", updateGuideFrame, { passive: true })
1025
+ window.addEventListener("load", updatePlayerLayout, { passive: true })
1026
+ window.addEventListener("pagehide", releasePresentationScroll)
1027
+ window.addEventListener("pageshow", (event) => {
1028
+ if (mode !== "presentation") return
1029
+ claimPresentationScroll()
1030
+ if (event.persisted) {
1031
+ reanchorPresentation(route?.beats[activeIndex]?.id)
1032
+ }
1033
+ })
1034
+
1035
+ controls.setPlaying(false)
1036
+ const initial = requestedState()
1037
+ if (initial) {
1038
+ activate(
1039
+ initial.mode,
1040
+ initial.intensity,
1041
+ initial.theme,
1042
+ initial.guide,
1043
+ initial.notes,
1044
+ "instant",
1045
+ "start",
1046
+ )
1047
+ }