@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/preferences.ts ADDED
@@ -0,0 +1,71 @@
1
+ import type { PlayIntensity, PlayMode, PlayThemeChoice } from "./controls.tsx"
2
+ import { isPlayTheme } from "./themes.ts"
3
+
4
+ export type PlayPreferences = Readonly<{
5
+ mode: PlayMode
6
+ intensity: PlayIntensity
7
+ theme: PlayThemeChoice
8
+ guide: boolean
9
+ notes: boolean
10
+ autoHide: boolean
11
+ }>
12
+
13
+ const PREFERENCES_KEY = "pathmx:play-preferences"
14
+ const LEGACY_CONTROLS_KEY = "pathmx:play-controls"
15
+ const DEFAULTS: PlayPreferences = {
16
+ mode: "focus",
17
+ intensity: "medium",
18
+ theme: "source",
19
+ guide: true,
20
+ notes: true,
21
+ autoHide: true,
22
+ }
23
+
24
+ function readPreferences(): PlayPreferences {
25
+ try {
26
+ const value = localStorage.getItem(PREFERENCES_KEY)
27
+ const stored = value ? (JSON.parse(value) as Record<string, unknown>) : {}
28
+ const legacyAutoHide =
29
+ localStorage.getItem(LEGACY_CONTROLS_KEY) !== "always"
30
+ return {
31
+ mode:
32
+ stored.mode === "focus" || stored.mode === "presentation"
33
+ ? stored.mode
34
+ : DEFAULTS.mode,
35
+ intensity:
36
+ stored.intensity === "low" ||
37
+ stored.intensity === "medium" ||
38
+ stored.intensity === "high"
39
+ ? stored.intensity
40
+ : DEFAULTS.intensity,
41
+ theme:
42
+ stored.theme === "source" ||
43
+ (typeof stored.theme === "string" && isPlayTheme(stored.theme))
44
+ ? stored.theme
45
+ : DEFAULTS.theme,
46
+ guide: typeof stored.guide === "boolean" ? stored.guide : DEFAULTS.guide,
47
+ notes: typeof stored.notes === "boolean" ? stored.notes : DEFAULTS.notes,
48
+ autoHide:
49
+ typeof stored.autoHide === "boolean" ? stored.autoHide : legacyAutoHide,
50
+ }
51
+ } catch {
52
+ return DEFAULTS
53
+ }
54
+ }
55
+
56
+ /** Own the browser-local Player settings boundary and legacy migration. */
57
+ export function createPlayPreferenceStore() {
58
+ let preferences = readPreferences()
59
+ return {
60
+ snapshot: () => preferences,
61
+ update(next: Partial<PlayPreferences>) {
62
+ preferences = { ...preferences, ...next }
63
+ try {
64
+ localStorage.setItem(PREFERENCES_KEY, JSON.stringify(preferences))
65
+ } catch {
66
+ // Local preference storage is optional.
67
+ }
68
+ return preferences
69
+ },
70
+ }
71
+ }
package/progress.tsx ADDED
@@ -0,0 +1,70 @@
1
+ import type { CSSProperties } from "react"
2
+
3
+ export type PlayProgressBlock = Readonly<{
4
+ start: number
5
+ total: number
6
+ }>
7
+
8
+ type PlayProgressProps = Readonly<{
9
+ playing: boolean
10
+ mode: "focus" | "presentation"
11
+ index: number
12
+ total: number
13
+ blocks: readonly PlayProgressBlock[]
14
+ }>
15
+
16
+ /** Render continuous Focus progress or Beat-filled Block Slides progress. */
17
+ export function PlayProgress(props: PlayProgressProps) {
18
+ const compact = props.blocks.length > 18
19
+ return (
20
+ <div
21
+ className="pmx-play-progressbar"
22
+ data-pmx-play-progressbar=""
23
+ data-mode={props.mode}
24
+ aria-hidden="true"
25
+ hidden={!props.playing}
26
+ style={
27
+ {
28
+ "--pmx-play-progress": props.total
29
+ ? String((props.index + 1) / props.total)
30
+ : "0",
31
+ "--pmx-play-block-gap": compact ? "3px" : "5px",
32
+ } as CSSProperties
33
+ }
34
+ >
35
+ {props.mode === "presentation" ? (
36
+ <span className="pmx-play-progress-blocks">
37
+ {props.blocks.map((block) => (
38
+ <span
39
+ className="pmx-play-progress-block"
40
+ data-pmx-play-progress-block=""
41
+ data-beats={block.total}
42
+ data-state={
43
+ props.index >= block.start + block.total
44
+ ? "complete"
45
+ : props.index >= block.start
46
+ ? "current"
47
+ : "upcoming"
48
+ }
49
+ key={block.start}
50
+ style={
51
+ {
52
+ "--pmx-play-block-progress":
53
+ props.index < block.start
54
+ ? "0"
55
+ : props.index >= block.start + block.total
56
+ ? "1"
57
+ : String((props.index - block.start + 1) / block.total),
58
+ } as CSSProperties
59
+ }
60
+ >
61
+ <span data-pmx-play-progress-block-fill="" />
62
+ </span>
63
+ ))}
64
+ </span>
65
+ ) : (
66
+ <span data-pmx-play-progress-fill="" />
67
+ )}
68
+ </div>
69
+ )
70
+ }
@@ -0,0 +1 @@
1
+ {"type":0,"identifier":"letters from sweden","customizations":{}}
@@ -0,0 +1 @@
1
+ {"identifier":"merriweather","type":0,"customizations":{}}
@@ -0,0 +1 @@
1
+ {"identifier":"next","type":0,"customizations":{}}
package/route.ts ADDED
@@ -0,0 +1,231 @@
1
+ import { explicitBeatFragment } from "./fragments.ts"
2
+
3
+ export type Beat = {
4
+ type: "beat"
5
+ id: string
6
+ fragment?: string
7
+ block: HTMLElement
8
+ element: HTMLElement
9
+ target: HTMLElement
10
+ label: string
11
+ member?:
12
+ | Readonly<{ type: "table-row" }>
13
+ | Readonly<{ type: "code-lines"; lines: readonly number[] }>
14
+ }
15
+
16
+ export type PlayRoute = {
17
+ document: HTMLElement
18
+ beats: Beat[]
19
+ }
20
+
21
+ const BLOCK_SELECTOR = ":scope > section[data-pmx-block]"
22
+ const BEAT_SELECTOR = [
23
+ "[data-pmx-beat]",
24
+ "h1",
25
+ "h2",
26
+ "h3",
27
+ "h4",
28
+ "h5",
29
+ "h6",
30
+ "p",
31
+ "li",
32
+ "blockquote",
33
+ "dl",
34
+ "figure",
35
+ "picture",
36
+ "img",
37
+ "svg",
38
+ "canvas",
39
+ "iframe",
40
+ "video",
41
+ "audio",
42
+ "pre",
43
+ ".pmx-code-block",
44
+ "table",
45
+ "details",
46
+ "form",
47
+ "fieldset",
48
+ '[data-pathmx-math="display"]',
49
+ '[role="img"]',
50
+ "[data-component]",
51
+ ].join(",")
52
+
53
+ function elementMode(element: Element) {
54
+ return element.closest<HTMLElement>("[data-pmx-beats]")?.dataset.pmxBeats
55
+ }
56
+
57
+ function isUnavailable(element: HTMLElement) {
58
+ return Boolean(
59
+ element.closest(
60
+ '[hidden], template, script, style, [aria-hidden="true"], [data-pmx-app], [data-pmx-beats="off"]',
61
+ ),
62
+ )
63
+ }
64
+
65
+ function isMeaningful(element: HTMLElement) {
66
+ if (element.matches("p") && !element.textContent?.trim()) return false
67
+ if (element.matches("img")) return Boolean(element.getAttribute("alt"))
68
+ return true
69
+ }
70
+
71
+ function playTarget(element: HTMLElement) {
72
+ const selector = element.dataset.pmxPlayTarget
73
+ if (!selector) return element
74
+ try {
75
+ const target = document.querySelector(selector)
76
+ return target instanceof HTMLElement ? target : element
77
+ } catch {
78
+ console.warn(`[pathmx-play] invalid target selector: ${selector}`)
79
+ return element
80
+ }
81
+ }
82
+
83
+ function labelFor(element: HTMLElement, fallback: string) {
84
+ const label =
85
+ element.dataset.pmxBeatLabel ??
86
+ element.getAttribute("aria-label") ??
87
+ element.getAttribute("alt") ??
88
+ element.textContent
89
+ const normalized = label?.replace(/\s+/g, " ").trim()
90
+ return normalized ? normalized.slice(0, 120) : fallback
91
+ }
92
+
93
+ function codeLineGroups(element: HTMLElement) {
94
+ const authored = element.dataset.pmxCodeSteps
95
+ if (!authored) return []
96
+ return authored.split("|").flatMap((part) => {
97
+ const [startText, endText = startText] = part.split("-")
98
+ const start = Number(startText)
99
+ const end = Number(endText)
100
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 1) {
101
+ return []
102
+ }
103
+ const last = Math.min(Math.max(start, end), start + 200)
104
+ return [
105
+ Array.from({ length: last - start + 1 }, (_, index) => start + index),
106
+ ]
107
+ })
108
+ }
109
+
110
+ function authoredBlockFragment(block: HTMLElement) {
111
+ return /^block-\d+$/u.test(block.id) ? undefined : block.id || undefined
112
+ }
113
+
114
+ /** Return the compiled presenter notes owned by one rendered Block. */
115
+ export function playNoteForBlock(block: HTMLElement) {
116
+ const notes = block.querySelectorAll<HTMLElement>("[data-pmx-play-note]")
117
+ if (!notes.length) return
118
+ return [...notes].map((note) => `<p>${note.innerHTML.trim()}</p>`).join("")
119
+ }
120
+
121
+ /** Derive one flat, ordered Play route from the rendered document. */
122
+ export function extractPlayRoute(): PlayRoute | undefined {
123
+ const playDocument = document.querySelector(".pmx-document")
124
+ if (!(playDocument instanceof HTMLElement)) return
125
+
126
+ const beats: Beat[] = []
127
+ const ids = new Set<string>()
128
+ const blocks = playDocument.querySelectorAll<HTMLElement>(BLOCK_SELECTOR)
129
+
130
+ blocks.forEach((block, blockIndex) => {
131
+ const blockId = block.id || `block-${blockIndex}`
132
+ const accepted: HTMLElement[] = []
133
+
134
+ for (const element of block.querySelectorAll<HTMLElement>(BEAT_SELECTOR)) {
135
+ if (isUnavailable(element)) continue
136
+ const explicit = element.hasAttribute("data-pmx-beat")
137
+ if (!explicit && elementMode(element) === "explicit") continue
138
+ if (!explicit && !isMeaningful(element)) continue
139
+
140
+ const component = element.closest<HTMLElement>("[data-component]")
141
+ if (!explicit && component && component !== element) continue
142
+ if (!explicit && accepted.some((parent) => parent.contains(element))) {
143
+ continue
144
+ }
145
+
146
+ const authoredValue = element.dataset.pmxBeat?.trim()
147
+ const authoredId = explicitBeatFragment(authoredValue)
148
+ if (authoredValue && !authoredId) {
149
+ console.warn(
150
+ `[pathmx-play] invalid Beat fragment ignored: ${authoredValue}`,
151
+ )
152
+ }
153
+ const id = authoredId || `${blockId}:beat-${beats.length}`
154
+ if (ids.has(id)) {
155
+ console.warn(`[pathmx-play] duplicate beat id ignored: ${id}`)
156
+ continue
157
+ }
158
+ ids.add(id)
159
+ accepted.push(element)
160
+ const target = playTarget(element)
161
+ if (authoredId && !element.id) {
162
+ const owner = document.getElementById(authoredId)
163
+ if (!owner || owner === element) element.id = authoredId
164
+ else {
165
+ console.warn(
166
+ `[pathmx-play] Beat fragment collides with an existing id: ${authoredId}`,
167
+ )
168
+ }
169
+ }
170
+ beats.push({
171
+ type: "beat",
172
+ id,
173
+ fragment: element.id || target.id || authoredId,
174
+ block,
175
+ element,
176
+ target,
177
+ label: labelFor(element, id),
178
+ })
179
+
180
+ if (element.matches("table")) {
181
+ for (const [rowIndex, row] of element
182
+ .querySelectorAll<HTMLElement>("tbody > tr")
183
+ .entries()) {
184
+ const rowId = `${id}:row-${rowIndex + 1}`
185
+ ids.add(rowId)
186
+ beats.push({
187
+ type: "beat",
188
+ id: rowId,
189
+ block,
190
+ element: row,
191
+ target: row,
192
+ label: labelFor(row, `Table row ${rowIndex + 1}`),
193
+ member: { type: "table-row" },
194
+ })
195
+ }
196
+ }
197
+
198
+ if (element.matches(".pmx-code-block")) {
199
+ for (const [groupIndex, lines] of codeLineGroups(element).entries()) {
200
+ const groupId = `${id}:lines-${groupIndex + 1}`
201
+ ids.add(groupId)
202
+ beats.push({
203
+ type: "beat",
204
+ id: groupId,
205
+ block,
206
+ element,
207
+ target: element,
208
+ label: `Code lines ${lines.join(", ")}`,
209
+ member: { type: "code-lines", lines },
210
+ })
211
+ }
212
+ }
213
+ }
214
+
215
+ if (accepted.length === 0) {
216
+ const id = `${blockId}:beat`
217
+ ids.add(id)
218
+ beats.push({
219
+ type: "beat",
220
+ id,
221
+ fragment: authoredBlockFragment(block),
222
+ block,
223
+ element: block,
224
+ target: block,
225
+ label: labelFor(block, blockId),
226
+ })
227
+ }
228
+ })
229
+
230
+ return beats.length ? { document: playDocument, beats } : undefined
231
+ }
package/scroll.ts ADDED
@@ -0,0 +1,276 @@
1
+ import type { PlayMode } from "./controls.ts"
2
+
3
+ export type PlayScrollBehavior = "smooth" | "instant"
4
+
5
+ export type PlayScrollSync = {
6
+ navigate(
7
+ target: HTMLElement,
8
+ block: HTMLElement,
9
+ mode: PlayMode,
10
+ behavior?: PlayScrollBehavior,
11
+ ): void
12
+ preserve(target: HTMLElement, viewportTop: number): void
13
+ setEnabled(enabled: boolean): void
14
+ }
15
+
16
+ const SNAP_END_SELECTOR = ":scope > [data-pmx-play-snap-end]"
17
+
18
+ /** Keep bottom-edge snap targets limited to document-scrolling tall Blocks. */
19
+ export function syncPresentationSnapTargets(
20
+ playDocument: HTMLElement,
21
+ enabled: boolean,
22
+ ) {
23
+ const blocks = playDocument.querySelectorAll<HTMLElement>(
24
+ ":scope > section[data-pmx-block]",
25
+ )
26
+ for (const block of blocks) {
27
+ const existing = block.querySelector<HTMLElement>(SNAP_END_SELECTOR)
28
+ const needsEndTarget =
29
+ enabled && block.getBoundingClientRect().height > innerHeight + 1
30
+ if (!needsEndTarget) {
31
+ existing?.remove()
32
+ continue
33
+ }
34
+ if (existing) continue
35
+ const marker = document.createElement("span")
36
+ marker.dataset.pmxPlaySnapEnd = ""
37
+ marker.setAttribute("aria-hidden", "true")
38
+ block.append(marker)
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Keep native scrolling authoritative. User scrolling suspends the spotlight;
44
+ * once it settles, the controller adopts the visible Beat without scrolling
45
+ * back. Programmatic Play navigation never enters that adoption path.
46
+ */
47
+ export function createPlayScrollSync(callbacks: {
48
+ start(): void
49
+ settle(): void
50
+ track(tracking: boolean): void
51
+ }): PlayScrollSync {
52
+ let enabled = false
53
+ let programmatic = false
54
+ let scrolling = false
55
+ let tracking = false
56
+ let settleTimer: number | undefined
57
+ let programTimer: number | undefined
58
+ let snapReleaseFrame: number | undefined
59
+ let userIntentUntil = 0
60
+
61
+ const markUserIntent = () => {
62
+ userIntentUntil = performance.now() + 700
63
+ }
64
+
65
+ const clearTimer = (timer: number | undefined) => {
66
+ if (timer !== undefined) window.clearTimeout(timer)
67
+ }
68
+
69
+ const releasePresentationSnap = () => {
70
+ if (snapReleaseFrame !== undefined) {
71
+ cancelAnimationFrame(snapReleaseFrame)
72
+ snapReleaseFrame = undefined
73
+ }
74
+ document.documentElement.removeAttribute(
75
+ "data-pmx-play-programmatic-scroll",
76
+ )
77
+ }
78
+
79
+ const suspendPresentationSnap = () => {
80
+ releasePresentationSnap()
81
+ document.documentElement.setAttribute(
82
+ "data-pmx-play-programmatic-scroll",
83
+ "",
84
+ )
85
+ snapReleaseFrame = requestAnimationFrame(() => {
86
+ snapReleaseFrame = requestAnimationFrame(releasePresentationSnap)
87
+ })
88
+ }
89
+
90
+ const setTracking = (value: boolean) => {
91
+ if (tracking === value) return
92
+ tracking = value
93
+ callbacks.track(value)
94
+ }
95
+
96
+ const settle = () => {
97
+ clearTimer(settleTimer)
98
+ settleTimer = undefined
99
+ if (programmatic) return
100
+ if (!scrolling) return
101
+ scrolling = false
102
+ userIntentUntil = 0
103
+ callbacks.settle()
104
+ }
105
+
106
+ const onScroll = (event: Event) => {
107
+ if (event.target !== document) return
108
+ if (!enabled) return
109
+ if (programmatic) {
110
+ clearTimer(programTimer)
111
+ programTimer = window.setTimeout(() => {
112
+ programmatic = false
113
+ setTracking(false)
114
+ }, 700)
115
+ return
116
+ }
117
+ if (!scrolling) {
118
+ if (performance.now() > userIntentUntil) return
119
+ scrolling = true
120
+ callbacks.start()
121
+ }
122
+ clearTimer(settleTimer)
123
+ settleTimer = window.setTimeout(settle, 160)
124
+ }
125
+
126
+ const onUserIntent = () => {
127
+ if (!enabled) return
128
+ releasePresentationSnap()
129
+ markUserIntent()
130
+ programmatic = false
131
+ setTracking(false)
132
+ }
133
+
134
+ const onKeyboardIntent = (event: KeyboardEvent) => {
135
+ if (
136
+ event.altKey ||
137
+ event.metaKey ||
138
+ event.ctrlKey ||
139
+ ![
140
+ "ArrowDown",
141
+ "ArrowUp",
142
+ "PageDown",
143
+ "PageUp",
144
+ "Home",
145
+ "End",
146
+ " ",
147
+ ].includes(event.key)
148
+ )
149
+ return
150
+ queueMicrotask(() => {
151
+ if (!event.defaultPrevented) onUserIntent()
152
+ })
153
+ }
154
+
155
+ const onScrollEnd = (event: Event) => {
156
+ if (event.target !== document) return
157
+ if (programmatic) {
158
+ clearTimer(programTimer)
159
+ programmatic = false
160
+ setTracking(false)
161
+ return
162
+ }
163
+ settle()
164
+ }
165
+
166
+ const focusOffset = (target: HTMLElement) => {
167
+ const rect = target.getBoundingClientRect()
168
+ const safeTop = innerHeight * 0.16
169
+ const safeBottom = innerHeight * 0.84
170
+ const fullyComfortable = rect.top >= safeTop && rect.bottom <= safeBottom
171
+ if (fullyComfortable) return 0
172
+ const playhead =
173
+ innerHeight * (rect.height > innerHeight * 0.65 ? 0.1 : 0.42)
174
+ return rect.top - playhead
175
+ }
176
+
177
+ const presentationScrollTarget = (
178
+ target: HTMLElement,
179
+ block: HTMLElement,
180
+ ) => {
181
+ const targetRect = target.getBoundingClientRect()
182
+ const blockRect = block.getBoundingClientRect()
183
+ const inset = Math.min(32, innerHeight * 0.08)
184
+ const visibleTop = Math.max(0, blockRect.top) + inset
185
+ const visibleBottom = Math.min(innerHeight, blockRect.bottom) - inset
186
+ if (targetRect.top >= visibleTop && targetRect.bottom <= visibleBottom) {
187
+ return
188
+ }
189
+
190
+ const localTop = targetRect.top - blockRect.top + block.scrollTop
191
+ const blockStartViewport = Math.min(block.clientHeight, innerHeight)
192
+ const fitsAtBlockStart =
193
+ localTop >= inset &&
194
+ localTop + targetRect.height <= blockStartViewport - inset
195
+ return fitsAtBlockStart ? block : target
196
+ }
197
+
198
+ document.addEventListener("scroll", onScroll, {
199
+ capture: true,
200
+ passive: true,
201
+ })
202
+ document.addEventListener("scrollend", onScrollEnd, { capture: true })
203
+ window.addEventListener("wheel", onUserIntent, { passive: true })
204
+ window.addEventListener("touchstart", onUserIntent, { passive: true })
205
+ window.addEventListener("pointerdown", onUserIntent, { passive: true })
206
+ document.addEventListener("keydown", onKeyboardIntent, { capture: true })
207
+
208
+ return {
209
+ navigate(target, block, mode, requestedBehavior = "smooth") {
210
+ programmatic = true
211
+ setTracking(true)
212
+ scrolling = false
213
+ userIntentUntil = 0
214
+ clearTimer(settleTimer)
215
+ clearTimer(programTimer)
216
+ const behavior =
217
+ requestedBehavior === "instant" ||
218
+ matchMedia("(prefers-reduced-motion: reduce)").matches
219
+ ? "instant"
220
+ : "smooth"
221
+ if (mode === "presentation") {
222
+ const destination = presentationScrollTarget(target, block)
223
+ if (!destination) {
224
+ programmatic = false
225
+ setTracking(false)
226
+ return
227
+ }
228
+ if (behavior === "instant") suspendPresentationSnap()
229
+ destination.scrollIntoView({
230
+ behavior,
231
+ block: destination === block ? "start" : "nearest",
232
+ inline: "nearest",
233
+ })
234
+ } else {
235
+ const top = focusOffset(target)
236
+ if (Math.abs(top) < 0.5) {
237
+ programmatic = false
238
+ setTracking(false)
239
+ return
240
+ }
241
+ window.scrollBy({ top, behavior })
242
+ }
243
+ programTimer = window.setTimeout(() => {
244
+ programmatic = false
245
+ setTracking(false)
246
+ }, 700)
247
+ },
248
+ preserve(target, viewportTop) {
249
+ programmatic = true
250
+ setTracking(true)
251
+ scrolling = false
252
+ userIntentUntil = 0
253
+ clearTimer(settleTimer)
254
+ clearTimer(programTimer)
255
+ const shift = target.getBoundingClientRect().top - viewportTop
256
+ if (Math.abs(shift) > 0.5) {
257
+ window.scrollBy({ top: shift, behavior: "instant" })
258
+ }
259
+ programTimer = window.setTimeout(() => {
260
+ programmatic = false
261
+ setTracking(false)
262
+ }, 100)
263
+ },
264
+ setEnabled(value) {
265
+ enabled = value
266
+ if (value) return
267
+ programmatic = false
268
+ setTracking(false)
269
+ scrolling = false
270
+ userIntentUntil = 0
271
+ clearTimer(settleTimer)
272
+ clearTimer(programTimer)
273
+ releasePresentationSnap()
274
+ },
275
+ }
276
+ }
package/themes.ts ADDED
@@ -0,0 +1,39 @@
1
+ import type { SourceDescription } from "@pathmx/core"
2
+
3
+ export type PlayThemeId = "native" | "editorial" | "signal" | "code"
4
+
5
+ export type PlayTheme = Readonly<{
6
+ id: PlayThemeId
7
+ label: string
8
+ description: string
9
+ }>
10
+
11
+ export const PLAY_THEMES: readonly PlayTheme[] = [
12
+ { id: "native", label: "Native", description: "Neutral PathMX baseline" },
13
+ {
14
+ id: "editorial",
15
+ label: "Editorial",
16
+ description: "Warm serif-led reading",
17
+ },
18
+ {
19
+ id: "signal",
20
+ label: "Signal",
21
+ description: "Bold, high-contrast talks",
22
+ },
23
+ {
24
+ id: "code",
25
+ label: "Code",
26
+ description: "Dark technical teaching",
27
+ },
28
+ ]
29
+
30
+ export function isPlayTheme(value: unknown): value is PlayThemeId {
31
+ return PLAY_THEMES.some((theme) => theme.id === value)
32
+ }
33
+
34
+ export function sourcePlayTheme(description: SourceDescription): PlayThemeId {
35
+ const play = description.source.data.play
36
+ if (!play || typeof play !== "object" || Array.isArray(play)) return "native"
37
+ const theme = (play as Readonly<Record<string, unknown>>).theme
38
+ return isPlayTheme(theme) ? theme : "native"
39
+ }