@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/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