@miliastry/quasar 1.1.0 → 1.2.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/README.md +1 -1
- package/dist/Visuals/lyne.css +257 -3
- package/dist/Visuals/osu.css +357 -6
- package/dist/index.js +850 -98
- package/dist/index.mjs +846 -99
- package/dist/types/BBCode/BBCodeDocumentModel.d.ts +11 -1
- package/dist/types/Visitors/DOMMorpher.d.ts +6 -0
- package/dist/types/Visitors/HTMLRenderer.d.ts +120 -8
- package/dist/types/Visitors/index.d.ts +1 -1
- package/dist/types/Visuals/BoxDrawer.d.ts +10 -5
- package/dist/types/Visuals/LyneAudio.d.ts +8 -0
- package/dist/types/Visuals/index.d.ts +1 -0
- package/dist/types/index.d.ts +4 -3
- package/package.json +1 -1
- package/src/BBCode/BBCodeDocumentModel.ts +36 -1
- package/src/HTML/HTMLToGreenNode.ts +8 -0
- package/src/Semantic/SemanticAnalyzer.ts +43 -0
- package/src/Visitors/BBCodeExporter.ts +51 -5
- package/src/Visitors/BlockPatcher.ts +36 -11
- package/src/Visitors/DOMMorpher.ts +44 -38
- package/src/Visitors/HTMLRenderer.ts +402 -59
- package/src/Visitors/index.ts +1 -1
- package/src/Visuals/BoxDrawer.ts +56 -10
- package/src/Visuals/LyneAudio.ts +346 -0
- package/src/Visuals/index.ts +1 -0
- package/src/Visuals/lyne.css +257 -3
- package/src/Visuals/osu.css +357 -6
- package/src/index.ts +6 -3
package/src/Visuals/BoxDrawer.ts
CHANGED
|
@@ -34,6 +34,9 @@ export interface BoxDrawerOptions {
|
|
|
34
34
|
const DEFAULT_DURATION_MS = 400
|
|
35
35
|
/** Floor so an interruption near the end still reads as motion, not a snap. */
|
|
36
36
|
const MIN_DURATION_MS = 120
|
|
37
|
+
/** Clase de estado abierto del spoilerbox de osu, puesta en el contenedor. */
|
|
38
|
+
const OSU_OPEN_CLASS = 'js-spoilerbox--open'
|
|
39
|
+
|
|
37
40
|
/** easeOutCubic — fast to start, settles softly, no overshoot. */
|
|
38
41
|
const DEFAULT_EASING = 'cubic-bezier(0.33, 1, 0.68, 1)'
|
|
39
42
|
|
|
@@ -50,7 +53,7 @@ interface DrawerState {
|
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
/** In-flight state per element; a WeakMap so morphed-away nodes are freed. */
|
|
53
|
-
const running = new WeakMap<
|
|
56
|
+
const running = new WeakMap<Element, DrawerState>()
|
|
54
57
|
|
|
55
58
|
function prefersReducedMotion(): boolean {
|
|
56
59
|
return typeof window !== 'undefined'
|
|
@@ -65,12 +68,14 @@ function prefersReducedMotion(): boolean {
|
|
|
65
68
|
* Exported so hosts that toggle boxes programmatically (a keyboard command, an
|
|
66
69
|
* "expand all" action) go through the same animation as a click.
|
|
67
70
|
*/
|
|
68
|
-
|
|
69
|
-
details:
|
|
71
|
+
function animateDrawer(
|
|
72
|
+
details: HTMLElement,
|
|
73
|
+
isOpen: () => boolean,
|
|
74
|
+
setOpen: (v: boolean) => void,
|
|
70
75
|
options: BoxDrawerOptions = {}
|
|
71
76
|
): void {
|
|
72
77
|
if (typeof details.animate !== 'function' || prefersReducedMotion()) {
|
|
73
|
-
|
|
78
|
+
setOpen(!isOpen())
|
|
74
79
|
return
|
|
75
80
|
}
|
|
76
81
|
|
|
@@ -85,7 +90,7 @@ export function toggleBoxWithDrawer(
|
|
|
85
90
|
// `details.open`: a closing box is kept open so its content stays visible, so
|
|
86
91
|
// reading the attribute would report "open" and close it a second time,
|
|
87
92
|
// making a mid-close click impossible to reverse.
|
|
88
|
-
const opening = previous ? !previous.opening : !
|
|
93
|
+
const opening = previous ? !previous.opening : !isOpen()
|
|
89
94
|
|
|
90
95
|
previous?.animation.cancel()
|
|
91
96
|
|
|
@@ -94,14 +99,14 @@ export function toggleBoxWithDrawer(
|
|
|
94
99
|
// while the box grows or shrinks over it.
|
|
95
100
|
let collapsed: number
|
|
96
101
|
let expanded: number
|
|
97
|
-
if (
|
|
102
|
+
if (isOpen()) {
|
|
98
103
|
expanded = details.getBoundingClientRect().height
|
|
99
|
-
|
|
104
|
+
setOpen(false)
|
|
100
105
|
collapsed = details.getBoundingClientRect().height
|
|
101
|
-
|
|
106
|
+
setOpen(true)
|
|
102
107
|
} else {
|
|
103
108
|
collapsed = details.getBoundingClientRect().height
|
|
104
|
-
|
|
109
|
+
setOpen(true)
|
|
105
110
|
expanded = details.getBoundingClientRect().height
|
|
106
111
|
}
|
|
107
112
|
|
|
@@ -133,7 +138,7 @@ export function toggleBoxWithDrawer(
|
|
|
133
138
|
const settle = (finished: boolean) => {
|
|
134
139
|
if (running.get(details)?.animation !== animation) return
|
|
135
140
|
running.delete(details)
|
|
136
|
-
if (finished)
|
|
141
|
+
if (finished) setOpen(opening)
|
|
137
142
|
details.style.overflow = restoreOverflow
|
|
138
143
|
}
|
|
139
144
|
|
|
@@ -141,6 +146,36 @@ export function toggleBoxWithDrawer(
|
|
|
141
146
|
animation.addEventListener('cancel', () => settle(false))
|
|
142
147
|
}
|
|
143
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Animate one `<details>` to its opposite state. Exported so hosts that toggle
|
|
151
|
+
* boxes programmatically go through the same animation as a click.
|
|
152
|
+
*/
|
|
153
|
+
export function toggleBoxWithDrawer(
|
|
154
|
+
details: HTMLDetailsElement,
|
|
155
|
+
options: BoxDrawerOptions = {}
|
|
156
|
+
): void {
|
|
157
|
+
animateDrawer(details, () => details.open, (v) => { details.open = v }, options)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Lo mismo para la caja del dialecto osu, que no es un `<details>`: es
|
|
162
|
+
* `div.js-spoilerbox > a.js-spoilerbox__link + div.js-spoilerbox__body`, y su
|
|
163
|
+
* estado abierto es la clase `js-spoilerbox--open` en el contenedor. Marcado
|
|
164
|
+
* distinto, misma animación — y la misma razón para vivir aquí: Quasar es
|
|
165
|
+
* quien emite el marcado, así que también define cómo se abre.
|
|
166
|
+
*/
|
|
167
|
+
export function toggleSpoilerboxWithDrawer(
|
|
168
|
+
box: HTMLElement,
|
|
169
|
+
options: BoxDrawerOptions = {}
|
|
170
|
+
): void {
|
|
171
|
+
animateDrawer(
|
|
172
|
+
box,
|
|
173
|
+
() => box.classList.contains(OSU_OPEN_CLASS),
|
|
174
|
+
(v) => box.classList.toggle(OSU_OPEN_CLASS, v),
|
|
175
|
+
options
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
144
179
|
/**
|
|
145
180
|
* Intercept the native toggle of every box inside `root`.
|
|
146
181
|
*
|
|
@@ -157,6 +192,17 @@ export function bindBoxDrawer(
|
|
|
157
192
|
): () => void {
|
|
158
193
|
const handleClick = (e: MouseEvent) => {
|
|
159
194
|
const target = e.target as HTMLElement | null
|
|
195
|
+
|
|
196
|
+
// Dialecto osu: el marcado es div > a + div, sin <details> que interceptar.
|
|
197
|
+
const osuLink = target?.closest('.js-spoilerbox__link')
|
|
198
|
+
if (osuLink) {
|
|
199
|
+
const box = osuLink.closest('.js-spoilerbox')
|
|
200
|
+
if (!(box instanceof HTMLElement) || !root.contains(box)) return
|
|
201
|
+
e.preventDefault() // el <a href="#"> saltaria al principio del documento
|
|
202
|
+
toggleSpoilerboxWithDrawer(box, options)
|
|
203
|
+
return
|
|
204
|
+
}
|
|
205
|
+
|
|
160
206
|
const summary = target?.closest('summary')
|
|
161
207
|
if (!summary) return
|
|
162
208
|
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quasar Document Engine — Visuals / LyneAudio.ts
|
|
3
|
+
*
|
|
4
|
+
* Interactive runtime for Quasar's Lyne / SYNE custom audio player (.lx-audio).
|
|
5
|
+
* Supports both scoped root binding (bindLyneAudio) and global document delegation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function bindLyneAudio(
|
|
9
|
+
root: HTMLElement = (typeof document !== 'undefined' ? document.documentElement : (undefined as any))
|
|
10
|
+
): () => void {
|
|
11
|
+
if (!root || typeof root.addEventListener !== 'function') {
|
|
12
|
+
return () => {}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const formatTime = (seconds: number): string => {
|
|
16
|
+
if (!isFinite(seconds) || isNaN(seconds) || seconds < 0) return '–:––'
|
|
17
|
+
const m = Math.floor(seconds / 60)
|
|
18
|
+
const s = Math.floor(seconds % 60)
|
|
19
|
+
return `${m}:${s < 10 ? '0' : ''}${s}`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const PLAY_SVG = '<svg viewBox="0 0 16 16"><path d="M3 1.5 14 8 3 14.5z"/></svg>'
|
|
23
|
+
const PAUSE_SVG = '<svg viewBox="0 0 16 16"><path d="M3 2h3.6v12H3zM9.4 2H13v12H9.4z"/></svg>'
|
|
24
|
+
const RETRY_SVG = '<svg viewBox="0 0 16 16"><path d="M8 2a6 6 0 1 0 6 6h-2a4 4 0 1 1-4-4v3l5-4-5-4z"/></svg>'
|
|
25
|
+
const VOL_LOW_SVG = '<svg viewBox="0 0 16 16"><path d="M10.707 11.182A4.5 4.5 0 0 0 12.025 8a4.5 4.5 0 0 0-1.318-3.182L10 5.525A3.5 3.5 0 0 1 11.025 8 3.5 3.5 0 0 1 10 10.475zM6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06"/></svg>'
|
|
26
|
+
const VOL_HIGH_SVG = '<svg viewBox="0 0 16 16"><path d="M11.536 14.01A8.47 8.47 0 0 0 14.026 8a8.47 8.47 0 0 0-2.49-6.01l-.708.707A7.48 7.48 0 0 1 13.025 8c0 2.071-.84 3.946-2.197 5.303z"/><path d="M10.121 12.596A6.48 6.48 0 0 0 12.025 8a6.48 6.48 0 0 0-1.904-4.596l-.707.707A5.48 5.48 0 0 1 11.025 8a5.48 5.48 0 0 1-1.61 3.89z"/><path d="M8.707 11.182A4.5 4.5 0 0 0 10.025 8a4.5 4.5 0 0 0-1.318-3.182L8 5.525A3.5 3.5 0 0 1 9.025 8 3.5 3.5 0 0 1 8 10.475zM6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06"/></svg>'
|
|
27
|
+
const VOL_MUTE_SVG = '<svg viewBox="0 0 16 16"><path d="M6.717 3.55A.5.5 0 0 1 7 4v8a.5.5 0 0 1-.812.39L3.825 10.5H1.5A.5.5 0 0 1 1 10V6a.5.5 0 0 1 .5-.5h2.325l2.363-1.89a.5.5 0 0 1 .529-.06m7.137 2.096a.5.5 0 0 1 0 .708L12.207 8l1.647 1.646a.5.5 0 0 1-.708.708L11.5 8.707l-1.646 1.647a.5.5 0 0 1-.708-.708L10.793 8 9.146 6.354a.5.5 0 1 1 .708-.708L11.5 7.293l1.646-1.647a.5.5 0 0 1 .708 0"/></svg>'
|
|
28
|
+
|
|
29
|
+
const DEFAULT_VOLUME = 0.2
|
|
30
|
+
|
|
31
|
+
const applyDefaultVolume = (audio: HTMLAudioElement) => {
|
|
32
|
+
if (audio && audio.dataset.volSet !== 'true') {
|
|
33
|
+
audio.volume = DEFAULT_VOLUME
|
|
34
|
+
audio.dataset.volSet = 'true'
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const updateVolUI = (player: HTMLElement, vol: number) => {
|
|
39
|
+
const btn = player.querySelector<HTMLButtonElement>('.lx-vol-btn')
|
|
40
|
+
const slider = player.querySelector<HTMLInputElement>('.lx-vol-slider')
|
|
41
|
+
if (slider) {
|
|
42
|
+
if (Math.abs(parseFloat(slider.value) - vol) > 0.005) {
|
|
43
|
+
slider.value = String(vol)
|
|
44
|
+
}
|
|
45
|
+
const pct = (vol * 100).toFixed(1)
|
|
46
|
+
slider.style.background = `linear-gradient(to right, var(--color-accent, #2EE6E2) ${pct}%, var(--color-inset-well, #080D20) ${pct}%)`
|
|
47
|
+
}
|
|
48
|
+
if (btn) {
|
|
49
|
+
if (vol <= 0.001) {
|
|
50
|
+
btn.innerHTML = VOL_MUTE_SVG
|
|
51
|
+
btn.setAttribute('aria-label', 'Unmute')
|
|
52
|
+
} else if (vol > 0.5) {
|
|
53
|
+
btn.innerHTML = VOL_HIGH_SVG
|
|
54
|
+
btn.setAttribute('aria-label', 'Mute')
|
|
55
|
+
} else {
|
|
56
|
+
btn.innerHTML = VOL_LOW_SVG
|
|
57
|
+
btn.setAttribute('aria-label', 'Mute')
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const onClick = (e: MouseEvent) => {
|
|
63
|
+
const target = e.target as HTMLElement | null
|
|
64
|
+
if (!target) return
|
|
65
|
+
|
|
66
|
+
// 1. Play / Pause button
|
|
67
|
+
const btn = target.closest<HTMLButtonElement>('.lx-btn')
|
|
68
|
+
if (btn) {
|
|
69
|
+
const player = btn.closest<HTMLElement>('.lx-audio')
|
|
70
|
+
const audio = player?.querySelector<HTMLAudioElement>('audio')
|
|
71
|
+
if (audio && player) {
|
|
72
|
+
e.preventDefault()
|
|
73
|
+
e.stopPropagation()
|
|
74
|
+
|
|
75
|
+
if (player.classList.contains('is-error')) {
|
|
76
|
+
player.classList.remove('is-error')
|
|
77
|
+
player.classList.add('is-loading')
|
|
78
|
+
audio.load()
|
|
79
|
+
const p = audio.play()
|
|
80
|
+
if (p !== undefined) {
|
|
81
|
+
p.catch((err) => {
|
|
82
|
+
console.warn('[LyneAudio] retry playback failed:', err)
|
|
83
|
+
player.classList.remove('is-loading')
|
|
84
|
+
player.classList.add('is-error')
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
} else if (audio.paused) {
|
|
88
|
+
// Pause any other playing audio on the page
|
|
89
|
+
const doc = root.ownerDocument || document
|
|
90
|
+
doc.querySelectorAll<HTMLElement>('.lx-audio.is-playing').forEach((other) => {
|
|
91
|
+
if (other !== player) {
|
|
92
|
+
const otherAudio = other.querySelector<HTMLAudioElement>('audio')
|
|
93
|
+
if (otherAudio && !otherAudio.paused) {
|
|
94
|
+
otherAudio.pause()
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
player.classList.add('is-loading')
|
|
100
|
+
const p = audio.play()
|
|
101
|
+
if (p !== undefined) {
|
|
102
|
+
p.then(() => {
|
|
103
|
+
player.classList.remove('is-loading')
|
|
104
|
+
}).catch((err) => {
|
|
105
|
+
console.warn('[LyneAudio] play failed:', err)
|
|
106
|
+
player.classList.remove('is-loading')
|
|
107
|
+
player.classList.add('is-error')
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
audio.pause()
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 2. Track seeking
|
|
118
|
+
const track = target.closest<HTMLElement>('.lx-track')
|
|
119
|
+
if (track) {
|
|
120
|
+
const player = track.closest<HTMLElement>('.lx-audio')
|
|
121
|
+
const audio = player?.querySelector<HTMLAudioElement>('audio')
|
|
122
|
+
if (audio) {
|
|
123
|
+
e.preventDefault()
|
|
124
|
+
e.stopPropagation()
|
|
125
|
+
const rect = track.getBoundingClientRect()
|
|
126
|
+
const pct = Math.min(Math.max((e.clientX - rect.left) / rect.width, 0), 1)
|
|
127
|
+
if (audio.duration && isFinite(audio.duration)) {
|
|
128
|
+
audio.currentTime = pct * audio.duration
|
|
129
|
+
}
|
|
130
|
+
const fill = track.querySelector<HTMLElement>('.fill')
|
|
131
|
+
if (fill) fill.style.width = `${pct * 100}%`
|
|
132
|
+
}
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 3. Playback speed cycling
|
|
137
|
+
const speed = target.closest<HTMLButtonElement>('.lx-speed')
|
|
138
|
+
if (speed) {
|
|
139
|
+
const player = speed.closest<HTMLElement>('.lx-audio')
|
|
140
|
+
const audio = player?.querySelector<HTMLAudioElement>('audio')
|
|
141
|
+
if (audio) {
|
|
142
|
+
e.preventDefault()
|
|
143
|
+
e.stopPropagation()
|
|
144
|
+
const rates = [1.0, 1.25, 1.5, 0.75]
|
|
145
|
+
const curRate = audio.playbackRate || 1.0
|
|
146
|
+
const curIdx = rates.indexOf(curRate)
|
|
147
|
+
const nextRate = rates[(curIdx + 1) % rates.length]
|
|
148
|
+
audio.playbackRate = nextRate
|
|
149
|
+
speed.textContent = `${nextRate}×`
|
|
150
|
+
speed.classList.toggle('active', nextRate !== 1.0)
|
|
151
|
+
}
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 4. Volume mute toggle button
|
|
156
|
+
const volBtn = target.closest<HTMLButtonElement>('.lx-vol-btn')
|
|
157
|
+
if (volBtn) {
|
|
158
|
+
const player = volBtn.closest<HTMLElement>('.lx-audio')
|
|
159
|
+
const audio = player?.querySelector<HTMLAudioElement>('audio')
|
|
160
|
+
if (audio && player) {
|
|
161
|
+
e.preventDefault()
|
|
162
|
+
e.stopPropagation()
|
|
163
|
+
audio.dataset.volSet = 'true'
|
|
164
|
+
if (audio.muted || audio.volume <= 0.001) {
|
|
165
|
+
const prev = parseFloat(audio.dataset.prevVol || String(DEFAULT_VOLUME)) || DEFAULT_VOLUME
|
|
166
|
+
audio.muted = false
|
|
167
|
+
audio.volume = prev
|
|
168
|
+
updateVolUI(player, prev)
|
|
169
|
+
} else {
|
|
170
|
+
audio.dataset.prevVol = String(audio.volume)
|
|
171
|
+
audio.muted = true
|
|
172
|
+
updateVolUI(player, 0)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const onInput = (e: Event) => {
|
|
180
|
+
const target = e.target as HTMLElement | null
|
|
181
|
+
if (!target) return
|
|
182
|
+
const slider = target.closest<HTMLInputElement>('.lx-vol-slider')
|
|
183
|
+
if (slider) {
|
|
184
|
+
const player = slider.closest<HTMLElement>('.lx-audio')
|
|
185
|
+
const audio = player?.querySelector<HTMLAudioElement>('audio')
|
|
186
|
+
if (audio) {
|
|
187
|
+
const val = parseFloat(slider.value)
|
|
188
|
+
const clamped = isNaN(val) ? DEFAULT_VOLUME : Math.max(0, Math.min(1, val))
|
|
189
|
+
audio.dataset.volSet = 'true'
|
|
190
|
+
audio.muted = (clamped === 0)
|
|
191
|
+
audio.volume = clamped
|
|
192
|
+
if (player) {
|
|
193
|
+
updateVolUI(player, clamped)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Media events (capture phase)
|
|
200
|
+
const onPlay = (e: Event) => {
|
|
201
|
+
const audio = e.target as HTMLAudioElement
|
|
202
|
+
if (audio?.tagName !== 'AUDIO') return
|
|
203
|
+
applyDefaultVolume(audio)
|
|
204
|
+
const player = audio.closest<HTMLElement>('.lx-audio')
|
|
205
|
+
if (!player) return
|
|
206
|
+
player.classList.remove('is-loading')
|
|
207
|
+
player.classList.add('is-playing')
|
|
208
|
+
const btn = player.querySelector<HTMLButtonElement>('.lx-btn')
|
|
209
|
+
if (btn) {
|
|
210
|
+
btn.innerHTML = PAUSE_SVG
|
|
211
|
+
btn.setAttribute('aria-label', 'Pause')
|
|
212
|
+
}
|
|
213
|
+
const label = player.querySelector<HTMLElement>('.lx-title .label .status-text')
|
|
214
|
+
if (label) label.textContent = 'playing'
|
|
215
|
+
updateVolUI(player, audio.muted ? 0 : audio.volume)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const onPause = (e: Event) => {
|
|
219
|
+
const audio = e.target as HTMLAudioElement
|
|
220
|
+
if (audio?.tagName !== 'AUDIO') return
|
|
221
|
+
const player = audio.closest<HTMLElement>('.lx-audio')
|
|
222
|
+
if (!player) return
|
|
223
|
+
player.classList.remove('is-playing', 'is-loading')
|
|
224
|
+
const btn = player.querySelector<HTMLButtonElement>('.lx-btn')
|
|
225
|
+
if (btn) {
|
|
226
|
+
btn.innerHTML = PLAY_SVG
|
|
227
|
+
btn.setAttribute('aria-label', 'Play')
|
|
228
|
+
}
|
|
229
|
+
const label = player.querySelector<HTMLElement>('.lx-title .label .status-text')
|
|
230
|
+
if (label) label.textContent = 'audio'
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const onTimeUpdate = (e: Event) => {
|
|
234
|
+
const audio = e.target as HTMLAudioElement
|
|
235
|
+
if (audio?.tagName !== 'AUDIO') return
|
|
236
|
+
const player = audio.closest<HTMLElement>('.lx-audio')
|
|
237
|
+
if (!player) return
|
|
238
|
+
const cur = audio.currentTime || 0
|
|
239
|
+
const dur = audio.duration || 0
|
|
240
|
+
const fill = player.querySelector<HTMLElement>('.lx-track .fill')
|
|
241
|
+
const curTime = player.querySelector<HTMLElement>('.lx-time .cur')
|
|
242
|
+
if (fill && dur > 0 && isFinite(dur)) {
|
|
243
|
+
fill.style.width = `${(cur / dur) * 100}%`
|
|
244
|
+
}
|
|
245
|
+
if (curTime) {
|
|
246
|
+
curTime.textContent = formatTime(cur)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const onLoadedMetadata = (e: Event) => {
|
|
251
|
+
const audio = e.target as HTMLAudioElement
|
|
252
|
+
if (audio?.tagName !== 'AUDIO') return
|
|
253
|
+
applyDefaultVolume(audio)
|
|
254
|
+
const player = audio.closest<HTMLElement>('.lx-audio')
|
|
255
|
+
if (!player) return
|
|
256
|
+
const dur = audio.duration || 0
|
|
257
|
+
const totalTime = player.querySelector<HTMLElement>('.lx-time .total')
|
|
258
|
+
if (totalTime && dur > 0 && isFinite(dur)) {
|
|
259
|
+
totalTime.textContent = formatTime(dur)
|
|
260
|
+
}
|
|
261
|
+
updateVolUI(player, audio.muted ? 0 : audio.volume)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const onWaiting = (e: Event) => {
|
|
265
|
+
const audio = e.target as HTMLAudioElement
|
|
266
|
+
if (audio?.tagName !== 'AUDIO') return
|
|
267
|
+
const player = audio.closest<HTMLElement>('.lx-audio')
|
|
268
|
+
if (!player) return
|
|
269
|
+
player.classList.add('is-loading')
|
|
270
|
+
const label = player.querySelector<HTMLElement>('.lx-title .label .status-text')
|
|
271
|
+
if (label) label.textContent = 'buffering…'
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const onError = (e: Event) => {
|
|
275
|
+
const audio = e.target as HTMLAudioElement
|
|
276
|
+
if (audio?.tagName !== 'AUDIO') return
|
|
277
|
+
const player = audio.closest<HTMLElement>('.lx-audio')
|
|
278
|
+
if (!player) return
|
|
279
|
+
player.classList.remove('is-playing', 'is-loading')
|
|
280
|
+
player.classList.add('is-error')
|
|
281
|
+
const label = player.querySelector<HTMLElement>('.lx-title .label .status-text')
|
|
282
|
+
if (label) label.textContent = 'unavailable — file error'
|
|
283
|
+
const btn = player.querySelector<HTMLButtonElement>('.lx-btn')
|
|
284
|
+
if (btn) {
|
|
285
|
+
btn.innerHTML = RETRY_SVG
|
|
286
|
+
btn.setAttribute('aria-label', 'Retry')
|
|
287
|
+
btn.style.background = 'var(--color-danger, #FF3B5C)'
|
|
288
|
+
}
|
|
289
|
+
const curTime = player.querySelector<HTMLElement>('.lx-time .cur')
|
|
290
|
+
const totalTime = player.querySelector<HTMLElement>('.lx-time .total')
|
|
291
|
+
if (curTime) curTime.textContent = '–:––'
|
|
292
|
+
if (totalTime) totalTime.textContent = '–:––'
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const onVolumeChange = (e: Event) => {
|
|
296
|
+
const audio = e.target as HTMLAudioElement
|
|
297
|
+
if (audio?.tagName !== 'AUDIO') return
|
|
298
|
+
const player = audio.closest<HTMLElement>('.lx-audio')
|
|
299
|
+
if (!player) return
|
|
300
|
+
updateVolUI(player, audio.muted ? 0 : audio.volume)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Apply 20% default volume to all audio elements within root
|
|
304
|
+
try {
|
|
305
|
+
root.querySelectorAll<HTMLAudioElement>('audio').forEach((audio) => {
|
|
306
|
+
applyDefaultVolume(audio)
|
|
307
|
+
const player = audio.closest<HTMLElement>('.lx-audio')
|
|
308
|
+
if (player) {
|
|
309
|
+
updateVolUI(player, audio.muted ? 0 : audio.volume)
|
|
310
|
+
}
|
|
311
|
+
})
|
|
312
|
+
} catch {}
|
|
313
|
+
|
|
314
|
+
// Use capture phase so parent block selection cannot swallow clicks
|
|
315
|
+
root.addEventListener('click', onClick, true)
|
|
316
|
+
root.addEventListener('input', onInput, true)
|
|
317
|
+
root.addEventListener('change', onInput, true)
|
|
318
|
+
root.addEventListener('play', onPlay, true)
|
|
319
|
+
root.addEventListener('pause', onPause, true)
|
|
320
|
+
root.addEventListener('timeupdate', onTimeUpdate, true)
|
|
321
|
+
root.addEventListener('loadedmetadata', onLoadedMetadata, true)
|
|
322
|
+
root.addEventListener('durationchange', onLoadedMetadata, true)
|
|
323
|
+
root.addEventListener('waiting', onWaiting, true)
|
|
324
|
+
root.addEventListener('error', onError, true)
|
|
325
|
+
root.addEventListener('volumechange', onVolumeChange, true)
|
|
326
|
+
|
|
327
|
+
return () => {
|
|
328
|
+
root.removeEventListener('click', onClick, true)
|
|
329
|
+
root.removeEventListener('input', onInput, true)
|
|
330
|
+
root.removeEventListener('change', onInput, true)
|
|
331
|
+
root.removeEventListener('play', onPlay, true)
|
|
332
|
+
root.removeEventListener('pause', onPause, true)
|
|
333
|
+
root.removeEventListener('timeupdate', onTimeUpdate, true)
|
|
334
|
+
root.removeEventListener('loadedmetadata', onLoadedMetadata, true)
|
|
335
|
+
root.removeEventListener('durationchange', onLoadedMetadata, true)
|
|
336
|
+
root.removeEventListener('waiting', onWaiting, true)
|
|
337
|
+
root.removeEventListener('error', onError, true)
|
|
338
|
+
root.removeEventListener('volumechange', onVolumeChange, true)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export const setupLyneAudioRuntime = bindLyneAudio
|
|
343
|
+
|
|
344
|
+
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
|
345
|
+
bindLyneAudio(document.documentElement)
|
|
346
|
+
}
|
package/src/Visuals/index.ts
CHANGED