@crtobiasdelsud/portal-ui 1.0.2 → 1.0.3
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/package.json +1 -1
- package/src/components/Blocks/BlockColumns/BlockColumns.jsx +12 -0
- package/src/components/Blocks/BlockColumns/BlockColumns.module.scss +6 -1
- package/src/components/Hero/Hero.module.scss +7 -0
- package/src/components/Hero/HeroView.jsx +1 -0
- package/src/components/ShareBlock/ShareBlock.module.scss +25 -1
- package/src/components/SpeechPlayerBar/SpeechPlayerBar.jsx +9 -38
- package/src/components/SpeechPlayerBar/SpeechPlayerBar.module.scss +33 -122
- package/src/context/SpeechContext.jsx +98 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crtobiasdelsud/portal-ui",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Componentes compartidos entre el portal (Next) y el CMS (Vite) — widgets, views, providers para adapters y article pool.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -14,6 +14,11 @@ export default function BlockColumns({ widgets, registry, settings = {} }) {
|
|
|
14
14
|
? style.gridCategoriaDos
|
|
15
15
|
: hasImportant ? style.gridFeatured : style.gridNormal
|
|
16
16
|
|
|
17
|
+
// Hero "fantasma": cuando hay un hero importante se agrega un Hero extra,
|
|
18
|
+
// sin articleId (artículo random), solo para ocupar el espacio del centro
|
|
19
|
+
// de la fila de abajo.
|
|
20
|
+
const HeroWidget = registry["HERO_BLOCK"]
|
|
21
|
+
|
|
17
22
|
return (
|
|
18
23
|
<section className={style.container}>
|
|
19
24
|
<div className={`${style.grid} ${gridClass}`}>
|
|
@@ -31,6 +36,13 @@ export default function BlockColumns({ widgets, registry, settings = {} }) {
|
|
|
31
36
|
</div>
|
|
32
37
|
)
|
|
33
38
|
})}
|
|
39
|
+
{hasImportant && HeroWidget && (
|
|
40
|
+
<div className={style.item} style={{ gridArea: "fantasma" }}>
|
|
41
|
+
<WidgetErrorBoundary>
|
|
42
|
+
<HeroWidget settings={{}} />
|
|
43
|
+
</WidgetErrorBoundary>
|
|
44
|
+
</div>
|
|
45
|
+
)}
|
|
34
46
|
</div>
|
|
35
47
|
</section>
|
|
36
48
|
)
|
|
@@ -26,15 +26,20 @@
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/* Hero "importante": hero grande arriba a todo el ancho; abajo
|
|
30
|
+
widget1 + hero fantasma (automático) + widget2. */
|
|
29
31
|
.gridFeatured {
|
|
30
32
|
grid-template-areas:
|
|
31
33
|
"hero"
|
|
32
34
|
"recommended"
|
|
35
|
+
"fantasma"
|
|
33
36
|
"feed";
|
|
34
37
|
|
|
35
38
|
@media (min-width: 1000px) {
|
|
36
39
|
grid-template-columns: repeat(4, 1fr);
|
|
37
|
-
grid-template-areas:
|
|
40
|
+
grid-template-areas:
|
|
41
|
+
"hero hero hero hero"
|
|
42
|
+
"recommended fantasma fantasma feed";
|
|
38
43
|
}
|
|
39
44
|
}
|
|
40
45
|
|
|
@@ -121,6 +121,13 @@
|
|
|
121
121
|
aspect-ratio: 16 / 7;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/* La imagen del hero importante (AspectImage) a todo el ancho queda
|
|
125
|
+
altísima (~774px). La topamos para que sea un banner ancho y entre
|
|
126
|
+
en pantalla. En mobile la imagen es chica y este tope nunca se activa. */
|
|
127
|
+
.media {
|
|
128
|
+
max-height: 400px;
|
|
129
|
+
}
|
|
130
|
+
|
|
124
131
|
.headline {
|
|
125
132
|
font-size: 1.6rem;
|
|
126
133
|
|
|
@@ -35,11 +35,35 @@
|
|
|
35
35
|
.icons {
|
|
36
36
|
display: flex;
|
|
37
37
|
align-items: center;
|
|
38
|
-
gap:
|
|
38
|
+
gap: 6px;
|
|
39
39
|
|
|
40
40
|
@include respond(desktop) {
|
|
41
41
|
gap: 12px;
|
|
42
42
|
}
|
|
43
|
+
|
|
44
|
+
// Non-AMP: el componente Icon renderiza <a><span aria-hidden/></a>.
|
|
45
|
+
// Achicamos en mobile y restauramos el tamaño completo en desktop.
|
|
46
|
+
a {
|
|
47
|
+
width: 26px;
|
|
48
|
+
height: 26px;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
span[aria-hidden] {
|
|
52
|
+
width: 22px;
|
|
53
|
+
height: 22px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
@include respond(desktop) {
|
|
57
|
+
a {
|
|
58
|
+
width: 33px;
|
|
59
|
+
height: 33px;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
span[aria-hidden] {
|
|
63
|
+
width: 32px;
|
|
64
|
+
height: 32px;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
43
67
|
}
|
|
44
68
|
|
|
45
69
|
.iconLink {
|
|
@@ -7,7 +7,7 @@ import styles from './SpeechPlayerBar.module.scss'
|
|
|
7
7
|
|
|
8
8
|
export default function SpeechPlayerBar() {
|
|
9
9
|
const [mounted, setMounted] = useState(false)
|
|
10
|
-
const { playing, paused, progress, meta, pause, resume, stop,
|
|
10
|
+
const { playing, paused, progress, meta, pause, resume, stop, seekTo } = useSpeech()
|
|
11
11
|
|
|
12
12
|
useEffect(() => { setMounted(true) }, [])
|
|
13
13
|
|
|
@@ -22,36 +22,8 @@ export default function SpeechPlayerBar() {
|
|
|
22
22
|
return createPortal(
|
|
23
23
|
<div className={styles.bar} role="region" aria-label="Reproductor de nota">
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
{/* Thumbnail */}
|
|
28
|
-
<div className={styles.thumb}>
|
|
29
|
-
{meta.imagen
|
|
30
|
-
? <img src={meta.imagen} alt="" className={styles.thumbImg} />
|
|
31
|
-
: <span className={styles.thumbIcon}>🔊</span>
|
|
32
|
-
}
|
|
33
|
-
</div>
|
|
34
|
-
|
|
35
|
-
{/* Info */}
|
|
36
|
-
<div className={styles.info}>
|
|
37
|
-
<span className={styles.titulo}>{meta.titulo}</span>
|
|
38
|
-
<span className={styles.subtitle}>{playing ? 'Escuchando nota...' : 'En pausa'}</span>
|
|
39
|
-
</div>
|
|
40
|
-
|
|
41
|
-
{/* Equalizer */}
|
|
42
|
-
<div className={`${styles.eq} ${!playing ? styles.eqPaused : ''}`} aria-hidden="true">
|
|
43
|
-
<span className={styles.eqBar} />
|
|
44
|
-
<span className={styles.eqBar} />
|
|
45
|
-
<span className={styles.eqBar} />
|
|
46
|
-
<span className={styles.eqBar} />
|
|
47
|
-
</div>
|
|
48
|
-
|
|
49
|
-
{/* Skip back */}
|
|
50
|
-
<button className={styles.skipBtn} onClick={() => skip(-1)} aria-label="Retroceder">
|
|
51
|
-
<svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
|
|
52
|
-
<path d="M6 6h2v12H6zm3.5 6 8.5 6V6z"/>
|
|
53
|
-
</svg>
|
|
54
|
-
</button>
|
|
25
|
+
{/* Título */}
|
|
26
|
+
<span className={styles.titulo}>{meta.titulo}</span>
|
|
55
27
|
|
|
56
28
|
{/* Play / Pause */}
|
|
57
29
|
<button
|
|
@@ -60,16 +32,15 @@ export default function SpeechPlayerBar() {
|
|
|
60
32
|
aria-label={playing ? 'Pausar' : 'Continuar'}
|
|
61
33
|
>
|
|
62
34
|
{playing
|
|
63
|
-
? <svg viewBox="0 0 24 24" fill="currentColor" width="
|
|
64
|
-
: <svg viewBox="0 0 24 24" fill="currentColor" width="
|
|
35
|
+
? <svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
|
|
36
|
+
: <svg viewBox="0 0 24 24" fill="currentColor" width="16" height="16"><path d="M8 5v14l11-7z"/></svg>
|
|
65
37
|
}
|
|
66
38
|
</button>
|
|
67
39
|
|
|
68
|
-
{/*
|
|
69
|
-
<button className={styles.
|
|
70
|
-
<svg viewBox="0 0 24 24" fill="currentColor" width="
|
|
71
|
-
<path d="
|
|
72
|
-
<path d="M16 6h-2v12h2zm-3.5 6L4 6v12z"/>
|
|
40
|
+
{/* Cerrar */}
|
|
41
|
+
<button className={styles.closeBtn} onClick={stop} aria-label="Detener lectura">
|
|
42
|
+
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14">
|
|
43
|
+
<path d="M18.3 5.7 12 12l6.3 6.3-1.4 1.4L10.6 13.4 4.3 19.7 2.9 18.3 9.2 12 2.9 5.7 4.3 4.3l6.3 6.3 6.3-6.3z"/>
|
|
73
44
|
</svg>
|
|
74
45
|
</button>
|
|
75
46
|
|
|
@@ -1,174 +1,85 @@
|
|
|
1
1
|
@use "../../styles/index.scss" as *;
|
|
2
2
|
|
|
3
|
-
// ── Mini player bar
|
|
3
|
+
// ── Mini player bar (minimalista) ─────────────────────────────────────────────
|
|
4
4
|
|
|
5
5
|
.bar {
|
|
6
6
|
position: fixed;
|
|
7
7
|
bottom: 20px;
|
|
8
8
|
left: 50%;
|
|
9
9
|
transform: translateX(-50%);
|
|
10
|
-
width:
|
|
11
|
-
max-width:
|
|
10
|
+
width: max-content;
|
|
11
|
+
max-width: calc(100% - 32px);
|
|
12
12
|
background: var(--secondary-color, #0D1333);
|
|
13
|
-
border-radius:
|
|
14
|
-
padding: 10px
|
|
13
|
+
border-radius: 14px;
|
|
14
|
+
padding: 7px 10px 7px 16px;
|
|
15
15
|
display: flex;
|
|
16
16
|
align-items: center;
|
|
17
|
-
gap:
|
|
17
|
+
gap: 6px;
|
|
18
18
|
z-index: 9999;
|
|
19
|
-
box-shadow: 0
|
|
20
|
-
|
|
21
|
-
@include respond(desktop) {
|
|
22
|
-
padding: 0px;
|
|
23
|
-
margin: 0px;
|
|
24
|
-
}
|
|
19
|
+
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4);
|
|
25
20
|
}
|
|
26
21
|
|
|
27
|
-
// ──
|
|
28
|
-
|
|
29
|
-
.closeBtn {
|
|
30
|
-
position: absolute;
|
|
31
|
-
top: 6px;
|
|
32
|
-
right: 8px;
|
|
33
|
-
background: none;
|
|
34
|
-
border: none;
|
|
35
|
-
color: rgba(255, 255, 255, 0.4);
|
|
36
|
-
font-size: 0.7rem;
|
|
37
|
-
cursor: pointer;
|
|
38
|
-
padding: 2px 4px;
|
|
39
|
-
line-height: 1;
|
|
40
|
-
|
|
41
|
-
&:hover { color: rgba(255, 255, 255, 0.9); }
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// ── Thumbnail ─────────────────────────────────────────────────────────────────
|
|
45
|
-
|
|
46
|
-
.thumb {
|
|
47
|
-
width: 44px;
|
|
48
|
-
height: 44px;
|
|
49
|
-
border-radius: 8px;
|
|
50
|
-
background: rgba(255, 255, 255, 0.08);
|
|
51
|
-
flex-shrink: 0;
|
|
52
|
-
overflow: hidden;
|
|
53
|
-
display: flex;
|
|
54
|
-
align-items: center;
|
|
55
|
-
justify-content: center;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
.thumbImg {
|
|
59
|
-
width: 100%;
|
|
60
|
-
height: 100%;
|
|
61
|
-
object-fit: cover;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
.thumbIcon {
|
|
65
|
-
font-size: 1.3rem;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// ── Info ──────────────────────────────────────────────────────────────────────
|
|
69
|
-
|
|
70
|
-
.info {
|
|
71
|
-
flex: 1;
|
|
72
|
-
min-width: 0;
|
|
73
|
-
display: flex;
|
|
74
|
-
flex-direction: column;
|
|
75
|
-
gap: 2px;
|
|
76
|
-
}
|
|
22
|
+
// ── Título ────────────────────────────────────────────────────────────────────
|
|
77
23
|
|
|
78
24
|
.titulo {
|
|
79
25
|
color: #fff;
|
|
80
|
-
font-size: 0.
|
|
26
|
+
font-size: 0.8rem;
|
|
81
27
|
font-weight: 600;
|
|
82
28
|
white-space: nowrap;
|
|
83
29
|
overflow: hidden;
|
|
84
30
|
text-overflow: ellipsis;
|
|
31
|
+
max-width: 220px;
|
|
32
|
+
margin-right: 4px;
|
|
85
33
|
}
|
|
86
34
|
|
|
87
|
-
|
|
88
|
-
color: rgba(255, 255, 255, 0.5);
|
|
89
|
-
font-size: 0.72rem;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// ── Equalizer animation ───────────────────────────────────────────────────────
|
|
93
|
-
|
|
94
|
-
.eq {
|
|
95
|
-
display: flex;
|
|
96
|
-
gap: 2px;
|
|
97
|
-
align-items: flex-end;
|
|
98
|
-
height: 18px;
|
|
99
|
-
flex-shrink: 0;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
.eqBar {
|
|
103
|
-
width: 3px;
|
|
104
|
-
background: var(--primary-color, #B1043F);
|
|
105
|
-
border-radius: 2px;
|
|
106
|
-
transform-origin: bottom;
|
|
107
|
-
animation: eq 0.7s ease-in-out infinite alternate;
|
|
108
|
-
|
|
109
|
-
&:nth-child(1) { height: 35%; animation-delay: 0s; }
|
|
110
|
-
&:nth-child(2) { height: 75%; animation-delay: 0.15s; }
|
|
111
|
-
&:nth-child(3) { height: 55%; animation-delay: 0.3s; }
|
|
112
|
-
&:nth-child(4) { height: 90%; animation-delay: 0.1s; }
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
.eqPaused .eqBar {
|
|
116
|
-
animation-play-state: paused;
|
|
117
|
-
opacity: 0.4;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
@keyframes eq {
|
|
121
|
-
from { transform: scaleY(0.25); }
|
|
122
|
-
to { transform: scaleY(1); }
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// ── Skip buttons ──────────────────────────────────────────────────────────────
|
|
35
|
+
// ── Play / Pause button ───────────────────────────────────────────────────────
|
|
126
36
|
|
|
127
|
-
.
|
|
128
|
-
|
|
37
|
+
.playBtn {
|
|
38
|
+
width: 34px;
|
|
39
|
+
height: 34px;
|
|
40
|
+
border-radius: 50%;
|
|
41
|
+
background: #fff;
|
|
129
42
|
border: none;
|
|
130
|
-
color:
|
|
43
|
+
color: var(--secondary-color, #0D1333);
|
|
131
44
|
cursor: pointer;
|
|
132
|
-
padding: 4px;
|
|
133
45
|
display: flex;
|
|
134
46
|
align-items: center;
|
|
47
|
+
justify-content: center;
|
|
135
48
|
flex-shrink: 0;
|
|
49
|
+
transition: transform 0.12s;
|
|
136
50
|
|
|
137
|
-
&:hover {
|
|
51
|
+
&:hover { transform: scale(1.05); }
|
|
138
52
|
}
|
|
139
53
|
|
|
140
|
-
// ──
|
|
54
|
+
// ── Botón cerrar ──────────────────────────────────────────────────────────────
|
|
141
55
|
|
|
142
|
-
.
|
|
143
|
-
|
|
144
|
-
height: 38px;
|
|
145
|
-
border-radius: 10px;
|
|
146
|
-
background: rgba(255, 255, 255, 0.92);
|
|
56
|
+
.closeBtn {
|
|
57
|
+
background: none;
|
|
147
58
|
border: none;
|
|
148
|
-
color:
|
|
59
|
+
color: rgba(255, 255, 255, 0.45);
|
|
149
60
|
cursor: pointer;
|
|
61
|
+
padding: 4px;
|
|
150
62
|
display: flex;
|
|
151
63
|
align-items: center;
|
|
152
|
-
justify-content: center;
|
|
153
64
|
flex-shrink: 0;
|
|
154
|
-
transition:
|
|
65
|
+
transition: color 0.15s;
|
|
155
66
|
|
|
156
|
-
&:hover {
|
|
67
|
+
&:hover { color: rgba(255, 255, 255, 0.95); }
|
|
157
68
|
}
|
|
158
69
|
|
|
159
70
|
// ── Progress bar ──────────────────────────────────────────────────────────────
|
|
160
71
|
|
|
161
72
|
.progressTrack {
|
|
162
73
|
position: absolute;
|
|
163
|
-
bottom:
|
|
164
|
-
left:
|
|
165
|
-
right:
|
|
166
|
-
height:
|
|
74
|
+
bottom: 0;
|
|
75
|
+
left: 16px;
|
|
76
|
+
right: 16px;
|
|
77
|
+
height: 2px;
|
|
167
78
|
background: rgba(255, 255, 255, 0.15);
|
|
168
79
|
border-radius: 2px;
|
|
169
80
|
cursor: pointer;
|
|
170
81
|
|
|
171
|
-
&:hover { height:
|
|
82
|
+
&:hover { height: 4px; }
|
|
172
83
|
}
|
|
173
84
|
|
|
174
85
|
.progressFill {
|
|
@@ -4,6 +4,27 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
|
|
|
4
4
|
|
|
5
5
|
const SpeechCtx = createContext(null)
|
|
6
6
|
|
|
7
|
+
const STORAGE_KEY = 'portal-ui:speech'
|
|
8
|
+
|
|
9
|
+
// ── Persistencia en sessionStorage ────────────────────────────────────────────
|
|
10
|
+
// Permite que la barra sobreviva a un remount del provider (navegación SPA).
|
|
11
|
+
|
|
12
|
+
const readSession = () => {
|
|
13
|
+
try { return JSON.parse(sessionStorage.getItem(STORAGE_KEY) || 'null') }
|
|
14
|
+
catch { return null }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const updateSession = (patch) => {
|
|
18
|
+
try {
|
|
19
|
+
const cur = readSession() || {}
|
|
20
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ ...cur, ...patch }))
|
|
21
|
+
} catch { /* sessionStorage no disponible */ }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const clearSession = () => {
|
|
25
|
+
try { sessionStorage.removeItem(STORAGE_KEY) } catch { /* noop */ }
|
|
26
|
+
}
|
|
27
|
+
|
|
7
28
|
const strip = (html) => (html ?? '').replace(/<[^>]+>/g, '').trim()
|
|
8
29
|
|
|
9
30
|
function extractText(titulo, copete, cuerpo) {
|
|
@@ -51,11 +72,40 @@ export function SpeechProvider({ children }) {
|
|
|
51
72
|
const [progress, setProgress] = useState(0)
|
|
52
73
|
const [meta, setMeta] = useState({ titulo: '', imagen: null })
|
|
53
74
|
|
|
54
|
-
const utterRef
|
|
55
|
-
const textRef
|
|
56
|
-
const charRef
|
|
75
|
+
const utterRef = useRef(null)
|
|
76
|
+
const textRef = useRef('')
|
|
77
|
+
const charRef = useRef(0)
|
|
78
|
+
const lastSaveRef = useRef(0) // throttle de escritura a sessionStorage
|
|
79
|
+
const mountedRef = useRef(true) // false tras desmontar este provider
|
|
80
|
+
|
|
81
|
+
// ── Restaura el estado tras un remount del provider ─────────────────────────
|
|
82
|
+
// Antes este efecto cancelaba el audio en CADA desmontaje, lo que rompía la
|
|
83
|
+
// navegación SPA. Ahora solo cancela al cerrar/recargar la pestaña de verdad.
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
const saved = readSession()
|
|
86
|
+
if (saved?.text) {
|
|
87
|
+
textRef.current = saved.text
|
|
88
|
+
charRef.current = saved.charIndex || 0
|
|
89
|
+
setMeta({ titulo: saved.titulo || '', imagen: saved.imagen || null })
|
|
90
|
+
setProgress(saved.progress || 0)
|
|
91
|
+
// El audio del navegador no sobrevive a un remount de React: cancelamos
|
|
92
|
+
// la utterance huérfana y dejamos la barra en pausa para que el usuario
|
|
93
|
+
// retome desde la posición guardada (resume() re-arranca solo).
|
|
94
|
+
window.speechSynthesis?.cancel()
|
|
95
|
+
setPlaying(false)
|
|
96
|
+
setPaused(true)
|
|
97
|
+
}
|
|
57
98
|
|
|
58
|
-
|
|
99
|
+
const onUnload = () => {
|
|
100
|
+
window.speechSynthesis?.cancel()
|
|
101
|
+
clearSession()
|
|
102
|
+
}
|
|
103
|
+
window.addEventListener('beforeunload', onUnload)
|
|
104
|
+
return () => {
|
|
105
|
+
mountedRef.current = false
|
|
106
|
+
window.removeEventListener('beforeunload', onUnload)
|
|
107
|
+
}
|
|
108
|
+
}, [])
|
|
59
109
|
|
|
60
110
|
const startFrom = useCallback((charIndex) => {
|
|
61
111
|
window.speechSynthesis.cancel()
|
|
@@ -68,13 +118,34 @@ export function SpeechProvider({ children }) {
|
|
|
68
118
|
if (esVoice) utter.voice = esVoice
|
|
69
119
|
utter.rate = 1
|
|
70
120
|
|
|
121
|
+
// Ignora eventos de utterances viejas (tras un skip/seek) o de un provider
|
|
122
|
+
// ya desmontado. Sin esto, un cancel() deliberado disparaba onend → y onend
|
|
123
|
+
// borraba la sesión, cerrando la barra en la siguiente navegación.
|
|
124
|
+
const isStale = () => !mountedRef.current || utterRef.current !== utter
|
|
125
|
+
|
|
71
126
|
utter.onboundary = (e) => {
|
|
127
|
+
if (isStale()) return
|
|
72
128
|
const abs = charIndex + (e.charIndex ?? 0)
|
|
73
129
|
charRef.current = abs
|
|
74
|
-
|
|
130
|
+
const p = abs / textRef.current.length
|
|
131
|
+
setProgress(p)
|
|
132
|
+
// Persiste el avance, pero como máximo cada 1.5s para no saturar.
|
|
133
|
+
const now = Date.now()
|
|
134
|
+
if (now - lastSaveRef.current > 1500) {
|
|
135
|
+
lastSaveRef.current = now
|
|
136
|
+
updateSession({ charIndex: abs, progress: p })
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
utter.onend = () => {
|
|
140
|
+
if (isStale()) return // fue un cancel() deliberado, NO un fin de lectura
|
|
141
|
+
setPlaying(false); setPaused(false); setProgress(0)
|
|
142
|
+
charRef.current = 0
|
|
143
|
+
clearSession()
|
|
144
|
+
}
|
|
145
|
+
utter.onerror = () => {
|
|
146
|
+
if (isStale()) return
|
|
147
|
+
setPlaying(false); setPaused(false)
|
|
75
148
|
}
|
|
76
|
-
utter.onend = () => { setPlaying(false); setPaused(false); setProgress(0); charRef.current = 0 }
|
|
77
|
-
utter.onerror = () => { setPlaying(false); setPaused(false) }
|
|
78
149
|
|
|
79
150
|
utterRef.current = utter
|
|
80
151
|
setTimeout(() => window.speechSynthesis.speak(utter), 100)
|
|
@@ -84,12 +155,14 @@ export function SpeechProvider({ children }) {
|
|
|
84
155
|
|
|
85
156
|
const play = useCallback(({ titulo, copete, cuerpo, imagen }) => {
|
|
86
157
|
const text = extractText(titulo, copete, cuerpo)
|
|
87
|
-
console.log('[Speech] voz cargada:', window.speechSynthesis.getVoices().find(v => v.lang.startsWith('es'))?.name ?? 'default')
|
|
88
|
-
console.log('[Speech] texto completo:', text)
|
|
89
158
|
textRef.current = text
|
|
90
159
|
charRef.current = 0
|
|
91
160
|
setMeta({ titulo, imagen: imagen ?? null })
|
|
92
161
|
setProgress(0)
|
|
162
|
+
updateSession({
|
|
163
|
+
titulo, imagen: imagen ?? null,
|
|
164
|
+
text, charIndex: 0, progress: 0, status: 'playing',
|
|
165
|
+
})
|
|
93
166
|
startFrom(0)
|
|
94
167
|
}, [startFrom])
|
|
95
168
|
|
|
@@ -97,20 +170,30 @@ export function SpeechProvider({ children }) {
|
|
|
97
170
|
window.speechSynthesis.pause()
|
|
98
171
|
setPlaying(false)
|
|
99
172
|
setPaused(true)
|
|
173
|
+
updateSession({ status: 'paused', charIndex: charRef.current })
|
|
100
174
|
}, [])
|
|
101
175
|
|
|
102
176
|
const resume = useCallback(() => {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
177
|
+
// Si la utterance sigue viva en el navegador, simplemente la reanudamos.
|
|
178
|
+
// Si no (p. ej. tras un remount), re-arrancamos desde la posición guardada.
|
|
179
|
+
if (window.speechSynthesis.speaking && window.speechSynthesis.paused) {
|
|
180
|
+
window.speechSynthesis.resume()
|
|
181
|
+
setPlaying(true)
|
|
182
|
+
setPaused(false)
|
|
183
|
+
} else {
|
|
184
|
+
startFrom(charRef.current)
|
|
185
|
+
}
|
|
186
|
+
updateSession({ status: 'playing' })
|
|
187
|
+
}, [startFrom])
|
|
107
188
|
|
|
108
189
|
const stop = useCallback(() => {
|
|
190
|
+
utterRef.current = null // su onend quedará marcado como "stale" → no re-procesa
|
|
109
191
|
window.speechSynthesis.cancel()
|
|
110
192
|
setPlaying(false)
|
|
111
193
|
setPaused(false)
|
|
112
194
|
setProgress(0)
|
|
113
195
|
charRef.current = 0
|
|
196
|
+
clearSession()
|
|
114
197
|
}, [])
|
|
115
198
|
|
|
116
199
|
// Salta ±10% del texto total
|
|
@@ -118,6 +201,7 @@ export function SpeechProvider({ children }) {
|
|
|
118
201
|
const delta = Math.floor(textRef.current.length * 0.10) * direction
|
|
119
202
|
const newPos = Math.max(0, Math.min(charRef.current + delta, textRef.current.length - 1))
|
|
120
203
|
charRef.current = newPos
|
|
204
|
+
updateSession({ charIndex: newPos, status: 'playing' })
|
|
121
205
|
startFrom(newPos)
|
|
122
206
|
}, [startFrom])
|
|
123
207
|
|
|
@@ -125,6 +209,7 @@ export function SpeechProvider({ children }) {
|
|
|
125
209
|
const seekTo = useCallback((ratio) => {
|
|
126
210
|
const pos = Math.floor(ratio * textRef.current.length)
|
|
127
211
|
charRef.current = pos
|
|
212
|
+
updateSession({ charIndex: pos, status: 'playing' })
|
|
128
213
|
startFrom(pos)
|
|
129
214
|
}, [startFrom])
|
|
130
215
|
|