@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/controls.tsx ADDED
@@ -0,0 +1,716 @@
1
+ import { createRoot, type Root } from "react-dom/client"
2
+ import {
3
+ useEffect,
4
+ useState,
5
+ useSyncExternalStore,
6
+ type CSSProperties,
7
+ type ReactNode,
8
+ } from "react"
9
+ import { PathMXProvider } from "@pathmx/react"
10
+ import {
11
+ ActionButton,
12
+ Button,
13
+ Drawer,
14
+ DrawerContent,
15
+ DrawerDescription,
16
+ DrawerHeader,
17
+ DrawerTitle,
18
+ DrawerTrigger,
19
+ Label,
20
+ Popover,
21
+ PopoverContent,
22
+ PopoverDescription,
23
+ PopoverHeader,
24
+ PopoverTitle,
25
+ PopoverTrigger,
26
+ Select,
27
+ SelectContent,
28
+ SelectItem,
29
+ SelectTrigger,
30
+ SelectValue,
31
+ Separator,
32
+ Switch,
33
+ ToggleGroup,
34
+ ToggleGroupItem,
35
+ Tooltip,
36
+ TooltipContent,
37
+ TooltipProvider,
38
+ TooltipTrigger,
39
+ } from "@pathmx/react/ui"
40
+ import {
41
+ ChevronDown,
42
+ ChevronUp,
43
+ ExternalLink,
44
+ LayoutGrid,
45
+ MessageSquareQuote,
46
+ Play,
47
+ SlidersHorizontal,
48
+ Square,
49
+ X,
50
+ } from "lucide-react"
51
+ import type { SourceDescription } from "@pathmx/core"
52
+ import type { PlayAction } from "./actions.ts"
53
+ import type { PlayGuideFrame } from "./guide.ts"
54
+ import { PlayProgress, type PlayProgressBlock } from "./progress.tsx"
55
+ import { PLAY_THEMES, sourcePlayTheme, type PlayThemeId } from "./themes.ts"
56
+
57
+ export type PlayMode = "focus" | "presentation"
58
+ export type PlayIntensity = "low" | "medium" | "high"
59
+ export type PlayThemeChoice = "source" | PlayThemeId
60
+
61
+ type PlayCommands = {
62
+ start(): void
63
+ previous(): void
64
+ next(): void
65
+ setMode(mode: PlayMode): void
66
+ setIntensity(intensity: PlayIntensity): void
67
+ setTheme(theme: PlayThemeChoice): void
68
+ setGuide(enabled: boolean): void
69
+ setNotes(enabled: boolean): void
70
+ setGrid(open: boolean): void
71
+ setAutoHide(enabled: boolean): void
72
+ setExpanded(expanded: boolean): void
73
+ exit(): void
74
+ activateAction(index: number): void
75
+ }
76
+
77
+ type ControlsState = {
78
+ description: SourceDescription
79
+ actions: readonly PlayAction[]
80
+ intensity: PlayIntensity
81
+ mode: PlayMode
82
+ playing: boolean
83
+ index: number
84
+ total: number
85
+ label: string
86
+ blocks: readonly PlayProgressBlock[]
87
+ theme: PlayThemeChoice
88
+ guide: boolean
89
+ notes: boolean
90
+ grid: boolean
91
+ autoHide: boolean
92
+ visible: boolean
93
+ guideFrame?: PlayGuideFrame
94
+ note?: string
95
+ }
96
+
97
+ type ControlsStore = Readonly<{
98
+ subscribe(listener: () => void): () => void
99
+ snapshot(): ControlsState
100
+ }>
101
+
102
+ export const MOBILE_CONTROLS_QUERY = "(max-width: 42rem), (pointer: coarse)"
103
+
104
+ function subscribeMobile(listener: () => void) {
105
+ const media = matchMedia(MOBILE_CONTROLS_QUERY)
106
+ media.addEventListener("change", listener)
107
+ return () => media.removeEventListener("change", listener)
108
+ }
109
+
110
+ function mobileSnapshot() {
111
+ return matchMedia(MOBILE_CONTROLS_QUERY).matches
112
+ }
113
+
114
+ function useMobileControls() {
115
+ return useSyncExternalStore(subscribeMobile, mobileSnapshot, () => false)
116
+ }
117
+
118
+ function TooltippedAction(
119
+ props: Readonly<{
120
+ label: string
121
+ tooltip?: string
122
+ keyShortcuts?: string
123
+ icon: ReactNode
124
+ onClick(): void
125
+ disabled?: boolean
126
+ pressed?: boolean
127
+ appearance?: "plain" | "floating"
128
+ className?: string
129
+ }>,
130
+ ) {
131
+ return (
132
+ <Tooltip>
133
+ <TooltipTrigger
134
+ render={
135
+ <ActionButton
136
+ label={props.label}
137
+ icon={props.icon}
138
+ appearance={props.appearance}
139
+ disabled={props.disabled}
140
+ pressed={props.pressed}
141
+ className={props.className}
142
+ aria-keyshortcuts={props.keyShortcuts}
143
+ onClick={props.onClick}
144
+ />
145
+ }
146
+ />
147
+ <TooltipContent>{props.tooltip ?? props.label}</TooltipContent>
148
+ </Tooltip>
149
+ )
150
+ }
151
+
152
+ function NavigationActions({
153
+ state,
154
+ commands,
155
+ close,
156
+ mobile,
157
+ }: Readonly<{
158
+ state: ControlsState
159
+ commands: PlayCommands
160
+ close(): void
161
+ mobile: boolean
162
+ }>) {
163
+ const actions = [
164
+ {
165
+ label: "Previous Beat",
166
+ tooltip: "Previous Beat (↑)",
167
+ keyShortcuts: "ArrowUp",
168
+ icon: <ChevronUp aria-hidden="true" />,
169
+ disabled: state.index <= 0,
170
+ run: commands.previous,
171
+ },
172
+ {
173
+ label: "Next Beat",
174
+ tooltip: "Next Beat (↓)",
175
+ keyShortcuts: "ArrowDown",
176
+ icon: <ChevronDown aria-hidden="true" />,
177
+ disabled: state.index >= state.total - 1,
178
+ run: commands.next,
179
+ },
180
+ {
181
+ label: state.grid ? "Close Block Grid" : "Block Grid",
182
+ tooltip: state.grid ? "Close Block Grid (G)" : "Block Grid (G)",
183
+ keyShortcuts: "G",
184
+ icon: <LayoutGrid aria-hidden="true" />,
185
+ pressed: state.grid,
186
+ run: () => {
187
+ commands.setGrid(!state.grid)
188
+ close()
189
+ },
190
+ },
191
+ {
192
+ label: "Stop Play",
193
+ tooltip: "Stop Play (Esc)",
194
+ keyShortcuts: "Escape",
195
+ icon: <Square aria-hidden="true" fill="currentColor" stroke="none" />,
196
+ appearance: "floating" as const,
197
+ run: commands.exit,
198
+ },
199
+ ]
200
+ return (
201
+ <div className="pmx-play-panel-actions" role="group" aria-label="Playback">
202
+ {actions.map((action) => (
203
+ <div className="pmx-play-panel-action" key={action.label}>
204
+ <TooltippedAction
205
+ label={action.label}
206
+ tooltip={action.tooltip}
207
+ keyShortcuts={action.keyShortcuts}
208
+ icon={action.icon}
209
+ onClick={action.run}
210
+ disabled={action.disabled}
211
+ pressed={action.pressed}
212
+ appearance={action.appearance}
213
+ />
214
+ {mobile && <span>{action.label}</span>}
215
+ </div>
216
+ ))}
217
+ </div>
218
+ )
219
+ }
220
+
221
+ function SettingRow({
222
+ label,
223
+ children,
224
+ stacked = false,
225
+ }: Readonly<{ label: string; children: ReactNode; stacked?: boolean }>) {
226
+ return (
227
+ <div className="pmx-play-setting" data-stacked={stacked ? "" : undefined}>
228
+ <span>{label}</span>
229
+ <div>{children}</div>
230
+ </div>
231
+ )
232
+ }
233
+
234
+ function SettingsModel({
235
+ state,
236
+ commands,
237
+ close,
238
+ mobile,
239
+ }: Readonly<{
240
+ state: ControlsState
241
+ commands: PlayCommands
242
+ close(): void
243
+ mobile: boolean
244
+ }>) {
245
+ const defaultTheme = sourcePlayTheme(state.description)
246
+ const defaultThemeLabel =
247
+ PLAY_THEMES.find((theme) => theme.id === defaultTheme)?.label ?? "Native"
248
+ const themeItems = {
249
+ source: `Source default · ${defaultThemeLabel}`,
250
+ ...Object.fromEntries(PLAY_THEMES.map((theme) => [theme.id, theme.label])),
251
+ }
252
+ return (
253
+ <>
254
+ <NavigationActions
255
+ state={state}
256
+ commands={commands}
257
+ close={close}
258
+ mobile={mobile}
259
+ />
260
+ <Separator />
261
+ <div className="pmx-play-settings-scroll">
262
+ <SettingRow label="Mode">
263
+ <ToggleGroup
264
+ value={[state.mode]}
265
+ onValueChange={(value) => {
266
+ const mode = value[0]
267
+ if (mode === "focus" || mode === "presentation") {
268
+ commands.setMode(mode)
269
+ }
270
+ }}
271
+ spacing={0}
272
+ >
273
+ <ToggleGroupItem value="focus">Focus</ToggleGroupItem>
274
+ <ToggleGroupItem value="presentation">Slides</ToggleGroupItem>
275
+ </ToggleGroup>
276
+ </SettingRow>
277
+ <Separator />
278
+ <SettingRow label="Theme" stacked={mobile}>
279
+ <Select
280
+ items={themeItems}
281
+ value={state.theme}
282
+ onValueChange={(value) => {
283
+ if (
284
+ value === "source" ||
285
+ PLAY_THEMES.some((theme) => theme.id === value)
286
+ ) {
287
+ commands.setTheme(value as PlayThemeChoice)
288
+ }
289
+ }}
290
+ >
291
+ <SelectTrigger aria-label="Presentation theme">
292
+ <SelectValue />
293
+ </SelectTrigger>
294
+ <SelectContent data-pmx-player-control="">
295
+ <SelectItem value="source">
296
+ Source default · {defaultThemeLabel}
297
+ </SelectItem>
298
+ {PLAY_THEMES.map((theme) => (
299
+ <SelectItem key={theme.id} value={theme.id}>
300
+ {theme.label}
301
+ </SelectItem>
302
+ ))}
303
+ </SelectContent>
304
+ </Select>
305
+ </SettingRow>
306
+ <Separator />
307
+ <SettingRow label="Intensity">
308
+ <ToggleGroup
309
+ value={[state.intensity]}
310
+ onValueChange={(value) => {
311
+ const intensity = value[0]
312
+ if (
313
+ intensity === "low" ||
314
+ intensity === "medium" ||
315
+ intensity === "high"
316
+ ) {
317
+ commands.setIntensity(intensity)
318
+ }
319
+ }}
320
+ spacing={0}
321
+ >
322
+ <ToggleGroupItem value="low">Low</ToggleGroupItem>
323
+ <ToggleGroupItem value="medium">Medium</ToggleGroupItem>
324
+ <ToggleGroupItem value="high">High</ToggleGroupItem>
325
+ </ToggleGroup>
326
+ </SettingRow>
327
+ <Separator />
328
+ <Label className="pmx-play-switch-row">
329
+ <span>Beat Guide</span>
330
+ <Switch
331
+ checked={state.guide}
332
+ onCheckedChange={commands.setGuide}
333
+ aria-label="Show Beat Guide"
334
+ />
335
+ </Label>
336
+ <Separator />
337
+ <Label className="pmx-play-switch-row">
338
+ <span>Notes</span>
339
+ <Switch
340
+ checked={state.notes}
341
+ onCheckedChange={commands.setNotes}
342
+ aria-label="Show presenter notes"
343
+ />
344
+ </Label>
345
+ <Separator />
346
+ <Label className="pmx-play-switch-row">
347
+ <span>Controls</span>
348
+ <span className="pmx-play-switch-value">Auto-hide</span>
349
+ <Switch
350
+ checked={state.autoHide}
351
+ onCheckedChange={commands.setAutoHide}
352
+ aria-label="Auto-hide controls"
353
+ />
354
+ </Label>
355
+ </div>
356
+ </>
357
+ )
358
+ }
359
+
360
+ function SettingsSurface({
361
+ state,
362
+ commands,
363
+ }: Readonly<{ state: ControlsState; commands: PlayCommands }>) {
364
+ const mobile = useMobileControls()
365
+ const [open, setOpen] = useState(false)
366
+ useEffect(() => {
367
+ if (state.playing) return
368
+ setOpen(false)
369
+ }, [state.playing])
370
+ useEffect(() => commands.setExpanded(open), [commands, open])
371
+ const changeOpen = (next: boolean) => setOpen(next)
372
+ const trigger = (
373
+ <ActionButton
374
+ label="Player controls"
375
+ icon={<SlidersHorizontal aria-hidden="true" />}
376
+ appearance="floating"
377
+ size="sm"
378
+ pressed={open}
379
+ className="pmx-play-controls-trigger"
380
+ />
381
+ )
382
+
383
+ if (mobile) {
384
+ return (
385
+ <Drawer
386
+ open={open}
387
+ onOpenChange={changeOpen}
388
+ showSwipeHandle
389
+ swipeDirection="down"
390
+ >
391
+ <DrawerTrigger render={trigger} />
392
+ <DrawerContent data-pmx-player-control="">
393
+ <DrawerHeader>
394
+ <DrawerTitle>Player controls</DrawerTitle>
395
+ <DrawerDescription>
396
+ Navigation, focus, presentation, and visibility settings.
397
+ </DrawerDescription>
398
+ </DrawerHeader>
399
+ <SettingsModel
400
+ state={state}
401
+ commands={commands}
402
+ close={() => changeOpen(false)}
403
+ mobile
404
+ />
405
+ </DrawerContent>
406
+ </Drawer>
407
+ )
408
+ }
409
+
410
+ return (
411
+ <Popover open={open} onOpenChange={changeOpen}>
412
+ <PopoverTrigger render={trigger} />
413
+ <PopoverContent data-pmx-player-control="">
414
+ <PopoverHeader>
415
+ <PopoverTitle>Player controls</PopoverTitle>
416
+ <PopoverDescription>
417
+ Navigation, focus, presentation, and visibility settings.
418
+ </PopoverDescription>
419
+ </PopoverHeader>
420
+ <SettingsModel
421
+ state={state}
422
+ commands={commands}
423
+ close={() => changeOpen(false)}
424
+ mobile={false}
425
+ />
426
+ </PopoverContent>
427
+ </Popover>
428
+ )
429
+ }
430
+
431
+ function ActiveActions({
432
+ actions,
433
+ activate,
434
+ }: Readonly<{
435
+ actions: readonly PlayAction[]
436
+ activate(index: number): void
437
+ }>) {
438
+ if (!actions.length) return null
439
+ return (
440
+ <div className="pmx-play-actions" aria-label="Actions for this Beat">
441
+ {actions.map((action, index) => (
442
+ <Button
443
+ key={`${action.label}:${index}`}
444
+ variant="outline"
445
+ size="sm"
446
+ data-pmx-play-action=""
447
+ onPointerDown={(event) => {
448
+ if (event.pointerType === "mouse") event.preventDefault()
449
+ }}
450
+ onClick={() => activate(index)}
451
+ >
452
+ <kbd>{index + 1}</kbd>
453
+ <span>{action.label}</span>
454
+ {action.external && <ExternalLink aria-hidden="true" size={15} />}
455
+ </Button>
456
+ ))}
457
+ </div>
458
+ )
459
+ }
460
+
461
+ function BeatGuide({ state }: Readonly<{ state: ControlsState }>) {
462
+ const frame = state.guideFrame
463
+ return (
464
+ <div
465
+ className="pmx-play-guide"
466
+ data-highlight={frame?.highlight ?? "none"}
467
+ data-visible={state.playing && state.guide && frame ? "true" : "false"}
468
+ aria-hidden="true"
469
+ style={
470
+ frame
471
+ ? ({
472
+ "--pmx-guide-left": `${frame.guideLeft}px`,
473
+ "--pmx-guide-top": `${frame.top}px`,
474
+ "--pmx-guide-height": `${frame.height}px`,
475
+ "--pmx-guide-target-left": `${frame.targetLeft}px`,
476
+ "--pmx-guide-target-width": `${frame.targetWidth}px`,
477
+ } as CSSProperties)
478
+ : undefined
479
+ }
480
+ >
481
+ <span data-pmx-play-guide-bar="" />
482
+ <span data-pmx-play-guide-highlight="" />
483
+ </div>
484
+ )
485
+ }
486
+
487
+ function PresenterNote({ state }: Readonly<{ state: ControlsState }>) {
488
+ if (
489
+ !state.playing ||
490
+ state.mode !== "presentation" ||
491
+ state.grid ||
492
+ !state.notes ||
493
+ !state.note
494
+ ) {
495
+ return null
496
+ }
497
+ return (
498
+ <aside
499
+ className="pmx-play-note"
500
+ role="note"
501
+ aria-label="Presenter note"
502
+ aria-live="polite"
503
+ >
504
+ <MessageSquareQuote aria-hidden="true" />
505
+ <div dangerouslySetInnerHTML={{ __html: state.note }} />
506
+ </aside>
507
+ )
508
+ }
509
+
510
+ function PlayerChrome({
511
+ state,
512
+ commands,
513
+ }: Readonly<{ state: ControlsState; commands: PlayCommands }>) {
514
+ return (
515
+ <TooltipProvider>
516
+ <PlayProgress
517
+ playing={state.playing}
518
+ mode={state.mode}
519
+ index={state.index}
520
+ total={state.total}
521
+ blocks={state.blocks}
522
+ />
523
+ <BeatGuide state={state} />
524
+ <PresenterNote state={state} />
525
+ {state.playing && state.mode === "presentation" && (
526
+ <ActionButton
527
+ label="Exit Play"
528
+ icon={<X aria-hidden="true" />}
529
+ appearance="floating"
530
+ className="pmx-play-exit"
531
+ data-pmx-player-control=""
532
+ onClick={commands.exit}
533
+ />
534
+ )}
535
+ <div
536
+ className="pmx-play-chrome"
537
+ data-pmx-player-control=""
538
+ data-pmx-play-state={state.playing ? "playing" : "idle"}
539
+ data-pmx-play-visible={
540
+ state.visible || !state.autoHide ? "" : undefined
541
+ }
542
+ >
543
+ {!state.playing ? (
544
+ <TooltippedAction
545
+ label="Play document"
546
+ tooltip="Play document (Command/Control + Enter)"
547
+ keyShortcuts="Meta+Enter Control+Enter"
548
+ icon={<Play aria-hidden="true" fill="currentColor" stroke="none" />}
549
+ appearance="floating"
550
+ className="pmx-play-launcher"
551
+ onClick={commands.start}
552
+ />
553
+ ) : (
554
+ <>
555
+ <ActiveActions
556
+ actions={state.actions}
557
+ activate={commands.activateAction}
558
+ />
559
+ <SettingsSurface state={state} commands={commands} />
560
+ </>
561
+ )}
562
+ <output
563
+ className="pmx-ui-visually-hidden"
564
+ data-pmx-play-progress=""
565
+ aria-live="polite"
566
+ >
567
+ {state.total
568
+ ? `${state.index + 1} of ${state.total}: ${state.label}`
569
+ : ""}
570
+ </output>
571
+ </div>
572
+ </TooltipProvider>
573
+ )
574
+ }
575
+
576
+ function PlayerControlsApp({
577
+ store,
578
+ commands,
579
+ }: Readonly<{ store: ControlsStore; commands: PlayCommands }>) {
580
+ const state = useSyncExternalStore(
581
+ store.subscribe,
582
+ store.snapshot,
583
+ store.snapshot,
584
+ )
585
+ return (
586
+ <PathMXProvider description={state.description}>
587
+ <PlayerChrome state={state} commands={commands} />
588
+ </PathMXProvider>
589
+ )
590
+ }
591
+
592
+ function playerViewportLayer(host: HTMLElement) {
593
+ const document = host.ownerDocument
594
+ const augmentations = document.querySelector<HTMLElement>(
595
+ "body > [data-pmx-augmentations]",
596
+ )
597
+ if (!augmentations) {
598
+ throw new Error("Missing PathMX augmentation layer.")
599
+ }
600
+ const existing = augmentations.querySelector<HTMLElement>(
601
+ ':scope > [data-pmx-augmentation="player"]',
602
+ )
603
+ if (existing) return existing
604
+ const layer = document.createElement("div")
605
+ layer.setAttribute("data-pmx-augmentation", "player")
606
+ augmentations.append(layer)
607
+ return layer
608
+ }
609
+
610
+ /** Mount Player chrome above authored layout and containing-block effects. */
611
+ export function createPlayControls(
612
+ host: HTMLElement,
613
+ description: SourceDescription,
614
+ commands: PlayCommands,
615
+ ) {
616
+ const layer = playerViewportLayer(host)
617
+ let root: Root | undefined
618
+ let state: ControlsState = {
619
+ description,
620
+ actions: [],
621
+ intensity: "medium",
622
+ mode: "focus",
623
+ playing: false,
624
+ index: 0,
625
+ total: 0,
626
+ label: "",
627
+ blocks: [],
628
+ theme: "source",
629
+ guide: true,
630
+ notes: true,
631
+ grid: false,
632
+ autoHide: true,
633
+ visible: true,
634
+ }
635
+ const listeners = new Set<() => void>()
636
+ const store: ControlsStore = {
637
+ subscribe(listener) {
638
+ listeners.add(listener)
639
+ return () => listeners.delete(listener)
640
+ },
641
+ snapshot: () => state,
642
+ }
643
+ const update = (next: Partial<ControlsState>) => {
644
+ state = { ...state, ...next }
645
+ for (const listener of listeners) listener()
646
+ }
647
+ const mount = () => {
648
+ if (root) return
649
+ if (!layer.isConnected) {
650
+ const augmentations = host.ownerDocument.querySelector(
651
+ "body > [data-pmx-augmentations]",
652
+ )
653
+ if (!augmentations) throw new Error("Missing PathMX augmentation layer.")
654
+ augmentations.append(layer)
655
+ }
656
+ root = createRoot(layer)
657
+ root.render(<PlayerControlsApp store={store} commands={commands} />)
658
+ }
659
+ const unmount = () => {
660
+ root?.unmount()
661
+ root = undefined
662
+ layer.remove()
663
+ }
664
+ mount()
665
+ return {
666
+ mount,
667
+ unmount,
668
+ setDescription(description: SourceDescription) {
669
+ update({ description })
670
+ },
671
+ setActions(actions: readonly PlayAction[]) {
672
+ update({ actions })
673
+ },
674
+ setIntensity(intensity: PlayIntensity) {
675
+ update({ intensity })
676
+ },
677
+ setMode(mode: PlayMode) {
678
+ update({ mode })
679
+ },
680
+ setPlaying(playing: boolean) {
681
+ update({ playing })
682
+ },
683
+ setProgress(
684
+ index: number,
685
+ total: number,
686
+ label: string,
687
+ blocks: readonly PlayProgressBlock[],
688
+ ) {
689
+ update({ index, total, label, blocks })
690
+ },
691
+ setTheme(theme: PlayThemeChoice) {
692
+ update({ theme })
693
+ },
694
+ setGuide(guide: boolean) {
695
+ update({ guide })
696
+ },
697
+ setNotes(notes: boolean) {
698
+ update({ notes })
699
+ },
700
+ setGrid(grid: boolean) {
701
+ update({ grid })
702
+ },
703
+ setAutoHide(autoHide: boolean) {
704
+ update({ autoHide })
705
+ },
706
+ setVisible(visible: boolean) {
707
+ update({ visible })
708
+ },
709
+ setGuideFrame(guideFrame?: PlayGuideFrame) {
710
+ update({ guideFrame })
711
+ },
712
+ setNote(note?: string) {
713
+ update({ note })
714
+ },
715
+ }
716
+ }
package/fragments.ts ADDED
@@ -0,0 +1,6 @@
1
+ /** Return an authored Beat value that is safe to expose as a URL fragment. */
2
+ export function explicitBeatFragment(value: string | null | undefined) {
3
+ const fragment = value?.trim()
4
+ if (!fragment || /[\s#]/u.test(fragment)) return
5
+ return fragment
6
+ }