@fanvue/ui 3.16.0 → 3.18.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/cjs/components/AudioPlayer/AudioPlayer.cjs +16 -89
- package/dist/cjs/components/AudioPlayer/AudioPlayer.cjs.map +1 -1
- package/dist/cjs/components/Icons/ReceiveMoneyIcon.cjs +79 -0
- package/dist/cjs/components/Icons/ReceiveMoneyIcon.cjs.map +1 -0
- package/dist/cjs/components/PhoneField/PhoneField.cjs +198 -0
- package/dist/cjs/components/PhoneField/PhoneField.cjs.map +1 -0
- package/dist/cjs/components/RatingSummary/RatingSummary.cjs +30 -23
- package/dist/cjs/components/RatingSummary/RatingSummary.cjs.map +1 -1
- package/dist/cjs/components/TextArea/TextArea.cjs +2 -2
- package/dist/cjs/components/TextArea/TextArea.cjs.map +1 -1
- package/dist/cjs/components/TextField/TextField.cjs +13 -6
- package/dist/cjs/components/TextField/TextField.cjs.map +1 -1
- package/dist/cjs/components/VoiceNote/VoiceNote.cjs +305 -0
- package/dist/cjs/components/VoiceNote/VoiceNote.cjs.map +1 -0
- package/dist/cjs/index.cjs +6 -0
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/utils/audioWaveform.cjs +76 -0
- package/dist/cjs/utils/audioWaveform.cjs.map +1 -0
- package/dist/cjs/utils/useAudioPlayback.cjs +123 -0
- package/dist/cjs/utils/useAudioPlayback.cjs.map +1 -0
- package/dist/cjs/utils/useFittedBarCount.cjs +42 -0
- package/dist/cjs/utils/useFittedBarCount.cjs.map +1 -0
- package/dist/cjs/utils/useWaveformPeaks.cjs +48 -0
- package/dist/cjs/utils/useWaveformPeaks.cjs.map +1 -0
- package/dist/cjs/utils/useWaveformSeek.cjs +100 -0
- package/dist/cjs/utils/useWaveformSeek.cjs.map +1 -0
- package/dist/components/AudioPlayer/AudioPlayer.mjs +7 -80
- package/dist/components/AudioPlayer/AudioPlayer.mjs.map +1 -1
- package/dist/components/Icons/ReceiveMoneyIcon.mjs +62 -0
- package/dist/components/Icons/ReceiveMoneyIcon.mjs.map +1 -0
- package/dist/components/PhoneField/PhoneField.mjs +181 -0
- package/dist/components/PhoneField/PhoneField.mjs.map +1 -0
- package/dist/components/RatingSummary/RatingSummary.mjs +30 -23
- package/dist/components/RatingSummary/RatingSummary.mjs.map +1 -1
- package/dist/components/TextArea/TextArea.mjs +2 -2
- package/dist/components/TextArea/TextArea.mjs.map +1 -1
- package/dist/components/TextField/TextField.mjs +13 -6
- package/dist/components/TextField/TextField.mjs.map +1 -1
- package/dist/components/VoiceNote/VoiceNote.mjs +288 -0
- package/dist/components/VoiceNote/VoiceNote.mjs.map +1 -0
- package/dist/index.d.ts +158 -0
- package/dist/index.mjs +6 -0
- package/dist/index.mjs.map +1 -1
- package/dist/styles/base.css +11 -2
- package/dist/utils/audioWaveform.mjs +76 -0
- package/dist/utils/audioWaveform.mjs.map +1 -0
- package/dist/utils/useAudioPlayback.mjs +106 -0
- package/dist/utils/useAudioPlayback.mjs.map +1 -0
- package/dist/utils/useFittedBarCount.mjs +25 -0
- package/dist/utils/useFittedBarCount.mjs.map +1 -0
- package/dist/utils/useWaveformPeaks.mjs +31 -0
- package/dist/utils/useWaveformPeaks.mjs.map +1 -0
- package/dist/utils/useWaveformSeek.mjs +83 -0
- package/dist/utils/useWaveformSeek.mjs.map +1 -0
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -3840,6 +3840,55 @@ export declare const PeopleIcon: React_2.ForwardRefExoticComponent<React_2.SVGAt
|
|
|
3840
3840
|
className?: string;
|
|
3841
3841
|
} & React_2.RefAttributes<SVGSVGElement>>;
|
|
3842
3842
|
|
|
3843
|
+
/**
|
|
3844
|
+
* A phone number input with a country selector (flag + chevron), a fixed dial
|
|
3845
|
+
* code prefix, and a `tel` input. The country picker itself is owned by the
|
|
3846
|
+
* consumer: pass the selected country's `flag` and `dialCode`, and open your
|
|
3847
|
+
* picker from `onCountrySelect`.
|
|
3848
|
+
*
|
|
3849
|
+
* Provide at least one of `label`, `aria-label`, or `aria-labelledby` for
|
|
3850
|
+
* accessibility — a console warning is emitted in development if none are set.
|
|
3851
|
+
*
|
|
3852
|
+
* @example
|
|
3853
|
+
* ```tsx
|
|
3854
|
+
* <PhoneField
|
|
3855
|
+
* label="Phone number"
|
|
3856
|
+
* flag={<img src={italyFlag} alt="" />}
|
|
3857
|
+
* dialCode="+39"
|
|
3858
|
+
* onCountrySelect={openCountryPicker}
|
|
3859
|
+
* value={number}
|
|
3860
|
+
* onChange={(e) => setNumber(e.target.value)}
|
|
3861
|
+
* />
|
|
3862
|
+
* ```
|
|
3863
|
+
*/
|
|
3864
|
+
export declare const PhoneField: React_2.ForwardRefExoticComponent<PhoneFieldProps & React_2.RefAttributes<HTMLInputElement>>;
|
|
3865
|
+
|
|
3866
|
+
export declare interface PhoneFieldProps extends Omit<React_2.InputHTMLAttributes<HTMLInputElement>, "size" | "prefix"> {
|
|
3867
|
+
/** Label text displayed above the input. Also used as the accessible name. */
|
|
3868
|
+
label?: string;
|
|
3869
|
+
/** Helper text displayed below the input. Replaced by `errorMessage` when `error` is `true`. */
|
|
3870
|
+
helperText?: string;
|
|
3871
|
+
/** Height of the phone field in pixels. @default "48" */
|
|
3872
|
+
size?: PhoneFieldSize;
|
|
3873
|
+
/** Whether the field is in an error state. @default false */
|
|
3874
|
+
error?: boolean;
|
|
3875
|
+
/** Error message displayed below the input. Shown instead of `helperText` when `error` is `true`. */
|
|
3876
|
+
errorMessage?: string;
|
|
3877
|
+
/** Dial code of the selected country, shown before the number (e.g. `"+39"`). */
|
|
3878
|
+
dialCode?: React_2.ReactNode;
|
|
3879
|
+
/** Flag of the selected country, rendered in the country selector (e.g. an image or flag icon). */
|
|
3880
|
+
flag?: React_2.ReactNode;
|
|
3881
|
+
/** Fired when the country selector (flag + chevron) is activated. Open the country picker here. */
|
|
3882
|
+
onCountrySelect?: () => void;
|
|
3883
|
+
/** Accessible name for the country selector button. @default "Select country" */
|
|
3884
|
+
countryButtonLabel?: string;
|
|
3885
|
+
/** Whether the field stretches to fill its container width. @default false */
|
|
3886
|
+
fullWidth?: boolean;
|
|
3887
|
+
}
|
|
3888
|
+
|
|
3889
|
+
/** Phone field height in pixels. */
|
|
3890
|
+
export declare type PhoneFieldSize = "48" | "40" | "32";
|
|
3891
|
+
|
|
3843
3892
|
/**
|
|
3844
3893
|
* Phone icon. Renders at sizes 16, 24, or 32 px with outlined and filled variants.
|
|
3845
3894
|
*
|
|
@@ -4171,6 +4220,11 @@ export declare interface RatingCount {
|
|
|
4171
4220
|
* followed by a per-rating histogram. Bar lengths are scaled relative to the
|
|
4172
4221
|
* most-reviewed rating, so the busiest rating always fills the track.
|
|
4173
4222
|
*
|
|
4223
|
+
* The histogram is one shared grid (with subgrid rows) rather than per-row
|
|
4224
|
+
* flex: the label column is sized by the widest label across all rows, so a
|
|
4225
|
+
* width difference between labels (e.g. "1 review" vs "3 reviews") never
|
|
4226
|
+
* changes a row's track length — every bar's right edge stays aligned.
|
|
4227
|
+
*
|
|
4174
4228
|
* The brand star is decorative; the header and each histogram row expose
|
|
4175
4229
|
* `role="img"` with an accessible label (override via `formatAverageLabel` /
|
|
4176
4230
|
* `formatRatingLabel`) so the single-star-plus-number reads clearly.
|
|
@@ -4226,6 +4280,19 @@ export declare interface RatingSummaryProps extends Omit<React_2.HTMLAttributes<
|
|
|
4226
4280
|
formatRatingLabel?: (rating: number, count: number) => string;
|
|
4227
4281
|
}
|
|
4228
4282
|
|
|
4283
|
+
/**
|
|
4284
|
+
* Receive Money icon. Renders at sizes 16, 24, or 32 px.
|
|
4285
|
+
*
|
|
4286
|
+
* @example
|
|
4287
|
+
* ```tsx
|
|
4288
|
+
* <ReceiveMoneyIcon size={24} />
|
|
4289
|
+
* ```
|
|
4290
|
+
*/
|
|
4291
|
+
export declare const ReceiveMoneyIcon: React_2.ForwardRefExoticComponent<BaseIconProps & React_2.RefAttributes<SVGSVGElement>>;
|
|
4292
|
+
|
|
4293
|
+
/** Props for {@link ReceiveMoneyIcon}. See {@link BaseIconProps} for the shared shape. */
|
|
4294
|
+
export declare type ReceiveMoneyIconProps = BaseIconProps;
|
|
4295
|
+
|
|
4229
4296
|
/**
|
|
4230
4297
|
* Repeat icon. Renders at sizes 16, 24, or 32 px.
|
|
4231
4298
|
*
|
|
@@ -5509,6 +5576,15 @@ export declare type TextAreaSize = "48" | "40" | "32";
|
|
|
5509
5576
|
* ```tsx
|
|
5510
5577
|
* <TextField label="Price" leftLabel="$" rightLabel="USD" placeholder="0.00" />
|
|
5511
5578
|
* ```
|
|
5579
|
+
*
|
|
5580
|
+
* @example
|
|
5581
|
+
* ```tsx
|
|
5582
|
+
* <TextField
|
|
5583
|
+
* label="Promo code"
|
|
5584
|
+
* placeholder="Enter code"
|
|
5585
|
+
* action={<Chip size="32" onClick={apply}>Apply</Chip>}
|
|
5586
|
+
* />
|
|
5587
|
+
* ```
|
|
5512
5588
|
*/
|
|
5513
5589
|
export declare const TextField: React_2.ForwardRefExoticComponent<TextFieldProps & React_2.RefAttributes<HTMLInputElement>>;
|
|
5514
5590
|
|
|
@@ -5533,6 +5609,12 @@ export declare interface TextFieldProps extends Omit<React_2.InputHTMLAttributes
|
|
|
5533
5609
|
leftLabel?: React_2.ReactNode;
|
|
5534
5610
|
/** Fixed, non-editable label pinned inside the right edge of the field — for a unit or suffix such as a currency code or domain. */
|
|
5535
5611
|
rightLabel?: React_2.ReactNode;
|
|
5612
|
+
/**
|
|
5613
|
+
* Trailing interactive element pinned to the right edge — typically a `Chip`
|
|
5614
|
+
* or `Button` (the "with button" field type). Reduces the right padding so the
|
|
5615
|
+
* control sits flush, and clicks on it do not steal focus from the input.
|
|
5616
|
+
*/
|
|
5617
|
+
action?: React_2.ReactNode;
|
|
5536
5618
|
/** Whether the text field stretches to fill its container width. @default false */
|
|
5537
5619
|
fullWidth?: boolean;
|
|
5538
5620
|
}
|
|
@@ -6053,6 +6135,82 @@ export declare const VipBadgeIcon: React_2.ForwardRefExoticComponent<React_2.SVG
|
|
|
6053
6135
|
className?: string;
|
|
6054
6136
|
} & React_2.RefAttributes<SVGSVGElement>>;
|
|
6055
6137
|
|
|
6138
|
+
/**
|
|
6139
|
+
* A voice-note audio player: a play/pause control, an amplitude waveform, and a
|
|
6140
|
+
* timestamp — for voice messages and audio attachments in a conversation.
|
|
6141
|
+
*
|
|
6142
|
+
* Two modes: pass `src` for a self-contained player that decodes the waveform,
|
|
6143
|
+
* plays real audio, tracks live progress and is seekable; or omit `src` and
|
|
6144
|
+
* drive it with `playing`/`progress` + `onPlayPause` for a presentational,
|
|
6145
|
+
* fully-controlled voice note. `flat` renders a compact dotted preview and
|
|
6146
|
+
* `negative` adapts it to dark message bubbles.
|
|
6147
|
+
*
|
|
6148
|
+
* @example
|
|
6149
|
+
* ```tsx
|
|
6150
|
+
* <VoiceNote src="https://example.com/note.mp3" duration={5} />
|
|
6151
|
+
* <VoiceNote time="0:05" progress={0.4} playing onPlayPause={toggle} />
|
|
6152
|
+
* ```
|
|
6153
|
+
*/
|
|
6154
|
+
export declare const VoiceNote: React_2.ForwardRefExoticComponent<VoiceNoteProps & React_2.RefAttributes<HTMLDivElement>>;
|
|
6155
|
+
|
|
6156
|
+
export declare interface VoiceNoteProps extends Omit<React_2.HTMLAttributes<HTMLDivElement>, "onPlay" | "onPause"> {
|
|
6157
|
+
/**
|
|
6158
|
+
* URL of the audio to play. When set, the component manages a real `<audio>`
|
|
6159
|
+
* element: it decodes the waveform, plays/pauses, tracks live progress and is
|
|
6160
|
+
* seekable — `waveform`/`progress` are derived automatically. Leave unset for
|
|
6161
|
+
* a presentational, fully-controlled voice note.
|
|
6162
|
+
*/
|
|
6163
|
+
src?: string;
|
|
6164
|
+
/** Fallback total duration (seconds), used until the media's metadata loads. */
|
|
6165
|
+
duration?: number;
|
|
6166
|
+
/** Amplitude values (0–1), one per bar. Ignored when `src` is set. Falls back to a built-in pattern. */
|
|
6167
|
+
waveform?: number[];
|
|
6168
|
+
/** Visual style; `flat` renders a simplified dotted preview. @default "default" */
|
|
6169
|
+
variant?: VoiceNoteVariant;
|
|
6170
|
+
/** Size preset. @default "default" */
|
|
6171
|
+
size?: VoiceNoteSize;
|
|
6172
|
+
/** Dark-surface treatment for use on message bubbles. @default false */
|
|
6173
|
+
negative?: boolean;
|
|
6174
|
+
/**
|
|
6175
|
+
* Playback progress (0–1) for the presentational mode. When set, the waveform
|
|
6176
|
+
* splits into played/unplayed bars (the "Listening" state). Ignored when `src`
|
|
6177
|
+
* is set (progress comes from the media element).
|
|
6178
|
+
*/
|
|
6179
|
+
progress?: number;
|
|
6180
|
+
/** Whether audio is playing (controlled) — toggles the play/pause icon. */
|
|
6181
|
+
playing?: boolean;
|
|
6182
|
+
/** Initial playing state (uncontrolled). @default false */
|
|
6183
|
+
defaultPlaying?: boolean;
|
|
6184
|
+
/** Called with the next playing state when the play/pause control is pressed. */
|
|
6185
|
+
onPlayPause?: (playing: boolean) => void;
|
|
6186
|
+
/** Called when audio playback reaches the end (only in `src` mode). */
|
|
6187
|
+
onEnded?: () => void;
|
|
6188
|
+
/** Timestamp or duration label, e.g. "0:05". Ignored when `src` is set (derived from the media). */
|
|
6189
|
+
time?: string;
|
|
6190
|
+
/** File name shown when the audio is an uploaded file rather than a voice note. */
|
|
6191
|
+
fileName?: string;
|
|
6192
|
+
/** Show the play/pause control. @default true */
|
|
6193
|
+
showControls?: boolean;
|
|
6194
|
+
/** Show the timestamp label. @default true */
|
|
6195
|
+
showTimestamp?: boolean;
|
|
6196
|
+
/** Show a remove button (calls {@link VoiceNoteProps.onRemove}). @default false */
|
|
6197
|
+
showRemove?: boolean;
|
|
6198
|
+
/** Called when the remove button is pressed. */
|
|
6199
|
+
onRemove?: () => void;
|
|
6200
|
+
/** Accessible name for the play/pause control. Defaults to "Play"/"Pause". */
|
|
6201
|
+
playButtonLabel?: string;
|
|
6202
|
+
/** Accessible name for the remove button. @default "Remove" */
|
|
6203
|
+
removeButtonLabel?: string;
|
|
6204
|
+
/** Accessible name for the whole player. @default "Voice note" */
|
|
6205
|
+
"aria-label"?: string;
|
|
6206
|
+
}
|
|
6207
|
+
|
|
6208
|
+
/** Size preset for the voice note. */
|
|
6209
|
+
export declare type VoiceNoteSize = "default" | "small";
|
|
6210
|
+
|
|
6211
|
+
/** Visual style of the waveform. */
|
|
6212
|
+
export declare type VoiceNoteVariant = "default" | "flat";
|
|
6213
|
+
|
|
6056
6214
|
/**
|
|
6057
6215
|
* Wallet icon. Renders at sizes 16, 24, or 32 px with outlined and filled variants.
|
|
6058
6216
|
*
|
package/dist/index.mjs
CHANGED
|
@@ -151,6 +151,7 @@ import { PlayIcon } from "./components/Icons/PlayIcon.mjs";
|
|
|
151
151
|
import { PlusIcon } from "./components/Icons/PlusIcon.mjs";
|
|
152
152
|
import { PrivacyIcon } from "./components/Icons/PrivacyIcon.mjs";
|
|
153
153
|
import { QueueIcon } from "./components/Icons/QueueIcon.mjs";
|
|
154
|
+
import { ReceiveMoneyIcon } from "./components/Icons/ReceiveMoneyIcon.mjs";
|
|
154
155
|
import { RepeatIcon } from "./components/Icons/RepeatIcon.mjs";
|
|
155
156
|
import { Reply2Icon } from "./components/Icons/Reply2Icon.mjs";
|
|
156
157
|
import { ReplyIcon } from "./components/Icons/ReplyIcon.mjs";
|
|
@@ -213,6 +214,7 @@ import { OnlineBlinkingIcon } from "./components/OnlineBlinkingIcon/OnlineBlinki
|
|
|
213
214
|
import { PageSelector } from "./components/PageSelector/PageSelector.mjs";
|
|
214
215
|
import { Pagination } from "./components/Pagination/Pagination.mjs";
|
|
215
216
|
import { PasswordField } from "./components/PasswordField/PasswordField.mjs";
|
|
217
|
+
import { PhoneField } from "./components/PhoneField/PhoneField.mjs";
|
|
216
218
|
import { Pill } from "./components/Pill/Pill.mjs";
|
|
217
219
|
import { ProfileOnlineStatus } from "./components/ProfileOnlineStatus/ProfileOnlineStatus.mjs";
|
|
218
220
|
import { ProfileStatus } from "./components/ProfileStatus/ProfileStatus.mjs";
|
|
@@ -248,6 +250,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./comp
|
|
|
248
250
|
import { UserDisplayName } from "./components/UserDisplayName/UserDisplayName.mjs";
|
|
249
251
|
import { UserHandle } from "./components/UserHandle/UserHandle.mjs";
|
|
250
252
|
import { UserItem } from "./components/UserItem/UserItem.mjs";
|
|
253
|
+
import { VoiceNote } from "./components/VoiceNote/VoiceNote.mjs";
|
|
251
254
|
import { cn } from "./utils/cn.mjs";
|
|
252
255
|
import { getInitials } from "./utils/getInitials.mjs";
|
|
253
256
|
import { useSuppressClickAfterDrag } from "./utils/useSuppressClickAfterDrag.mjs";
|
|
@@ -446,6 +449,7 @@ export {
|
|
|
446
449
|
PasswordField,
|
|
447
450
|
PauseIcon,
|
|
448
451
|
PeopleIcon,
|
|
452
|
+
PhoneField,
|
|
449
453
|
PhoneIcon,
|
|
450
454
|
PhoneOffIcon,
|
|
451
455
|
Pill,
|
|
@@ -463,6 +467,7 @@ export {
|
|
|
463
467
|
Radio,
|
|
464
468
|
RadioGroup,
|
|
465
469
|
RatingSummary,
|
|
470
|
+
ReceiveMoneyIcon,
|
|
466
471
|
RepeatIcon,
|
|
467
472
|
Reply2Icon,
|
|
468
473
|
ReplyIcon,
|
|
@@ -564,6 +569,7 @@ export {
|
|
|
564
569
|
VerifiedIcon,
|
|
565
570
|
VideoIcon,
|
|
566
571
|
VipBadgeIcon,
|
|
572
|
+
VoiceNote,
|
|
567
573
|
WalletIcon,
|
|
568
574
|
WarningIcon,
|
|
569
575
|
WarningTriangleIcon,
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","sources":[],"sourcesContent":[],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.mjs","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
|
package/dist/styles/base.css
CHANGED
|
@@ -13,6 +13,15 @@
|
|
|
13
13
|
scrollbar-gutter: stable;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/*
|
|
17
|
+
* Neutralise the browser's autofill highlight with the inset box-shadow
|
|
18
|
+
* hack. The shadow colour must match the input container background
|
|
19
|
+
* (TextField/TextArea render a transparent <input> inside a
|
|
20
|
+
* bg-inputs-inputs-primary wrapper) — a translucent tint here paints a
|
|
21
|
+
* visibly lighter band inside the field in dark mode. Note this rule
|
|
22
|
+
* cannot be overridden by unlayered !important declarations downstream:
|
|
23
|
+
* for !important the cascade reverses, so @layer base wins.
|
|
24
|
+
*/
|
|
16
25
|
input:-webkit-autofill,
|
|
17
26
|
input:-webkit-autofill:hover,
|
|
18
27
|
input:-webkit-autofill:focus,
|
|
@@ -26,8 +35,8 @@
|
|
|
26
35
|
textarea:autofill:hover,
|
|
27
36
|
textarea:autofill:focus {
|
|
28
37
|
-webkit-text-fill-color: var(--color-content-primary);
|
|
29
|
-
-webkit-box-shadow: inset 0 0 0 1000px var(--color-
|
|
30
|
-
box-shadow: inset 0 0 0 1000px var(--color-
|
|
38
|
+
-webkit-box-shadow: inset 0 0 0 1000px var(--color-inputs-inputs-primary) !important;
|
|
39
|
+
box-shadow: inset 0 0 0 1000px var(--color-inputs-inputs-primary) !important;
|
|
31
40
|
background-clip: padding-box !important;
|
|
32
41
|
transition: background-color 9999s ease-in-out 0s;
|
|
33
42
|
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
const RAW_PEAK_COUNT = 128;
|
|
3
|
+
function formatTime(seconds) {
|
|
4
|
+
if (seconds === void 0 || !Number.isFinite(seconds)) return "--:--";
|
|
5
|
+
const totalSeconds = Math.max(0, Math.floor(seconds));
|
|
6
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
7
|
+
const remainingSeconds = totalSeconds % 60;
|
|
8
|
+
return `${minutes}:${String(remainingSeconds).padStart(2, "0")}`;
|
|
9
|
+
}
|
|
10
|
+
function hashString(value) {
|
|
11
|
+
let hash = 2166136261;
|
|
12
|
+
for (let i = 0; i < value.length; i++) {
|
|
13
|
+
hash ^= value.charCodeAt(i);
|
|
14
|
+
hash = Math.imul(hash, 16777619);
|
|
15
|
+
}
|
|
16
|
+
return hash >>> 0;
|
|
17
|
+
}
|
|
18
|
+
function createSeededRandom(seed) {
|
|
19
|
+
let state = seed;
|
|
20
|
+
return () => {
|
|
21
|
+
state = state + 1831565813 | 0;
|
|
22
|
+
let t = Math.imul(state ^ state >>> 15, 1 | state);
|
|
23
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
24
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
function generateFallbackPeaks(src, count) {
|
|
28
|
+
const random = createSeededRandom(hashString(src));
|
|
29
|
+
return Array.from({ length: count }, () => 0.2 + random() * 0.8);
|
|
30
|
+
}
|
|
31
|
+
function computePeaksFromChannelData(channelData, count) {
|
|
32
|
+
const blockSize = Math.max(1, Math.floor(channelData.length / count));
|
|
33
|
+
const peaks = [];
|
|
34
|
+
for (let i = 0; i < count; i++) {
|
|
35
|
+
const start = i * blockSize;
|
|
36
|
+
let max = 0;
|
|
37
|
+
for (let j = 0; j < blockSize && start + j < channelData.length; j++) {
|
|
38
|
+
max = Math.max(max, Math.abs(channelData[start + j] ?? 0));
|
|
39
|
+
}
|
|
40
|
+
peaks.push(max);
|
|
41
|
+
}
|
|
42
|
+
return peaks;
|
|
43
|
+
}
|
|
44
|
+
function resamplePeaks(peaks, barCount) {
|
|
45
|
+
if (peaks.length === 0 || barCount <= 0) return [];
|
|
46
|
+
const step = peaks.length / barCount;
|
|
47
|
+
return Array.from(
|
|
48
|
+
{ length: barCount },
|
|
49
|
+
(_, i) => peaks[Math.min(peaks.length - 1, Math.floor(i * step))] ?? 0
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
async function decodeAudioPeaks(src, signal) {
|
|
53
|
+
const AudioContextCtor = window.AudioContext ?? window.webkitAudioContext;
|
|
54
|
+
if (!AudioContextCtor) throw new Error("WebAudio unsupported");
|
|
55
|
+
const response = await fetch(src, { signal });
|
|
56
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
57
|
+
const audioContext = new AudioContextCtor();
|
|
58
|
+
try {
|
|
59
|
+
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
|
60
|
+
return computePeaksFromChannelData(audioBuffer.getChannelData(0), RAW_PEAK_COUNT);
|
|
61
|
+
} finally {
|
|
62
|
+
if (audioContext.state !== "closed") audioContext.close().catch(() => {
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
export {
|
|
67
|
+
RAW_PEAK_COUNT,
|
|
68
|
+
computePeaksFromChannelData,
|
|
69
|
+
createSeededRandom,
|
|
70
|
+
decodeAudioPeaks,
|
|
71
|
+
formatTime,
|
|
72
|
+
generateFallbackPeaks,
|
|
73
|
+
hashString,
|
|
74
|
+
resamplePeaks
|
|
75
|
+
};
|
|
76
|
+
//# sourceMappingURL=audioWaveform.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"audioWaveform.mjs","sources":["../../src/utils/audioWaveform.ts"],"sourcesContent":["/** Number of amplitude samples decoded/kept internally, resampled to the rendered bar count. */\nexport const RAW_PEAK_COUNT = 128;\n\n/** Formats a duration in seconds as `m:ss`, or `--:--` when unknown. */\nexport function formatTime(seconds: number | undefined): string {\n if (seconds === undefined || !Number.isFinite(seconds)) return \"--:--\";\n const totalSeconds = Math.max(0, Math.floor(seconds));\n const minutes = Math.floor(totalSeconds / 60);\n const remainingSeconds = totalSeconds % 60;\n return `${minutes}:${String(remainingSeconds).padStart(2, \"0\")}`;\n}\n\n/** Deterministic 32-bit string hash (FNV-1a), used to seed the fallback waveform. */\nexport function hashString(value: string): number {\n let hash = 2166136261;\n for (let i = 0; i < value.length; i++) {\n hash ^= value.charCodeAt(i);\n hash = Math.imul(hash, 16777619);\n }\n return hash >>> 0;\n}\n\n/** Seeded PRNG (mulberry32) — deterministic across runs, unlike `Math.random`. */\nexport function createSeededRandom(seed: number): () => number {\n let state = seed;\n return () => {\n state = (state + 0x6d2b79f5) | 0;\n let t = Math.imul(state ^ (state >>> 15), 1 | state);\n t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;\n return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n };\n}\n\n/** Deterministic placeholder amplitudes used when audio can't be decoded (network/CORS/format failure). */\nexport function generateFallbackPeaks(src: string, count: number): number[] {\n const random = createSeededRandom(hashString(src));\n return Array.from({ length: count }, () => 0.2 + random() * 0.8);\n}\n\n/** Downsamples decoded PCM data to `count` peak (max-abs) amplitudes. */\nexport function computePeaksFromChannelData(channelData: Float32Array, count: number): number[] {\n const blockSize = Math.max(1, Math.floor(channelData.length / count));\n const peaks: number[] = [];\n for (let i = 0; i < count; i++) {\n const start = i * blockSize;\n let max = 0;\n for (let j = 0; j < blockSize && start + j < channelData.length; j++) {\n max = Math.max(max, Math.abs(channelData[start + j] ?? 0));\n }\n peaks.push(max);\n }\n return peaks;\n}\n\n/** Resamples a peaks array to `barCount` values (nearest-neighbour). */\nexport function resamplePeaks(peaks: number[], barCount: number): number[] {\n if (peaks.length === 0 || barCount <= 0) return [];\n const step = peaks.length / barCount;\n return Array.from(\n { length: barCount },\n (_, i) => peaks[Math.min(peaks.length - 1, Math.floor(i * step))] ?? 0,\n );\n}\n\n/** Decodes `src` via WebAudio and returns downsampled peak amplitudes. Throws on any failure. */\nexport async function decodeAudioPeaks(src: string, signal: AbortSignal): Promise<number[]> {\n const AudioContextCtor =\n window.AudioContext ??\n (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;\n if (!AudioContextCtor) throw new Error(\"WebAudio unsupported\");\n\n const response = await fetch(src, { signal });\n const arrayBuffer = await response.arrayBuffer();\n const audioContext = new AudioContextCtor();\n try {\n const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);\n return computePeaksFromChannelData(audioBuffer.getChannelData(0), RAW_PEAK_COUNT);\n } finally {\n if (audioContext.state !== \"closed\") audioContext.close().catch(() => {});\n }\n}\n"],"names":[],"mappings":";AACO,MAAM,iBAAiB;AAGvB,SAAS,WAAW,SAAqC;AAC9D,MAAI,YAAY,UAAa,CAAC,OAAO,SAAS,OAAO,EAAG,QAAO;AAC/D,QAAM,eAAe,KAAK,IAAI,GAAG,KAAK,MAAM,OAAO,CAAC;AACpD,QAAM,UAAU,KAAK,MAAM,eAAe,EAAE;AAC5C,QAAM,mBAAmB,eAAe;AACxC,SAAO,GAAG,OAAO,IAAI,OAAO,gBAAgB,EAAE,SAAS,GAAG,GAAG,CAAC;AAChE;AAGO,SAAS,WAAW,OAAuB;AAChD,MAAI,OAAO;AACX,WAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACrC,YAAQ,MAAM,WAAW,CAAC;AAC1B,WAAO,KAAK,KAAK,MAAM,QAAQ;AAAA,EACjC;AACA,SAAO,SAAS;AAClB;AAGO,SAAS,mBAAmB,MAA4B;AAC7D,MAAI,QAAQ;AACZ,SAAO,MAAM;AACX,YAAS,QAAQ,aAAc;AAC/B,QAAI,IAAI,KAAK,KAAK,QAAS,UAAU,IAAK,IAAI,KAAK;AACnD,QAAK,IAAI,KAAK,KAAK,IAAK,MAAM,GAAI,KAAK,CAAC,IAAK;AAC7C,aAAS,IAAK,MAAM,QAAS,KAAK;AAAA,EACpC;AACF;AAGO,SAAS,sBAAsB,KAAa,OAAyB;AAC1E,QAAM,SAAS,mBAAmB,WAAW,GAAG,CAAC;AACjD,SAAO,MAAM,KAAK,EAAE,QAAQ,MAAA,GAAS,MAAM,MAAM,OAAA,IAAW,GAAG;AACjE;AAGO,SAAS,4BAA4B,aAA2B,OAAyB;AAC9F,QAAM,YAAY,KAAK,IAAI,GAAG,KAAK,MAAM,YAAY,SAAS,KAAK,CAAC;AACpE,QAAM,QAAkB,CAAA;AACxB,WAAS,IAAI,GAAG,IAAI,OAAO,KAAK;AAC9B,UAAM,QAAQ,IAAI;AAClB,QAAI,MAAM;AACV,aAAS,IAAI,GAAG,IAAI,aAAa,QAAQ,IAAI,YAAY,QAAQ,KAAK;AACpE,YAAM,KAAK,IAAI,KAAK,KAAK,IAAI,YAAY,QAAQ,CAAC,KAAK,CAAC,CAAC;AAAA,IAC3D;AACA,UAAM,KAAK,GAAG;AAAA,EAChB;AACA,SAAO;AACT;AAGO,SAAS,cAAc,OAAiB,UAA4B;AACzE,MAAI,MAAM,WAAW,KAAK,YAAY,UAAU,CAAA;AAChD,QAAM,OAAO,MAAM,SAAS;AAC5B,SAAO,MAAM;AAAA,IACX,EAAE,QAAQ,SAAA;AAAA,IACV,CAAC,GAAG,MAAM,MAAM,KAAK,IAAI,MAAM,SAAS,GAAG,KAAK,MAAM,IAAI,IAAI,CAAC,CAAC,KAAK;AAAA,EAAA;AAEzE;AAGA,eAAsB,iBAAiB,KAAa,QAAwC;AAC1F,QAAM,mBACJ,OAAO,gBACN,OAAmE;AACtE,MAAI,CAAC,iBAAkB,OAAM,IAAI,MAAM,sBAAsB;AAE7D,QAAM,WAAW,MAAM,MAAM,KAAK,EAAE,QAAQ;AAC5C,QAAM,cAAc,MAAM,SAAS,YAAA;AACnC,QAAM,eAAe,IAAI,iBAAA;AACzB,MAAI;AACF,UAAM,cAAc,MAAM,aAAa,gBAAgB,WAAW;AAClE,WAAO,4BAA4B,YAAY,eAAe,CAAC,GAAG,cAAc;AAAA,EAClF,UAAA;AACE,QAAI,aAAa,UAAU,uBAAuB,MAAA,EAAQ,MAAM,MAAM;AAAA,IAAC,CAAC;AAAA,EAC1E;AACF;"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
function useAudioPlayback({
|
|
4
|
+
src,
|
|
5
|
+
duration,
|
|
6
|
+
playing: controlledPlaying,
|
|
7
|
+
defaultPlaying = false,
|
|
8
|
+
onPlay,
|
|
9
|
+
onPause,
|
|
10
|
+
onEnded
|
|
11
|
+
}) {
|
|
12
|
+
const audioRef = React.useRef(null);
|
|
13
|
+
const [internalPlaying, setInternalPlaying] = React.useState(defaultPlaying);
|
|
14
|
+
const isControlled = controlledPlaying !== void 0;
|
|
15
|
+
const isPlaying = isControlled ? controlledPlaying : internalPlaying;
|
|
16
|
+
const [currentTime, setCurrentTime] = React.useState(0);
|
|
17
|
+
const [mediaDuration, setMediaDuration] = React.useState(void 0);
|
|
18
|
+
const seekingRef = React.useRef(false);
|
|
19
|
+
const displayDuration = mediaDuration ?? duration;
|
|
20
|
+
const hasStarted = isPlaying || currentTime > 0;
|
|
21
|
+
const playingRef = React.useRef(isPlaying);
|
|
22
|
+
playingRef.current = isPlaying;
|
|
23
|
+
const isControlledRef = React.useRef(isControlled);
|
|
24
|
+
isControlledRef.current = isControlled;
|
|
25
|
+
const onPauseRef = React.useRef(onPause);
|
|
26
|
+
onPauseRef.current = onPause;
|
|
27
|
+
const isFirstSrcRenderRef = React.useRef(true);
|
|
28
|
+
React.useEffect(() => {
|
|
29
|
+
if (isFirstSrcRenderRef.current) {
|
|
30
|
+
isFirstSrcRenderRef.current = false;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
setCurrentTime(0);
|
|
34
|
+
setMediaDuration(void 0);
|
|
35
|
+
if (playingRef.current) {
|
|
36
|
+
if (!isControlledRef.current) setInternalPlaying(false);
|
|
37
|
+
onPauseRef.current?.();
|
|
38
|
+
}
|
|
39
|
+
}, [src]);
|
|
40
|
+
React.useEffect(() => {
|
|
41
|
+
const audio = audioRef.current;
|
|
42
|
+
if (!audio) return;
|
|
43
|
+
if (isPlaying) {
|
|
44
|
+
audio.play().catch(() => {
|
|
45
|
+
if (!isControlled) setInternalPlaying(false);
|
|
46
|
+
onPauseRef.current?.();
|
|
47
|
+
});
|
|
48
|
+
} else {
|
|
49
|
+
audio.pause();
|
|
50
|
+
}
|
|
51
|
+
}, [isPlaying, isControlled]);
|
|
52
|
+
const toggle = React.useCallback(() => {
|
|
53
|
+
const next = !playingRef.current;
|
|
54
|
+
if (!isControlledRef.current) setInternalPlaying(next);
|
|
55
|
+
if (next) onPlay?.();
|
|
56
|
+
else onPause?.();
|
|
57
|
+
}, [onPlay, onPause]);
|
|
58
|
+
const seekTo = React.useCallback(
|
|
59
|
+
(time) => {
|
|
60
|
+
if (displayDuration === void 0) return;
|
|
61
|
+
const clamped = Math.min(Math.max(time, 0), displayDuration);
|
|
62
|
+
setCurrentTime(clamped);
|
|
63
|
+
const audio = audioRef.current;
|
|
64
|
+
if (audio) audio.currentTime = clamped;
|
|
65
|
+
},
|
|
66
|
+
[displayDuration]
|
|
67
|
+
);
|
|
68
|
+
const setSeeking = React.useCallback((seeking) => {
|
|
69
|
+
seekingRef.current = seeking;
|
|
70
|
+
}, []);
|
|
71
|
+
const progress = displayDuration && displayDuration > 0 ? Math.min(Math.max(currentTime / displayDuration, 0), 1) : 0;
|
|
72
|
+
const audioHandlers = React.useMemo(
|
|
73
|
+
() => ({
|
|
74
|
+
onLoadedMetadata: (event) => {
|
|
75
|
+
const nativeDuration = event.currentTarget.duration;
|
|
76
|
+
setMediaDuration(Number.isFinite(nativeDuration) ? nativeDuration : void 0);
|
|
77
|
+
},
|
|
78
|
+
onTimeUpdate: (event) => {
|
|
79
|
+
if (seekingRef.current) return;
|
|
80
|
+
setCurrentTime(event.currentTarget.currentTime);
|
|
81
|
+
},
|
|
82
|
+
onEnded: () => {
|
|
83
|
+
setCurrentTime(0);
|
|
84
|
+
if (!isControlledRef.current) setInternalPlaying(false);
|
|
85
|
+
onEnded?.();
|
|
86
|
+
}
|
|
87
|
+
}),
|
|
88
|
+
[onEnded]
|
|
89
|
+
);
|
|
90
|
+
return {
|
|
91
|
+
audioRef,
|
|
92
|
+
isPlaying,
|
|
93
|
+
toggle,
|
|
94
|
+
currentTime,
|
|
95
|
+
displayDuration,
|
|
96
|
+
progress,
|
|
97
|
+
hasStarted,
|
|
98
|
+
seekTo,
|
|
99
|
+
setSeeking,
|
|
100
|
+
audioHandlers
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
export {
|
|
104
|
+
useAudioPlayback
|
|
105
|
+
};
|
|
106
|
+
//# sourceMappingURL=useAudioPlayback.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useAudioPlayback.mjs","sources":["../../src/utils/useAudioPlayback.ts"],"sourcesContent":["import * as React from \"react\";\n\n/** Options for {@link useAudioPlayback}. */\nexport interface UseAudioPlaybackOptions {\n /** URL of the audio to play. When absent, the hook stays idle (no media element attached). */\n src?: string;\n /** Fallback total duration (seconds) used until the media's own metadata loads. */\n duration?: number;\n /** Whether playback is active (controlled). */\n playing?: boolean;\n /** Initial playing state (uncontrolled). @default false */\n defaultPlaying?: boolean;\n /** Called when playback starts (via the toggle). */\n onPlay?: () => void;\n /** Called when playback pauses (via the toggle, a failed play(), or a src swap). */\n onPause?: () => void;\n /** Called when playback reaches the end of the media. */\n onEnded?: () => void;\n}\n\n/** Handlers to spread onto the backing `<audio>` element. */\nexport interface AudioElementHandlers {\n onLoadedMetadata: (event: React.SyntheticEvent<HTMLAudioElement>) => void;\n onTimeUpdate: (event: React.SyntheticEvent<HTMLAudioElement>) => void;\n onEnded: () => void;\n}\n\n/** Result of {@link useAudioPlayback}. */\nexport interface UseAudioPlaybackResult {\n /** Attach to the backing `<audio>` element. */\n audioRef: React.RefObject<HTMLAudioElement | null>;\n /** Current play/pause state (respects controlled `playing`). */\n isPlaying: boolean;\n /** Toggle play/pause, firing `onPlay`/`onPause`. */\n toggle: () => void;\n /** Current playback position in seconds. */\n currentTime: number;\n /** Media duration once known, else the `duration` fallback. */\n displayDuration: number | undefined;\n /** Playback progress, 0–1. */\n progress: number;\n /** Whether playback has begun (playing or scrubbed past 0). */\n hasStarted: boolean;\n /** Seek to a time in seconds (clamped to the duration). */\n seekTo: (time: number) => void;\n /** Suppress `timeupdate`-driven position updates while a manual scrub is in progress. */\n setSeeking: (seeking: boolean) => void;\n /** Handlers to spread onto the `<audio>` element. */\n audioHandlers: AudioElementHandlers;\n}\n\n/**\n * Drives a single `<audio>` element: controlled/uncontrolled play/pause, live\n * position + duration, seeking, and src-swap resets. Extracted from\n * {@link AudioPlayer} so waveform players (e.g. {@link VoiceNote}) can share the\n * same playback engine without duplicating the effect wiring.\n */\nexport function useAudioPlayback({\n src,\n duration,\n playing: controlledPlaying,\n defaultPlaying = false,\n onPlay,\n onPause,\n onEnded,\n}: UseAudioPlaybackOptions): UseAudioPlaybackResult {\n const audioRef = React.useRef<HTMLAudioElement>(null);\n\n const [internalPlaying, setInternalPlaying] = React.useState(defaultPlaying);\n const isControlled = controlledPlaying !== undefined;\n const isPlaying = isControlled ? controlledPlaying : internalPlaying;\n\n const [currentTime, setCurrentTime] = React.useState(0);\n const [mediaDuration, setMediaDuration] = React.useState<number | undefined>(undefined);\n const seekingRef = React.useRef(false);\n\n const displayDuration = mediaDuration ?? duration;\n const hasStarted = isPlaying || currentTime > 0;\n\n // Latest-value refs so the src-change effect can react to a src swap without\n // re-running whenever playing/isControlled/onPause change.\n const playingRef = React.useRef(isPlaying);\n playingRef.current = isPlaying;\n const isControlledRef = React.useRef(isControlled);\n isControlledRef.current = isControlled;\n const onPauseRef = React.useRef(onPause);\n onPauseRef.current = onPause;\n\n // Reset transient playback state on source change; skip the initial mount and,\n // if playback was active, stop it (the browser drops playback on a src swap).\n const isFirstSrcRenderRef = React.useRef(true);\n // biome-ignore lint/correctness/useExhaustiveDependencies: intentionally keyed on src to reset transient state on a source swap\n React.useEffect(() => {\n if (isFirstSrcRenderRef.current) {\n isFirstSrcRenderRef.current = false;\n return;\n }\n setCurrentTime(0);\n setMediaDuration(undefined);\n if (playingRef.current) {\n if (!isControlledRef.current) setInternalPlaying(false);\n onPauseRef.current?.();\n }\n }, [src]);\n\n // Sync the controlled/uncontrolled playing state to the media element.\n React.useEffect(() => {\n const audio = audioRef.current;\n if (!audio) return;\n if (isPlaying) {\n audio.play().catch(() => {\n if (!isControlled) setInternalPlaying(false);\n onPauseRef.current?.();\n });\n } else {\n audio.pause();\n }\n }, [isPlaying, isControlled]);\n\n const toggle = React.useCallback(() => {\n const next = !playingRef.current;\n if (!isControlledRef.current) setInternalPlaying(next);\n if (next) onPlay?.();\n else onPause?.();\n }, [onPlay, onPause]);\n\n const seekTo = React.useCallback(\n (time: number) => {\n if (displayDuration === undefined) return;\n const clamped = Math.min(Math.max(time, 0), displayDuration);\n setCurrentTime(clamped);\n const audio = audioRef.current;\n if (audio) audio.currentTime = clamped;\n },\n [displayDuration],\n );\n\n const setSeeking = React.useCallback((seeking: boolean) => {\n seekingRef.current = seeking;\n }, []);\n\n const progress =\n displayDuration && displayDuration > 0\n ? Math.min(Math.max(currentTime / displayDuration, 0), 1)\n : 0;\n\n const audioHandlers = React.useMemo<AudioElementHandlers>(\n () => ({\n onLoadedMetadata: (event) => {\n const nativeDuration = event.currentTarget.duration;\n setMediaDuration(Number.isFinite(nativeDuration) ? nativeDuration : undefined);\n },\n onTimeUpdate: (event) => {\n if (seekingRef.current) return;\n setCurrentTime(event.currentTarget.currentTime);\n },\n onEnded: () => {\n setCurrentTime(0);\n if (!isControlledRef.current) setInternalPlaying(false);\n onEnded?.();\n },\n }),\n [onEnded],\n );\n\n return {\n audioRef,\n isPlaying,\n toggle,\n currentTime,\n displayDuration,\n progress,\n hasStarted,\n seekTo,\n setSeeking,\n audioHandlers,\n };\n}\n"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
function useFittedBarCount(trackRef, { barWidthPx, gapPx, fallback }) {
|
|
4
|
+
const [barCount, setBarCount] = React.useState(fallback);
|
|
5
|
+
React.useEffect(() => {
|
|
6
|
+
const track = trackRef.current;
|
|
7
|
+
if (!track) return;
|
|
8
|
+
const updateFromWidth = (width) => {
|
|
9
|
+
if (width <= 0) return;
|
|
10
|
+
setBarCount(Math.max(1, Math.floor((width + gapPx) / (barWidthPx + gapPx))));
|
|
11
|
+
};
|
|
12
|
+
updateFromWidth(track.getBoundingClientRect().width);
|
|
13
|
+
const observer = new ResizeObserver((entries) => {
|
|
14
|
+
const entry = entries[0];
|
|
15
|
+
if (entry) updateFromWidth(entry.contentRect.width);
|
|
16
|
+
});
|
|
17
|
+
observer.observe(track);
|
|
18
|
+
return () => observer.disconnect();
|
|
19
|
+
}, [trackRef, barWidthPx, gapPx]);
|
|
20
|
+
return barCount;
|
|
21
|
+
}
|
|
22
|
+
export {
|
|
23
|
+
useFittedBarCount
|
|
24
|
+
};
|
|
25
|
+
//# sourceMappingURL=useFittedBarCount.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useFittedBarCount.mjs","sources":["../../src/utils/useFittedBarCount.ts"],"sourcesContent":["import * as React from \"react\";\n\n/** Options for {@link useFittedBarCount}. */\nexport interface UseFittedBarCountOptions {\n /** Rendered width of a single bar, in pixels. */\n barWidthPx: number;\n /** Gap between bars, in pixels. */\n gapPx: number;\n /** Bar count used before the track is measured (SSR, jsdom, or zero-width layout). */\n fallback: number;\n}\n\n/**\n * Measures a waveform track and returns how many fixed-width bars fit across it,\n * updating on resize. Lets callers resample their peaks to the *visible* bar\n * count so overflow bars aren't rendered (and, crucially, aren't counted when\n * computing playback progress).\n */\nexport function useFittedBarCount(\n trackRef: React.RefObject<HTMLElement | null>,\n { barWidthPx, gapPx, fallback }: UseFittedBarCountOptions,\n): number {\n const [barCount, setBarCount] = React.useState(fallback);\n\n React.useEffect(() => {\n const track = trackRef.current;\n if (!track) return;\n\n const updateFromWidth = (width: number) => {\n if (width <= 0) return;\n setBarCount(Math.max(1, Math.floor((width + gapPx) / (barWidthPx + gapPx))));\n };\n\n updateFromWidth(track.getBoundingClientRect().width);\n\n const observer = new ResizeObserver((entries) => {\n const entry = entries[0];\n if (entry) updateFromWidth(entry.contentRect.width);\n });\n observer.observe(track);\n return () => observer.disconnect();\n }, [trackRef, barWidthPx, gapPx]);\n\n return barCount;\n}\n"],"names":[],"mappings":";;AAkBO,SAAS,kBACd,UACA,EAAE,YAAY,OAAO,YACb;AACR,QAAM,CAAC,UAAU,WAAW,IAAI,MAAM,SAAS,QAAQ;AAEvD,QAAM,UAAU,MAAM;AACpB,UAAM,QAAQ,SAAS;AACvB,QAAI,CAAC,MAAO;AAEZ,UAAM,kBAAkB,CAAC,UAAkB;AACzC,UAAI,SAAS,EAAG;AAChB,kBAAY,KAAK,IAAI,GAAG,KAAK,OAAO,QAAQ,UAAU,aAAa,MAAM,CAAC,CAAC;AAAA,IAC7E;AAEA,oBAAgB,MAAM,sBAAA,EAAwB,KAAK;AAEnD,UAAM,WAAW,IAAI,eAAe,CAAC,YAAY;AAC/C,YAAM,QAAQ,QAAQ,CAAC;AACvB,UAAI,MAAO,iBAAgB,MAAM,YAAY,KAAK;AAAA,IACpD,CAAC;AACD,aAAS,QAAQ,KAAK;AACtB,WAAO,MAAM,SAAS,WAAA;AAAA,EACxB,GAAG,CAAC,UAAU,YAAY,KAAK,CAAC;AAEhC,SAAO;AACT;"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { RAW_PEAK_COUNT, generateFallbackPeaks, decodeAudioPeaks } from "./audioWaveform.mjs";
|
|
4
|
+
function useWaveformPeaks(src, count = RAW_PEAK_COUNT) {
|
|
5
|
+
const [peaks, setPeaks] = React.useState(
|
|
6
|
+
() => src ? generateFallbackPeaks(src, count) : []
|
|
7
|
+
);
|
|
8
|
+
React.useEffect(() => {
|
|
9
|
+
if (!src) {
|
|
10
|
+
setPeaks([]);
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
setPeaks(generateFallbackPeaks(src, count));
|
|
14
|
+
let cancelled = false;
|
|
15
|
+
const abortController = new AbortController();
|
|
16
|
+
decodeAudioPeaks(src, abortController.signal).then((decoded) => {
|
|
17
|
+
if (!cancelled) setPeaks(decoded);
|
|
18
|
+
}).catch(() => {
|
|
19
|
+
if (!cancelled) setPeaks(generateFallbackPeaks(src, count));
|
|
20
|
+
});
|
|
21
|
+
return () => {
|
|
22
|
+
cancelled = true;
|
|
23
|
+
abortController.abort();
|
|
24
|
+
};
|
|
25
|
+
}, [src, count]);
|
|
26
|
+
return peaks;
|
|
27
|
+
}
|
|
28
|
+
export {
|
|
29
|
+
useWaveformPeaks
|
|
30
|
+
};
|
|
31
|
+
//# sourceMappingURL=useWaveformPeaks.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useWaveformPeaks.mjs","sources":["../../src/utils/useWaveformPeaks.ts"],"sourcesContent":["import * as React from \"react\";\nimport { decodeAudioPeaks, generateFallbackPeaks, RAW_PEAK_COUNT } from \"./audioWaveform\";\n\n/**\n * Decodes `src` into waveform peak amplitudes (0–1). Seeds a deterministic\n * fallback immediately (stable across renders/SSR) and swaps in the real\n * decoded peaks once WebAudio resolves, falling back again on any failure.\n * Returns an empty array when `src` is absent.\n */\nexport function useWaveformPeaks(\n src: string | undefined,\n count: number = RAW_PEAK_COUNT,\n): number[] {\n const [peaks, setPeaks] = React.useState<number[]>(() =>\n src ? generateFallbackPeaks(src, count) : [],\n );\n\n React.useEffect(() => {\n if (!src) {\n setPeaks([]);\n return;\n }\n\n setPeaks(generateFallbackPeaks(src, count));\n\n let cancelled = false;\n const abortController = new AbortController();\n decodeAudioPeaks(src, abortController.signal)\n .then((decoded) => {\n if (!cancelled) setPeaks(decoded);\n })\n .catch(() => {\n if (!cancelled) setPeaks(generateFallbackPeaks(src, count));\n });\n\n return () => {\n cancelled = true;\n abortController.abort();\n };\n }, [src, count]);\n\n return peaks;\n}\n"],"names":[],"mappings":";;;AASO,SAAS,iBACd,KACA,QAAgB,gBACN;AACV,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM;AAAA,IAAmB,MACjD,MAAM,sBAAsB,KAAK,KAAK,IAAI,CAAA;AAAA,EAAC;AAG7C,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,KAAK;AACR,eAAS,CAAA,CAAE;AACX;AAAA,IACF;AAEA,aAAS,sBAAsB,KAAK,KAAK,CAAC;AAE1C,QAAI,YAAY;AAChB,UAAM,kBAAkB,IAAI,gBAAA;AAC5B,qBAAiB,KAAK,gBAAgB,MAAM,EACzC,KAAK,CAAC,YAAY;AACjB,UAAI,CAAC,UAAW,UAAS,OAAO;AAAA,IAClC,CAAC,EACA,MAAM,MAAM;AACX,UAAI,CAAC,UAAW,UAAS,sBAAsB,KAAK,KAAK,CAAC;AAAA,IAC5D,CAAC;AAEH,WAAO,MAAM;AACX,kBAAY;AACZ,sBAAgB,MAAA;AAAA,IAClB;AAAA,EACF,GAAG,CAAC,KAAK,KAAK,CAAC;AAEf,SAAO;AACT;"}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { formatTime } from "./audioWaveform.mjs";
|
|
4
|
+
const SEEK_STEP_SECONDS = 1;
|
|
5
|
+
const SEEK_STEP_LARGE_SECONDS = 5;
|
|
6
|
+
const STEP_BY_KEY = {
|
|
7
|
+
ArrowRight: SEEK_STEP_SECONDS,
|
|
8
|
+
ArrowUp: SEEK_STEP_SECONDS,
|
|
9
|
+
ArrowLeft: -SEEK_STEP_SECONDS,
|
|
10
|
+
ArrowDown: -SEEK_STEP_SECONDS,
|
|
11
|
+
PageUp: SEEK_STEP_LARGE_SECONDS,
|
|
12
|
+
PageDown: -SEEK_STEP_LARGE_SECONDS
|
|
13
|
+
};
|
|
14
|
+
function useWaveformSeek({
|
|
15
|
+
trackRef,
|
|
16
|
+
displayDuration,
|
|
17
|
+
currentTime,
|
|
18
|
+
seekTo,
|
|
19
|
+
setSeeking,
|
|
20
|
+
label = "Seek"
|
|
21
|
+
}) {
|
|
22
|
+
const [isDragging, setIsDragging] = React.useState(false);
|
|
23
|
+
const seekFromClientX = React.useCallback(
|
|
24
|
+
(clientX) => {
|
|
25
|
+
const track = trackRef.current;
|
|
26
|
+
if (!track || displayDuration === void 0) return;
|
|
27
|
+
const rect = track.getBoundingClientRect();
|
|
28
|
+
if (rect.width <= 0) return;
|
|
29
|
+
const fraction = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1);
|
|
30
|
+
seekTo(fraction * displayDuration);
|
|
31
|
+
},
|
|
32
|
+
[trackRef, displayDuration, seekTo]
|
|
33
|
+
);
|
|
34
|
+
React.useEffect(() => {
|
|
35
|
+
if (!isDragging) return;
|
|
36
|
+
const handleMove = (event) => seekFromClientX(event.clientX);
|
|
37
|
+
const handleUp = () => {
|
|
38
|
+
setIsDragging(false);
|
|
39
|
+
setSeeking(false);
|
|
40
|
+
};
|
|
41
|
+
window.addEventListener("pointermove", handleMove);
|
|
42
|
+
window.addEventListener("pointerup", handleUp);
|
|
43
|
+
return () => {
|
|
44
|
+
window.removeEventListener("pointermove", handleMove);
|
|
45
|
+
window.removeEventListener("pointerup", handleUp);
|
|
46
|
+
};
|
|
47
|
+
}, [isDragging, seekFromClientX, setSeeking]);
|
|
48
|
+
const onPointerDown = (event) => {
|
|
49
|
+
seekFromClientX(event.clientX);
|
|
50
|
+
setSeeking(true);
|
|
51
|
+
setIsDragging(true);
|
|
52
|
+
};
|
|
53
|
+
const onKeyDown = (event) => {
|
|
54
|
+
if (displayDuration === void 0) return;
|
|
55
|
+
const delta = STEP_BY_KEY[event.key];
|
|
56
|
+
if (delta !== void 0) {
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
seekTo(currentTime + delta);
|
|
59
|
+
} else if (event.key === "Home") {
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
seekTo(0);
|
|
62
|
+
} else if (event.key === "End") {
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
seekTo(displayDuration);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
return {
|
|
68
|
+
role: "slider",
|
|
69
|
+
tabIndex: 0,
|
|
70
|
+
"aria-label": label,
|
|
71
|
+
"aria-orientation": "horizontal",
|
|
72
|
+
"aria-valuemin": 0,
|
|
73
|
+
"aria-valuemax": displayDuration,
|
|
74
|
+
"aria-valuenow": Math.round(currentTime),
|
|
75
|
+
"aria-valuetext": displayDuration !== void 0 ? `${formatTime(currentTime)} of ${formatTime(displayDuration)}` : void 0,
|
|
76
|
+
onPointerDown,
|
|
77
|
+
onKeyDown
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export {
|
|
81
|
+
useWaveformSeek
|
|
82
|
+
};
|
|
83
|
+
//# sourceMappingURL=useWaveformSeek.mjs.map
|