@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/LICENSE +43 -0
- package/LICENSE.md +172 -0
- package/README.md +161 -0
- package/actions.ts +69 -0
- package/client.ts +1047 -0
- package/controls.tsx +716 -0
- package/fragments.ts +6 -0
- package/gestures.ts +143 -0
- package/grid.ts +199 -0
- package/guide.ts +86 -0
- package/index.ts +59 -0
- package/keyboard.ts +172 -0
- package/media.ts +47 -0
- package/navigation.ts +97 -0
- package/notes.css +65 -0
- package/notes.ts +10 -0
- package/package.json +32 -0
- package/play.css +1052 -0
- package/preferences.ts +71 -0
- package/progress.tsx +70 -0
- package/ref/deckset-exports/Letters from Sweden.dstheme +1 -0
- package/ref/deckset-exports/Merriweather.dstheme +1 -0
- package/ref/deckset-exports/Next.dstheme +1 -0
- package/route.ts +231 -0
- package/scroll.ts +276 -0
- package/themes.ts +39 -0
- package/variants.ts +36 -0
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
|
+
}
|