trix-ui 0.2.3 → 0.2.6
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
CHANGED
|
@@ -27,11 +27,14 @@ export type MusicPlayerCardProps = {
|
|
|
27
27
|
/** Optional playlist */
|
|
28
28
|
tracks?: MusicTrack[];
|
|
29
29
|
|
|
30
|
-
/** Optional File object for local playback */
|
|
31
|
-
audioFile?: File;
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
|
|
30
|
+
/** Optional File object for local playback */
|
|
31
|
+
audioFile?: File;
|
|
32
|
+
|
|
33
|
+
/** Optional list of local files for playlist playback */
|
|
34
|
+
audioFiles?: File[];
|
|
35
|
+
|
|
36
|
+
/** Initial index when using tracks (default 0) */
|
|
37
|
+
initialTrackIndex?: number;
|
|
35
38
|
|
|
36
39
|
/** Auto play on load (default false) */
|
|
37
40
|
autoPlay?: boolean;
|
|
@@ -52,9 +55,10 @@ export type MusicPlayerCardProps = {
|
|
|
52
55
|
|
|
53
56
|
onShuffleChange?: (enabled: boolean) => void;
|
|
54
57
|
onRepeatChange?: (enabled: boolean) => void;
|
|
55
|
-
onPlayStateChange?: (playing: boolean) => void;
|
|
56
|
-
onTrackChange?: (track: MusicTrack, index: number) => void;
|
|
57
|
-
onFileSelect?: (file: File) => void;
|
|
58
|
+
onPlayStateChange?: (playing: boolean) => void;
|
|
59
|
+
onTrackChange?: (track: MusicTrack, index: number) => void;
|
|
60
|
+
onFileSelect?: (file: File) => void;
|
|
61
|
+
onFilesSelect?: (files: File[]) => void;
|
|
58
62
|
|
|
59
63
|
/** Toggle states */
|
|
60
64
|
isPlaying?: boolean;
|
|
@@ -79,32 +83,38 @@ export type MusicPlayerCardProps = {
|
|
|
79
83
|
|
|
80
84
|
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n));
|
|
81
85
|
|
|
82
|
-
function formatTime(value: number): string {
|
|
83
|
-
if (!Number.isFinite(value) || value <= 0) return "0:00";
|
|
84
|
-
const minutes = Math.floor(value / 60);
|
|
85
|
-
const seconds = Math.floor(value % 60);
|
|
86
|
-
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function
|
|
90
|
-
const [
|
|
91
|
-
|
|
92
|
-
React.useEffect(() => {
|
|
93
|
-
if (!
|
|
94
|
-
|
|
95
|
-
return undefined;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return () => {
|
|
102
|
-
URL.revokeObjectURL(
|
|
103
|
-
};
|
|
104
|
-
}, [
|
|
105
|
-
|
|
106
|
-
return
|
|
107
|
-
}
|
|
86
|
+
function formatTime(value: number): string {
|
|
87
|
+
if (!Number.isFinite(value) || value <= 0) return "0:00";
|
|
88
|
+
const minutes = Math.floor(value / 60);
|
|
89
|
+
const seconds = Math.floor(value % 60);
|
|
90
|
+
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function useObjectUrls(files: File[]): string[] {
|
|
94
|
+
const [urls, setUrls] = React.useState<string[]>([]);
|
|
95
|
+
|
|
96
|
+
React.useEffect(() => {
|
|
97
|
+
if (!files.length) {
|
|
98
|
+
setUrls([]);
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const nextUrls = files.map((file) => URL.createObjectURL(file));
|
|
103
|
+
setUrls(nextUrls);
|
|
104
|
+
|
|
105
|
+
return () => {
|
|
106
|
+
nextUrls.forEach((url) => URL.revokeObjectURL(url));
|
|
107
|
+
};
|
|
108
|
+
}, [files]);
|
|
109
|
+
|
|
110
|
+
return urls;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function getFileTitle(file: File): string {
|
|
114
|
+
const name = file.name?.trim() || "Untitled";
|
|
115
|
+
const trimmed = name.replace(/\.[^/.]+$/, "");
|
|
116
|
+
return trimmed || name;
|
|
117
|
+
}
|
|
108
118
|
|
|
109
119
|
const MusicPlayerCardBase = React.forwardRef<HTMLDivElement, MusicPlayerCardProps>(
|
|
110
120
|
(
|
|
@@ -113,12 +123,13 @@ const MusicPlayerCardBase = React.forwardRef<HTMLDivElement, MusicPlayerCardProp
|
|
|
113
123
|
title,
|
|
114
124
|
subtitle = "Unknown Artist",
|
|
115
125
|
|
|
116
|
-
audioSrc,
|
|
117
|
-
tracks,
|
|
118
|
-
audioFile,
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
126
|
+
audioSrc,
|
|
127
|
+
tracks,
|
|
128
|
+
audioFile,
|
|
129
|
+
audioFiles,
|
|
130
|
+
initialTrackIndex = 0,
|
|
131
|
+
autoPlay = false,
|
|
132
|
+
showFileInput = false,
|
|
122
133
|
|
|
123
134
|
progress,
|
|
124
135
|
currentTime,
|
|
@@ -131,10 +142,11 @@ const MusicPlayerCardBase = React.forwardRef<HTMLDivElement, MusicPlayerCardProp
|
|
|
131
142
|
onRepeat,
|
|
132
143
|
|
|
133
144
|
onShuffleChange,
|
|
134
|
-
onRepeatChange,
|
|
135
|
-
onPlayStateChange,
|
|
136
|
-
onTrackChange,
|
|
137
|
-
onFileSelect,
|
|
145
|
+
onRepeatChange,
|
|
146
|
+
onPlayStateChange,
|
|
147
|
+
onTrackChange,
|
|
148
|
+
onFileSelect,
|
|
149
|
+
onFilesSelect,
|
|
138
150
|
|
|
139
151
|
isPlaying,
|
|
140
152
|
isShuffleOn,
|
|
@@ -159,43 +171,70 @@ const MusicPlayerCardBase = React.forwardRef<HTMLDivElement, MusicPlayerCardProp
|
|
|
159
171
|
ref
|
|
160
172
|
) => {
|
|
161
173
|
const [internalPlaying, setInternalPlaying] = React.useState(false);
|
|
162
|
-
const [internalShuffle, setInternalShuffle] = React.useState(false);
|
|
163
|
-
const [internalRepeat, setInternalRepeat] = React.useState(false);
|
|
164
|
-
const [trackIndex, setTrackIndex] = React.useState(
|
|
165
|
-
clamp(initialTrackIndex, 0, Math.max((tracks?.length ?? 1) - 1, 0))
|
|
166
|
-
);
|
|
167
|
-
const [
|
|
168
|
-
const [currentTimeSec, setCurrentTimeSec] = React.useState(0);
|
|
169
|
-
const [durationSec, setDurationSec] = React.useState(0);
|
|
170
|
-
const
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
};
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
const
|
|
198
|
-
|
|
174
|
+
const [internalShuffle, setInternalShuffle] = React.useState(false);
|
|
175
|
+
const [internalRepeat, setInternalRepeat] = React.useState(false);
|
|
176
|
+
const [trackIndex, setTrackIndex] = React.useState(
|
|
177
|
+
clamp(initialTrackIndex, 0, Math.max((tracks?.length ?? 1) - 1, 0))
|
|
178
|
+
);
|
|
179
|
+
const [uploadedFiles, setUploadedFiles] = React.useState<File[]>([]);
|
|
180
|
+
const [currentTimeSec, setCurrentTimeSec] = React.useState(0);
|
|
181
|
+
const [durationSec, setDurationSec] = React.useState(0);
|
|
182
|
+
const [playbackError, setPlaybackError] = React.useState<string | null>(null);
|
|
183
|
+
const audioRef = React.useRef<HTMLAudioElement | null>(null);
|
|
184
|
+
const shuffleHistoryRef = React.useRef<number[]>([]);
|
|
185
|
+
|
|
186
|
+
const isShuffle = isShuffleOn ?? internalShuffle;
|
|
187
|
+
const isRepeat = isRepeatOn ?? internalRepeat;
|
|
188
|
+
const playing = isPlaying ?? internalPlaying;
|
|
189
|
+
|
|
190
|
+
const fileList = React.useMemo<File[]>(() => {
|
|
191
|
+
if (audioFiles?.length) return audioFiles;
|
|
192
|
+
if (uploadedFiles.length) return uploadedFiles;
|
|
193
|
+
if (audioFile) return [audioFile];
|
|
194
|
+
return [];
|
|
195
|
+
}, [audioFile, audioFiles, uploadedFiles]);
|
|
196
|
+
|
|
197
|
+
const fileUrls = useObjectUrls(fileList);
|
|
198
|
+
const fileTracks = React.useMemo<MusicTrack[]>(() => {
|
|
199
|
+
if (!fileList.length) return [];
|
|
200
|
+
return fileList.map((file, index) => ({
|
|
201
|
+
src: fileUrls[index] ?? "",
|
|
202
|
+
title: getFileTitle(file),
|
|
203
|
+
subtitle: "Local file",
|
|
204
|
+
imageSrc,
|
|
205
|
+
}));
|
|
206
|
+
}, [fileList, fileUrls, imageSrc]);
|
|
207
|
+
|
|
208
|
+
const playlist = fileTracks.length ? fileTracks : tracks?.length ? tracks : [];
|
|
209
|
+
const fallbackTrack: MusicTrack = {
|
|
210
|
+
src: audioSrc ?? "",
|
|
211
|
+
title,
|
|
212
|
+
subtitle,
|
|
213
|
+
imageSrc,
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const activeTrack = playlist.length
|
|
217
|
+
? playlist[clamp(trackIndex, 0, playlist.length - 1)]
|
|
218
|
+
: fallbackTrack;
|
|
219
|
+
|
|
220
|
+
const resolvedSrc = activeTrack.src;
|
|
221
|
+
const isPlayable = Boolean(resolvedSrc);
|
|
222
|
+
const hasExplicitSource =
|
|
223
|
+
Boolean(audioSrc) ||
|
|
224
|
+
Boolean(audioFile) ||
|
|
225
|
+
Boolean(fileList.length) ||
|
|
226
|
+
Boolean(playlist.length && activeTrack.src);
|
|
227
|
+
const missingSourceMessage =
|
|
228
|
+
!resolvedSrc && hasExplicitSource && fileList.length === 0
|
|
229
|
+
? "Audio file not found."
|
|
230
|
+
: null;
|
|
231
|
+
|
|
232
|
+
const derivedProgress =
|
|
233
|
+
durationSec > 0 ? clamp((currentTimeSec / durationSec) * 100, 0, 100) : 0;
|
|
234
|
+
const pct = clamp(progress ?? derivedProgress, 0, 100);
|
|
235
|
+
const resolvedCurrentTime = currentTime ?? formatTime(currentTimeSec);
|
|
236
|
+
const resolvedTotalTime = totalTime ?? formatTime(durationSec);
|
|
237
|
+
const errorMessage = playbackError ?? missingSourceMessage;
|
|
199
238
|
|
|
200
239
|
const requestPlay = React.useCallback(
|
|
201
240
|
async (nextPlaying: boolean): Promise<void> => {
|
|
@@ -210,16 +249,21 @@ const MusicPlayerCardBase = React.forwardRef<HTMLDivElement, MusicPlayerCardProp
|
|
|
210
249
|
} else {
|
|
211
250
|
audio.pause();
|
|
212
251
|
}
|
|
213
|
-
} catch {
|
|
214
|
-
if (isPlaying === undefined) {
|
|
215
|
-
setInternalPlaying(false);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
252
|
+
} catch {
|
|
253
|
+
if (isPlaying === undefined) {
|
|
254
|
+
setInternalPlaying(false);
|
|
255
|
+
}
|
|
256
|
+
setPlaybackError("Audio file is not playable.");
|
|
257
|
+
onPlayStateChange?.(false);
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
[isPlaying, onPlayStateChange, resolvedSrc]
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
React.useEffect(() => {
|
|
264
|
+
setPlaybackError(null);
|
|
265
|
+
}, [resolvedSrc]);
|
|
266
|
+
|
|
223
267
|
React.useEffect(() => {
|
|
224
268
|
const audio = audioRef.current;
|
|
225
269
|
if (!audio) return;
|
|
@@ -398,16 +442,19 @@ const MusicPlayerCardBase = React.forwardRef<HTMLDivElement, MusicPlayerCardProp
|
|
|
398
442
|
onPlayStateChange?.(false);
|
|
399
443
|
};
|
|
400
444
|
|
|
401
|
-
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
|
402
|
-
const
|
|
403
|
-
if (!
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
445
|
+
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>): void => {
|
|
446
|
+
const files = Array.from(event.target.files ?? []);
|
|
447
|
+
if (!files.length) return;
|
|
448
|
+
setUploadedFiles(files);
|
|
449
|
+
setTrackIndex(0);
|
|
450
|
+
onFileSelect?.(files[0]);
|
|
451
|
+
onFilesSelect?.(files);
|
|
452
|
+
if (isPlaying === undefined) {
|
|
453
|
+
setInternalPlaying(true);
|
|
454
|
+
}
|
|
455
|
+
onPlayStateChange?.(true);
|
|
456
|
+
event.currentTarget.value = "";
|
|
457
|
+
};
|
|
411
458
|
|
|
412
459
|
return (
|
|
413
460
|
<div
|
|
@@ -420,19 +467,27 @@ const MusicPlayerCardBase = React.forwardRef<HTMLDivElement, MusicPlayerCardProp
|
|
|
420
467
|
)}
|
|
421
468
|
{...props}
|
|
422
469
|
>
|
|
423
|
-
<audio
|
|
424
|
-
ref={audioRef}
|
|
425
|
-
preload="metadata"
|
|
426
|
-
onTimeUpdate={(event) => {
|
|
427
|
-
const audio = event.currentTarget;
|
|
428
|
-
setCurrentTimeSec(audio.currentTime);
|
|
429
|
-
}}
|
|
430
|
-
onLoadedMetadata={(event) => {
|
|
431
|
-
const audio = event.currentTarget;
|
|
432
|
-
setDurationSec(audio.duration || 0);
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
470
|
+
<audio
|
|
471
|
+
ref={audioRef}
|
|
472
|
+
preload="metadata"
|
|
473
|
+
onTimeUpdate={(event) => {
|
|
474
|
+
const audio = event.currentTarget;
|
|
475
|
+
setCurrentTimeSec(audio.currentTime);
|
|
476
|
+
}}
|
|
477
|
+
onLoadedMetadata={(event) => {
|
|
478
|
+
const audio = event.currentTarget;
|
|
479
|
+
setDurationSec(audio.duration || 0);
|
|
480
|
+
setPlaybackError(null);
|
|
481
|
+
}}
|
|
482
|
+
onError={() => {
|
|
483
|
+
setPlaybackError("Audio file is not playable.");
|
|
484
|
+
if (isPlaying === undefined) {
|
|
485
|
+
setInternalPlaying(false);
|
|
486
|
+
}
|
|
487
|
+
onPlayStateChange?.(false);
|
|
488
|
+
}}
|
|
489
|
+
onEnded={handleEnded}
|
|
490
|
+
/>
|
|
436
491
|
{/* Album Art */}
|
|
437
492
|
<div
|
|
438
493
|
className={cn(
|
|
@@ -457,12 +512,13 @@ const MusicPlayerCardBase = React.forwardRef<HTMLDivElement, MusicPlayerCardProp
|
|
|
457
512
|
{showFileInput ? (
|
|
458
513
|
<label className="mb-4 inline-flex cursor-pointer items-center gap-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-stone-400">
|
|
459
514
|
<span>Load audio</span>
|
|
460
|
-
<input
|
|
461
|
-
type="file"
|
|
462
|
-
accept="audio/*"
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
515
|
+
<input
|
|
516
|
+
type="file"
|
|
517
|
+
accept="audio/*"
|
|
518
|
+
multiple
|
|
519
|
+
className="sr-only"
|
|
520
|
+
onChange={handleFileChange}
|
|
521
|
+
/>
|
|
466
522
|
</label>
|
|
467
523
|
) : null}
|
|
468
524
|
|
|
@@ -498,10 +554,13 @@ const MusicPlayerCardBase = React.forwardRef<HTMLDivElement, MusicPlayerCardProp
|
|
|
498
554
|
/>
|
|
499
555
|
</div>
|
|
500
556
|
|
|
501
|
-
<div className="flex justify-between text-[10px] text-stone-400 font-mono mb-4">
|
|
502
|
-
<span>{resolvedCurrentTime}</span>
|
|
503
|
-
<span>{resolvedTotalTime}</span>
|
|
504
|
-
</div>
|
|
557
|
+
<div className="flex justify-between text-[10px] text-stone-400 font-mono mb-4">
|
|
558
|
+
<span>{resolvedCurrentTime}</span>
|
|
559
|
+
<span>{resolvedTotalTime}</span>
|
|
560
|
+
</div>
|
|
561
|
+
{errorMessage ? (
|
|
562
|
+
<p className="mb-3 text-xs text-rose-500">{errorMessage}</p>
|
|
563
|
+
) : null}
|
|
505
564
|
|
|
506
565
|
{/* Controls */}
|
|
507
566
|
<div className={cn("flex justify-between items-center mt-auto px-2", controlsClassName)}>
|