@scalemule/nextjs 0.1.39 → 0.1.41
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 +30 -0
- package/dist/audio.css +206 -0
- package/dist/audio.d.mts +33 -0
- package/dist/audio.d.ts +33 -0
- package/dist/audio.js +384 -0
- package/dist/audio.mjs +364 -0
- package/dist/client.d.mts +1 -1
- package/dist/client.d.ts +1 -1
- package/dist/client.js +33 -3
- package/dist/client.mjs +33 -3
- package/dist/{index-Tq5WdDfS.d.mts → index-Bv1m_qQ8.d.mts} +20 -0
- package/dist/{index-Tq5WdDfS.d.ts → index-Bv1m_qQ8.d.ts} +20 -0
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +33 -3
- package/dist/index.mjs +33 -3
- package/dist/server/auth.js +29 -2
- package/dist/server/auth.mjs +29 -2
- package/dist/server/index.d.mts +24 -4
- package/dist/server/index.d.ts +24 -4
- package/dist/server/index.js +38 -5
- package/dist/server/index.mjs +38 -5
- package/dist/server/webhook-handler.d.mts +2 -2
- package/dist/server/webhook-handler.d.ts +2 -2
- package/dist/testing.d.mts +1 -1
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +3 -0
- package/dist/testing.mjs +3 -0
- package/dist/{webhook-handler-Cfa7-yKN.d.ts → webhook-handler-5ymVecTD.d.ts} +1 -1
- package/dist/{webhook-handler-DN4WBjbm.d.mts → webhook-handler-CVmBvPFN.d.mts} +1 -1
- package/package.json +12 -5
package/README.md
CHANGED
|
@@ -395,3 +395,33 @@ test('shows user name', () => {
|
|
|
395
395
|
## License
|
|
396
396
|
|
|
397
397
|
MIT - ScaleMule Inc.
|
|
398
|
+
|
|
399
|
+
### Shared audio player
|
|
400
|
+
|
|
401
|
+
```tsx
|
|
402
|
+
import { AudioPlayer } from '@scalemule/nextjs/audio'
|
|
403
|
+
import '@scalemule/nextjs/audio.css'
|
|
404
|
+
|
|
405
|
+
<AudioPlayer
|
|
406
|
+
audioKey={article.id}
|
|
407
|
+
audio={{ url: article.audio.url, duration_ms: article.audio.duration_ms }}
|
|
408
|
+
variant="compact"
|
|
409
|
+
label="Listen to this story"
|
|
410
|
+
/>
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
`AudioPlayer` has three layouts using one playback controller:
|
|
414
|
+
|
|
415
|
+
- `waveform` (default): full article player; displays real supplied `waveform_peaks`, or a progress track when peaks are unavailable.
|
|
416
|
+
- `compact`: narration label, elapsed time, explicit remaining time, seek track and speed control. Suited to news articles.
|
|
417
|
+
- `inline`: play/pause, elapsed/remaining time and speed for a story list.
|
|
418
|
+
|
|
419
|
+
The CSS is shared and framework-independent; import it once in your app layout. The audio entry point preserves `use client` in both ESM and CJS and imports only React at runtime. It requires neither `ScaleMuleProvider` nor an API key. Existing `NarrationPlayer` exports/props remain supported unchanged.
|
|
420
|
+
|
|
421
|
+
Defaults: no autoplay, `preload="none"`, one recording playing per document, and a persisted speed under `scalemule:audio:playback-rate`. Set `playbackRateStorageKey={null}` to disable storage, `exclusivePlayback={false}` to allow simultaneous players, or `preload="metadata"` for eager duration discovery. Supplying `duration_ms` shows remaining time without fetching media. Elapsed time stays on the recording's timeline; remaining time accounts for playback speed. Native range inputs support keyboard and touch seeking; both played/remaining contrast and the seek handle identify progress.
|
|
422
|
+
|
|
423
|
+
For signed URLs, supply `onRefresh(signal)` returning a fresh `AudioPlayerSource`. Pass the signal to your application's fetch request. The controller refreshes near expiry only on a play attempt, deduplicates concurrent requests, attempts automatic recovery at most once per play attempt, and preserves position after metadata loads. `showRefreshButton` exposes a manual retry. Refresh callbacks should use a bounded request timeout. The player never fetches a platform endpoint itself, and never generates audio. Keep auth/tenant authorization in the host application's server route; only return a reader-authorized media URL. Change `audioKey` when the article changes to abort stale refreshes and reset position.
|
|
424
|
+
|
|
425
|
+
Customize `--sm-audio-accent`, `--sm-audio-ink`, `--sm-audio-muted`, `--sm-audio-track`, and `--sm-audio-wave` on `className` or `style`, retaining accessible contrast. Default accent/track contrast is over 4:1. Keep the player outside a navigation link; links and playback controls need separate interaction targets.
|
|
426
|
+
|
|
427
|
+
This patch adds reusable presentation and bounded playback recovery. It does not change media storage, transcoding, TTS generation or entitlement enforcement.
|
package/dist/audio.css
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/* src/components/audio-player.css */
|
|
2
|
+
.sm-audio {
|
|
3
|
+
--sm-audio-accent: #246346;
|
|
4
|
+
--sm-audio-ink: #203b30;
|
|
5
|
+
--sm-audio-muted: #52675c;
|
|
6
|
+
--sm-audio-track: #d4ddd6;
|
|
7
|
+
--sm-audio-wave: #8e9e93;
|
|
8
|
+
display: flex;
|
|
9
|
+
align-items: center;
|
|
10
|
+
flex-wrap: wrap;
|
|
11
|
+
gap: 14px;
|
|
12
|
+
width: 100%;
|
|
13
|
+
min-width: 0;
|
|
14
|
+
color: var(--sm-audio-ink);
|
|
15
|
+
font: 400 14px/1.4 system-ui, sans-serif;
|
|
16
|
+
color-scheme: light;
|
|
17
|
+
}
|
|
18
|
+
.sm-audio,
|
|
19
|
+
.sm-audio * {
|
|
20
|
+
box-sizing: border-box;
|
|
21
|
+
}
|
|
22
|
+
.sm-audio audio {
|
|
23
|
+
display: none;
|
|
24
|
+
}
|
|
25
|
+
.sm-audio button {
|
|
26
|
+
font: inherit;
|
|
27
|
+
cursor: pointer;
|
|
28
|
+
touch-action: manipulation;
|
|
29
|
+
}
|
|
30
|
+
.sm-audio button:focus-visible,
|
|
31
|
+
.sm-audio input:focus-visible {
|
|
32
|
+
outline: 3px solid var(--sm-audio-accent);
|
|
33
|
+
outline-offset: 4px;
|
|
34
|
+
}
|
|
35
|
+
.sm-audio__play {
|
|
36
|
+
display: grid;
|
|
37
|
+
place-items: center;
|
|
38
|
+
flex: 0 0 44px;
|
|
39
|
+
width: 44px;
|
|
40
|
+
height: 44px;
|
|
41
|
+
padding: 0;
|
|
42
|
+
border: 0;
|
|
43
|
+
border-radius: 50%;
|
|
44
|
+
background: var(--sm-audio-accent);
|
|
45
|
+
color: #fff;
|
|
46
|
+
}
|
|
47
|
+
.sm-audio__play svg {
|
|
48
|
+
width: 21px;
|
|
49
|
+
height: 21px;
|
|
50
|
+
fill: currentColor;
|
|
51
|
+
}
|
|
52
|
+
.sm-audio__content {
|
|
53
|
+
flex: 1 1 150px;
|
|
54
|
+
min-width: 0;
|
|
55
|
+
}
|
|
56
|
+
.sm-audio__label {
|
|
57
|
+
display: block;
|
|
58
|
+
font-weight: 650;
|
|
59
|
+
font-size: 14px;
|
|
60
|
+
}
|
|
61
|
+
.sm-audio__times {
|
|
62
|
+
display: flex;
|
|
63
|
+
flex-wrap: wrap;
|
|
64
|
+
justify-content: space-between;
|
|
65
|
+
gap: 4px 12px;
|
|
66
|
+
font-size: 12px;
|
|
67
|
+
color: var(--sm-audio-muted);
|
|
68
|
+
font-variant-numeric: tabular-nums;
|
|
69
|
+
}
|
|
70
|
+
.sm-audio__seek {
|
|
71
|
+
width: 100%;
|
|
72
|
+
}
|
|
73
|
+
.sm-audio__range {
|
|
74
|
+
appearance: none;
|
|
75
|
+
-webkit-appearance: none;
|
|
76
|
+
display: block;
|
|
77
|
+
width: 100%;
|
|
78
|
+
height: 24px;
|
|
79
|
+
padding: 0;
|
|
80
|
+
margin: 0;
|
|
81
|
+
border: 0;
|
|
82
|
+
cursor: pointer;
|
|
83
|
+
background: transparent;
|
|
84
|
+
border-radius: 0;
|
|
85
|
+
}
|
|
86
|
+
.sm-audio__range::-webkit-slider-runnable-track {
|
|
87
|
+
height: 6px;
|
|
88
|
+
border-radius: 3px;
|
|
89
|
+
background:
|
|
90
|
+
linear-gradient(
|
|
91
|
+
to right,
|
|
92
|
+
var(--sm-audio-accent) 0 var(--sm-audio-progress),
|
|
93
|
+
var(--sm-audio-track) var(--sm-audio-progress) 100%);
|
|
94
|
+
}
|
|
95
|
+
.sm-audio__range::-moz-range-track {
|
|
96
|
+
height: 6px;
|
|
97
|
+
border-radius: 3px;
|
|
98
|
+
background: var(--sm-audio-track);
|
|
99
|
+
}
|
|
100
|
+
.sm-audio__range::-moz-range-progress {
|
|
101
|
+
height: 6px;
|
|
102
|
+
border-radius: 3px;
|
|
103
|
+
background: var(--sm-audio-accent);
|
|
104
|
+
}
|
|
105
|
+
.sm-audio__range::-webkit-slider-thumb {
|
|
106
|
+
appearance: none;
|
|
107
|
+
-webkit-appearance: none;
|
|
108
|
+
width: 14px;
|
|
109
|
+
height: 14px;
|
|
110
|
+
margin-top: -4px;
|
|
111
|
+
border: 2px solid #fff;
|
|
112
|
+
border-radius: 50%;
|
|
113
|
+
background: var(--sm-audio-accent);
|
|
114
|
+
box-shadow: 0 0 0 1px var(--sm-audio-accent);
|
|
115
|
+
}
|
|
116
|
+
.sm-audio__range::-moz-range-thumb {
|
|
117
|
+
width: 10px;
|
|
118
|
+
height: 10px;
|
|
119
|
+
border: 2px solid #fff;
|
|
120
|
+
border-radius: 50%;
|
|
121
|
+
background: var(--sm-audio-accent);
|
|
122
|
+
box-shadow: 0 0 0 1px var(--sm-audio-accent);
|
|
123
|
+
}
|
|
124
|
+
.sm-audio__range:disabled {
|
|
125
|
+
cursor: default;
|
|
126
|
+
opacity: 0.6;
|
|
127
|
+
}
|
|
128
|
+
.sm-audio__actions {
|
|
129
|
+
display: flex;
|
|
130
|
+
align-items: center;
|
|
131
|
+
gap: 8px;
|
|
132
|
+
}
|
|
133
|
+
.sm-audio__speed,
|
|
134
|
+
.sm-audio__refresh {
|
|
135
|
+
min-width: 44px;
|
|
136
|
+
min-height: 44px;
|
|
137
|
+
padding: 6px 10px;
|
|
138
|
+
border: 1px solid var(--sm-audio-track);
|
|
139
|
+
border-radius: 8px;
|
|
140
|
+
background: transparent;
|
|
141
|
+
color: var(--sm-audio-ink);
|
|
142
|
+
font-weight: 600 !important;
|
|
143
|
+
}
|
|
144
|
+
.sm-audio__refresh:disabled {
|
|
145
|
+
opacity: 0.6;
|
|
146
|
+
cursor: wait;
|
|
147
|
+
}
|
|
148
|
+
.sm-audio__status {
|
|
149
|
+
flex: 1 0 100%;
|
|
150
|
+
margin: 0;
|
|
151
|
+
font-size: 13px;
|
|
152
|
+
color: var(--sm-audio-muted);
|
|
153
|
+
}
|
|
154
|
+
.sm-audio__waveform {
|
|
155
|
+
display: flex;
|
|
156
|
+
align-items: center;
|
|
157
|
+
gap: 3px;
|
|
158
|
+
height: 48px;
|
|
159
|
+
margin-top: 12px;
|
|
160
|
+
}
|
|
161
|
+
.sm-audio__waveform span {
|
|
162
|
+
flex: 1;
|
|
163
|
+
min-width: 1px;
|
|
164
|
+
border-radius: 2px;
|
|
165
|
+
}
|
|
166
|
+
.sm-audio--waveform {
|
|
167
|
+
padding: 16px;
|
|
168
|
+
border: 1px solid var(--sm-audio-track);
|
|
169
|
+
border-radius: 12px;
|
|
170
|
+
}
|
|
171
|
+
.sm-audio--inline {
|
|
172
|
+
gap: 8px;
|
|
173
|
+
}
|
|
174
|
+
.sm-audio--inline .sm-audio__label {
|
|
175
|
+
position: absolute;
|
|
176
|
+
width: 1px;
|
|
177
|
+
height: 1px;
|
|
178
|
+
overflow: hidden;
|
|
179
|
+
clip-path: inset(50%);
|
|
180
|
+
}
|
|
181
|
+
.sm-audio--inline .sm-audio__times {
|
|
182
|
+
justify-content: flex-start;
|
|
183
|
+
}
|
|
184
|
+
.sm-audio__sr {
|
|
185
|
+
position: absolute;
|
|
186
|
+
width: 1px;
|
|
187
|
+
height: 1px;
|
|
188
|
+
overflow: hidden;
|
|
189
|
+
clip-path: inset(50%);
|
|
190
|
+
}
|
|
191
|
+
@media (forced-colors: active) {
|
|
192
|
+
.sm-audio__range {
|
|
193
|
+
appearance: auto;
|
|
194
|
+
}
|
|
195
|
+
.sm-audio__play {
|
|
196
|
+
border: 1px solid ButtonText;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
@media (max-width: 380px) {
|
|
200
|
+
.sm-audio {
|
|
201
|
+
gap: 10px;
|
|
202
|
+
}
|
|
203
|
+
.sm-audio__content {
|
|
204
|
+
flex-basis: 120px;
|
|
205
|
+
}
|
|
206
|
+
}
|
package/dist/audio.d.mts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { CSSProperties } from 'react';
|
|
3
|
+
|
|
4
|
+
interface AudioPlayerSource {
|
|
5
|
+
url: string | null;
|
|
6
|
+
duration_ms?: number | null;
|
|
7
|
+
expires_at?: string | null;
|
|
8
|
+
waveform_peaks?: number[] | null;
|
|
9
|
+
ai_generated?: boolean;
|
|
10
|
+
}
|
|
11
|
+
type AudioPlayerVariant = 'waveform' | 'compact' | 'inline';
|
|
12
|
+
interface AudioPlayerProps {
|
|
13
|
+
audio: AudioPlayerSource;
|
|
14
|
+
/** Stable article/record identity, including the tenant if needed. Changing it resets playback. */
|
|
15
|
+
audioKey?: string;
|
|
16
|
+
variant?: AudioPlayerVariant;
|
|
17
|
+
label?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
style?: CSSProperties;
|
|
20
|
+
/** Defaults to none: lists do not download every recording on page load. */
|
|
21
|
+
preload?: 'none' | 'metadata';
|
|
22
|
+
/** Called at most once automatically per play attempt. Honor signal to cancel network work. */
|
|
23
|
+
onRefresh?: (signal: AbortSignal) => Promise<AudioPlayerSource>;
|
|
24
|
+
showRefreshButton?: boolean;
|
|
25
|
+
onPlaybackError?: () => void;
|
|
26
|
+
/** Shared with the existing ScaleMule blog player. Set null to disable persistence. */
|
|
27
|
+
playbackRateStorageKey?: string | null;
|
|
28
|
+
exclusivePlayback?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** Three layouts, one playback controller. No provider, credentials, polling, or Next.js runtime imports. */
|
|
31
|
+
declare function AudioPlayer(props: AudioPlayerProps): react_jsx_runtime.JSX.Element | null;
|
|
32
|
+
|
|
33
|
+
export { AudioPlayer, type AudioPlayerProps, type AudioPlayerSource, type AudioPlayerVariant };
|
package/dist/audio.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
import { CSSProperties } from 'react';
|
|
3
|
+
|
|
4
|
+
interface AudioPlayerSource {
|
|
5
|
+
url: string | null;
|
|
6
|
+
duration_ms?: number | null;
|
|
7
|
+
expires_at?: string | null;
|
|
8
|
+
waveform_peaks?: number[] | null;
|
|
9
|
+
ai_generated?: boolean;
|
|
10
|
+
}
|
|
11
|
+
type AudioPlayerVariant = 'waveform' | 'compact' | 'inline';
|
|
12
|
+
interface AudioPlayerProps {
|
|
13
|
+
audio: AudioPlayerSource;
|
|
14
|
+
/** Stable article/record identity, including the tenant if needed. Changing it resets playback. */
|
|
15
|
+
audioKey?: string;
|
|
16
|
+
variant?: AudioPlayerVariant;
|
|
17
|
+
label?: string;
|
|
18
|
+
className?: string;
|
|
19
|
+
style?: CSSProperties;
|
|
20
|
+
/** Defaults to none: lists do not download every recording on page load. */
|
|
21
|
+
preload?: 'none' | 'metadata';
|
|
22
|
+
/** Called at most once automatically per play attempt. Honor signal to cancel network work. */
|
|
23
|
+
onRefresh?: (signal: AbortSignal) => Promise<AudioPlayerSource>;
|
|
24
|
+
showRefreshButton?: boolean;
|
|
25
|
+
onPlaybackError?: () => void;
|
|
26
|
+
/** Shared with the existing ScaleMule blog player. Set null to disable persistence. */
|
|
27
|
+
playbackRateStorageKey?: string | null;
|
|
28
|
+
exclusivePlayback?: boolean;
|
|
29
|
+
}
|
|
30
|
+
/** Three layouts, one playback controller. No provider, credentials, polling, or Next.js runtime imports. */
|
|
31
|
+
declare function AudioPlayer(props: AudioPlayerProps): react_jsx_runtime.JSX.Element | null;
|
|
32
|
+
|
|
33
|
+
export { AudioPlayer, type AudioPlayerProps, type AudioPlayerSource, type AudioPlayerVariant };
|
package/dist/audio.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
"use strict";
|
|
3
|
+
"use client";
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
21
|
+
|
|
22
|
+
// src/audio.ts
|
|
23
|
+
var audio_exports = {};
|
|
24
|
+
__export(audio_exports, {
|
|
25
|
+
AudioPlayer: () => AudioPlayer
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(audio_exports);
|
|
28
|
+
|
|
29
|
+
// src/components/audio-player.tsx
|
|
30
|
+
var import_react = require("react");
|
|
31
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
32
|
+
var SPEEDS = [1, 1.25, 1.5, 2, 3];
|
|
33
|
+
var PLAY_EVENT = "scalemule:audio:play";
|
|
34
|
+
var positive = (n) => n != null && Number.isFinite(n) && n > 0 ? n : 0;
|
|
35
|
+
var durationOf = (audio) => positive(audio.duration_ms) / 1e3;
|
|
36
|
+
function time(seconds) {
|
|
37
|
+
const s = Math.floor(positive(seconds));
|
|
38
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
|
|
39
|
+
}
|
|
40
|
+
function AudioPlayer(props) {
|
|
41
|
+
if (!props.audio.url) return null;
|
|
42
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PlayerSession, { ...props }, props.audioKey ?? props.audio.url);
|
|
43
|
+
}
|
|
44
|
+
function PlayerSession({
|
|
45
|
+
audio,
|
|
46
|
+
variant = "waveform",
|
|
47
|
+
label,
|
|
48
|
+
className,
|
|
49
|
+
style,
|
|
50
|
+
preload = "none",
|
|
51
|
+
onRefresh,
|
|
52
|
+
showRefreshButton = false,
|
|
53
|
+
onPlaybackError,
|
|
54
|
+
playbackRateStorageKey = "scalemule:audio:playback-rate",
|
|
55
|
+
exclusivePlayback = true
|
|
56
|
+
}) {
|
|
57
|
+
const media = (0, import_react.useRef)(null);
|
|
58
|
+
const alive = (0, import_react.useRef)(true);
|
|
59
|
+
const request = (0, import_react.useRef)(null);
|
|
60
|
+
const triedRefresh = (0, import_react.useRef)(false);
|
|
61
|
+
const intent = (0, import_react.useRef)(false);
|
|
62
|
+
const resume = (0, import_react.useRef)(null);
|
|
63
|
+
const [source, setSource] = (0, import_react.useState)(audio);
|
|
64
|
+
const [duration, setDuration] = (0, import_react.useState)(durationOf(audio));
|
|
65
|
+
const [position, setPosition] = (0, import_react.useState)(0);
|
|
66
|
+
const [speed, setSpeed] = (0, import_react.useState)(1);
|
|
67
|
+
const [playing, setPlaying] = (0, import_react.useState)(false);
|
|
68
|
+
const [pendingPlay, setPendingPlay] = (0, import_react.useState)(false);
|
|
69
|
+
const [busy, setBusy] = (0, import_react.useState)(false);
|
|
70
|
+
const [error, setError] = (0, import_react.useState)(null);
|
|
71
|
+
const sliderId = (0, import_react.useId)();
|
|
72
|
+
const labelId = (0, import_react.useId)();
|
|
73
|
+
const rate = (0, import_react.useRef)(1);
|
|
74
|
+
(0, import_react.useEffect)(() => {
|
|
75
|
+
alive.current = true;
|
|
76
|
+
const element = media.current;
|
|
77
|
+
return () => {
|
|
78
|
+
alive.current = false;
|
|
79
|
+
intent.current = false;
|
|
80
|
+
request.current?.abort();
|
|
81
|
+
element?.pause();
|
|
82
|
+
};
|
|
83
|
+
}, []);
|
|
84
|
+
(0, import_react.useEffect)(() => {
|
|
85
|
+
request.current?.abort();
|
|
86
|
+
request.current = null;
|
|
87
|
+
setBusy(false);
|
|
88
|
+
resume.current = media.current?.currentTime ?? 0;
|
|
89
|
+
setSource(audio);
|
|
90
|
+
setDuration(durationOf(audio));
|
|
91
|
+
}, [audio.url, audio.expires_at, audio.duration_ms]);
|
|
92
|
+
(0, import_react.useEffect)(() => {
|
|
93
|
+
if (!playbackRateStorageKey) return;
|
|
94
|
+
try {
|
|
95
|
+
const saved = Number(window.localStorage.getItem(playbackRateStorageKey));
|
|
96
|
+
if (SPEEDS.includes(saved)) {
|
|
97
|
+
setSpeed(saved);
|
|
98
|
+
rate.current = saved;
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
}
|
|
102
|
+
}, [playbackRateStorageKey]);
|
|
103
|
+
(0, import_react.useEffect)(() => {
|
|
104
|
+
if (media.current) {
|
|
105
|
+
media.current.playbackRate = speed;
|
|
106
|
+
media.current.preservesPitch = true;
|
|
107
|
+
}
|
|
108
|
+
}, [speed, source.url]);
|
|
109
|
+
(0, import_react.useEffect)(() => {
|
|
110
|
+
if (!exclusivePlayback) return;
|
|
111
|
+
const stop = (event) => {
|
|
112
|
+
if (event.detail !== media.current) {
|
|
113
|
+
intent.current = false;
|
|
114
|
+
setPendingPlay(false);
|
|
115
|
+
media.current?.pause();
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
document.addEventListener(PLAY_EVENT, stop);
|
|
119
|
+
return () => document.removeEventListener(PLAY_EVENT, stop);
|
|
120
|
+
}, [exclusivePlayback]);
|
|
121
|
+
function fail() {
|
|
122
|
+
if (!alive.current) return;
|
|
123
|
+
intent.current = false;
|
|
124
|
+
setPendingPlay(false);
|
|
125
|
+
setPlaying(false);
|
|
126
|
+
setBusy(false);
|
|
127
|
+
setError("Audio could not be played. Try again.");
|
|
128
|
+
onPlaybackError?.();
|
|
129
|
+
}
|
|
130
|
+
async function play() {
|
|
131
|
+
const element = media.current;
|
|
132
|
+
if (!element || !intent.current) return;
|
|
133
|
+
try {
|
|
134
|
+
await element.play();
|
|
135
|
+
} catch (e) {
|
|
136
|
+
if (!alive.current || !intent.current) return;
|
|
137
|
+
if (e.name === "NotAllowedError") {
|
|
138
|
+
fail();
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
await recover();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function recover(manual = false) {
|
|
145
|
+
if (!alive.current || request.current) return;
|
|
146
|
+
if (!onRefresh || !manual && triedRefresh.current) {
|
|
147
|
+
fail();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
triedRefresh.current = true;
|
|
151
|
+
const controller = new AbortController();
|
|
152
|
+
request.current = controller;
|
|
153
|
+
resume.current = media.current?.currentTime ?? position;
|
|
154
|
+
setBusy(true);
|
|
155
|
+
setError(null);
|
|
156
|
+
try {
|
|
157
|
+
const fresh = await onRefresh(controller.signal);
|
|
158
|
+
if (!alive.current || controller.signal.aborted) return;
|
|
159
|
+
if (!fresh.url) throw new Error("No audio URL");
|
|
160
|
+
setDuration(durationOf(fresh) || duration);
|
|
161
|
+
if (fresh.url === source.url) {
|
|
162
|
+
media.current?.load();
|
|
163
|
+
} else {
|
|
164
|
+
setSource(fresh);
|
|
165
|
+
}
|
|
166
|
+
} catch {
|
|
167
|
+
if (!controller.signal.aborted) fail();
|
|
168
|
+
} finally {
|
|
169
|
+
if (request.current === controller) {
|
|
170
|
+
request.current = null;
|
|
171
|
+
if (alive.current) setBusy(false);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function toggle() {
|
|
176
|
+
if (intent.current || playing) {
|
|
177
|
+
intent.current = false;
|
|
178
|
+
setPendingPlay(false);
|
|
179
|
+
media.current?.pause();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
intent.current = true;
|
|
183
|
+
setPendingPlay(true);
|
|
184
|
+
triedRefresh.current = false;
|
|
185
|
+
setError(null);
|
|
186
|
+
if (exclusivePlayback)
|
|
187
|
+
document.dispatchEvent(
|
|
188
|
+
new CustomEvent(PLAY_EVENT, { detail: media.current })
|
|
189
|
+
);
|
|
190
|
+
if (request.current) return;
|
|
191
|
+
const expiry = Date.parse(source.expires_at ?? "");
|
|
192
|
+
if (onRefresh && Number.isFinite(expiry) && expiry - Date.now() <= 6e4) {
|
|
193
|
+
void recover();
|
|
194
|
+
} else {
|
|
195
|
+
void play();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function seek(value) {
|
|
199
|
+
if (!media.current || !duration || !Number.isFinite(value)) return;
|
|
200
|
+
const target = Math.max(0, Math.min(duration, value));
|
|
201
|
+
try {
|
|
202
|
+
media.current.currentTime = target;
|
|
203
|
+
resume.current = target;
|
|
204
|
+
setPosition(target);
|
|
205
|
+
} catch {
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function changeSpeed() {
|
|
209
|
+
const next = SPEEDS[(SPEEDS.indexOf(rate.current) + 1) % SPEEDS.length];
|
|
210
|
+
rate.current = next;
|
|
211
|
+
setSpeed(next);
|
|
212
|
+
if (playbackRateStorageKey) {
|
|
213
|
+
try {
|
|
214
|
+
window.localStorage.setItem(playbackRateStorageKey, String(next));
|
|
215
|
+
} catch {
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const peaks = (0, import_react.useMemo)(() => {
|
|
220
|
+
const input = source.waveform_peaks;
|
|
221
|
+
if (!input?.length) return [];
|
|
222
|
+
const count = Math.min(64, input.length);
|
|
223
|
+
return Array.from({ length: count }, (_, i) => {
|
|
224
|
+
const value = input[Math.floor(i * input.length / count)];
|
|
225
|
+
return Number.isFinite(value) ? Math.min(1, Math.max(0.08, Math.abs(value))) : 0.08;
|
|
226
|
+
});
|
|
227
|
+
}, [source.waveform_peaks]);
|
|
228
|
+
const current = Math.min(positive(position), duration || Infinity);
|
|
229
|
+
const progress = duration ? Math.max(0, Math.min(100, current / duration * 100)) : 0;
|
|
230
|
+
const remaining = duration ? `${time((duration - current) / speed)} remaining` : "Duration available when played";
|
|
231
|
+
const title = label ?? (source.ai_generated === false ? "Listen to narration" : "Listen to AI narration");
|
|
232
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
233
|
+
"div",
|
|
234
|
+
{
|
|
235
|
+
className: `sm-audio sm-audio--${variant}${className ? ` ${className}` : ""}`,
|
|
236
|
+
style,
|
|
237
|
+
role: "group",
|
|
238
|
+
"aria-labelledby": labelId,
|
|
239
|
+
"data-audio-variant": variant,
|
|
240
|
+
children: [
|
|
241
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
242
|
+
"audio",
|
|
243
|
+
{
|
|
244
|
+
ref: media,
|
|
245
|
+
src: source.url ?? void 0,
|
|
246
|
+
preload,
|
|
247
|
+
onLoadedMetadata: (e) => {
|
|
248
|
+
const element = e.currentTarget;
|
|
249
|
+
const total = positive(element.duration) || durationOf(source);
|
|
250
|
+
setDuration(total);
|
|
251
|
+
element.playbackRate = rate.current;
|
|
252
|
+
element.preservesPitch = true;
|
|
253
|
+
if (resume.current != null) {
|
|
254
|
+
try {
|
|
255
|
+
element.currentTime = Math.min(
|
|
256
|
+
resume.current,
|
|
257
|
+
total || resume.current
|
|
258
|
+
);
|
|
259
|
+
} catch {
|
|
260
|
+
}
|
|
261
|
+
resume.current = null;
|
|
262
|
+
}
|
|
263
|
+
setPosition(positive(element.currentTime));
|
|
264
|
+
if (intent.current) void play();
|
|
265
|
+
},
|
|
266
|
+
onDurationChange: (e) => {
|
|
267
|
+
const d = positive(e.currentTarget.duration);
|
|
268
|
+
if (d) setDuration(d);
|
|
269
|
+
},
|
|
270
|
+
onTimeUpdate: (e) => setPosition(positive(e.currentTarget.currentTime)),
|
|
271
|
+
onPlay: () => {
|
|
272
|
+
intent.current = true;
|
|
273
|
+
setPlaying(true);
|
|
274
|
+
setError(null);
|
|
275
|
+
if (exclusivePlayback)
|
|
276
|
+
document.dispatchEvent(
|
|
277
|
+
new CustomEvent(PLAY_EVENT, { detail: media.current })
|
|
278
|
+
);
|
|
279
|
+
},
|
|
280
|
+
onPause: () => setPlaying(false),
|
|
281
|
+
onEnded: () => {
|
|
282
|
+
intent.current = false;
|
|
283
|
+
setPendingPlay(false);
|
|
284
|
+
setPlaying(false);
|
|
285
|
+
setPosition(duration);
|
|
286
|
+
},
|
|
287
|
+
onError: () => {
|
|
288
|
+
if (intent.current) void recover();
|
|
289
|
+
else if (!request.current) {
|
|
290
|
+
setError("Audio is unavailable. Press play to retry.");
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
),
|
|
295
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
296
|
+
"button",
|
|
297
|
+
{
|
|
298
|
+
type: "button",
|
|
299
|
+
className: "sm-audio__play",
|
|
300
|
+
onClick: toggle,
|
|
301
|
+
"aria-label": playing || busy && pendingPlay ? "Pause narration" : "Play narration",
|
|
302
|
+
children: playing || busy && pendingPlay ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M6 4h4v16H6zm8 0h4v16h-4z" }) }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("svg", { viewBox: "0 0 24 24", "aria-hidden": "true", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("path", { d: "M8 4v16l12-8z" }) })
|
|
303
|
+
}
|
|
304
|
+
),
|
|
305
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "sm-audio__content", children: [
|
|
306
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "sm-audio__label", id: labelId, children: title }),
|
|
307
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "sm-audio__times", children: [
|
|
308
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { children: [
|
|
309
|
+
time(current),
|
|
310
|
+
" ",
|
|
311
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { className: "sm-audio__elapsed", children: "elapsed" })
|
|
312
|
+
] }),
|
|
313
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: remaining })
|
|
314
|
+
] }),
|
|
315
|
+
variant !== "inline" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
316
|
+
"div",
|
|
317
|
+
{
|
|
318
|
+
className: "sm-audio__seek",
|
|
319
|
+
style: { "--sm-audio-progress": `${progress}%` },
|
|
320
|
+
children: [
|
|
321
|
+
variant === "waveform" && peaks.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className: "sm-audio__waveform", "aria-hidden": "true", children: peaks.map((peak, i) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
322
|
+
"span",
|
|
323
|
+
{
|
|
324
|
+
style: {
|
|
325
|
+
height: `${peak * 100}%`,
|
|
326
|
+
background: (i + 0.5) / peaks.length * 100 <= progress ? "var(--sm-audio-accent)" : "var(--sm-audio-wave)"
|
|
327
|
+
}
|
|
328
|
+
},
|
|
329
|
+
i
|
|
330
|
+
)) }),
|
|
331
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("label", { className: "sm-audio__sr", htmlFor: sliderId, children: "Seek narration" }),
|
|
332
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
333
|
+
"input",
|
|
334
|
+
{
|
|
335
|
+
id: sliderId,
|
|
336
|
+
className: "sm-audio__range",
|
|
337
|
+
type: "range",
|
|
338
|
+
min: "0",
|
|
339
|
+
max: duration || 1,
|
|
340
|
+
step: "0.1",
|
|
341
|
+
disabled: !duration,
|
|
342
|
+
value: Math.min(current, duration || 1),
|
|
343
|
+
"aria-valuetext": `${time(current)} elapsed; ${remaining}`,
|
|
344
|
+
onChange: (e) => seek(Number(e.currentTarget.value))
|
|
345
|
+
}
|
|
346
|
+
)
|
|
347
|
+
]
|
|
348
|
+
}
|
|
349
|
+
)
|
|
350
|
+
] }),
|
|
351
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "sm-audio__actions", children: [
|
|
352
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
353
|
+
"button",
|
|
354
|
+
{
|
|
355
|
+
type: "button",
|
|
356
|
+
className: "sm-audio__speed",
|
|
357
|
+
onClick: changeSpeed,
|
|
358
|
+
"aria-label": `Change playback speed (current ${speed}\xD7)`,
|
|
359
|
+
children: [
|
|
360
|
+
speed,
|
|
361
|
+
"\xD7"
|
|
362
|
+
]
|
|
363
|
+
}
|
|
364
|
+
),
|
|
365
|
+
onRefresh && showRefreshButton && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
|
|
366
|
+
"button",
|
|
367
|
+
{
|
|
368
|
+
type: "button",
|
|
369
|
+
className: "sm-audio__refresh",
|
|
370
|
+
disabled: busy,
|
|
371
|
+
onClick: () => void recover(true),
|
|
372
|
+
children: "Refresh"
|
|
373
|
+
}
|
|
374
|
+
)
|
|
375
|
+
] }),
|
|
376
|
+
(busy || error) && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { className: "sm-audio__status", role: "status", children: busy ? "Refreshing audio\u2026" : error })
|
|
377
|
+
]
|
|
378
|
+
}
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
382
|
+
0 && (module.exports = {
|
|
383
|
+
AudioPlayer
|
|
384
|
+
});
|