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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trix-ui",
3
- "version": "0.2.3",
3
+ "version": "0.2.6",
4
4
  "description": "Lite UI CLI, registry tooling, and templates.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- /** Initial index when using tracks (default 0) */
34
- initialTrackIndex?: number;
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 useObjectUrl(file?: File): string | null {
90
- const [url, setUrl] = React.useState<string | null>(null);
91
-
92
- React.useEffect(() => {
93
- if (!file) {
94
- setUrl(null);
95
- return undefined;
96
- }
97
-
98
- const nextUrl = URL.createObjectURL(file);
99
- setUrl(nextUrl);
100
-
101
- return () => {
102
- URL.revokeObjectURL(nextUrl);
103
- };
104
- }, [file]);
105
-
106
- return url;
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
- initialTrackIndex = 0,
120
- autoPlay = false,
121
- showFileInput = false,
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 [uploadedFile, setUploadedFile] = React.useState<File | null>(null);
168
- const [currentTimeSec, setCurrentTimeSec] = React.useState(0);
169
- const [durationSec, setDurationSec] = React.useState(0);
170
- const audioRef = React.useRef<HTMLAudioElement | null>(null);
171
- const shuffleHistoryRef = React.useRef<number[]>([]);
172
-
173
- const isShuffle = isShuffleOn ?? internalShuffle;
174
- const isRepeat = isRepeatOn ?? internalRepeat;
175
- const playing = isPlaying ?? internalPlaying;
176
-
177
- const playlist = tracks?.length ? tracks : [];
178
- const fallbackTrack: MusicTrack = {
179
- src: audioSrc ?? "",
180
- title,
181
- subtitle,
182
- imageSrc,
183
- };
184
-
185
- const activeTrack = playlist.length
186
- ? playlist[clamp(trackIndex, 0, playlist.length - 1)]
187
- : fallbackTrack;
188
-
189
- const propFileUrl = useObjectUrl(audioFile);
190
- const uploadedFileUrl = useObjectUrl(uploadedFile ?? undefined);
191
- const resolvedSrc = propFileUrl ?? uploadedFileUrl ?? activeTrack.src;
192
- const isPlayable = Boolean(resolvedSrc);
193
-
194
- const derivedProgress =
195
- durationSec > 0 ? clamp((currentTimeSec / durationSec) * 100, 0, 100) : 0;
196
- const pct = clamp(progress ?? derivedProgress, 0, 100);
197
- const resolvedCurrentTime = currentTime ?? formatTime(currentTimeSec);
198
- const resolvedTotalTime = totalTime ?? formatTime(durationSec);
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
- onPlayStateChange?.(false);
218
- }
219
- },
220
- [isPlaying, onPlayStateChange, resolvedSrc]
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 file = event.target.files?.[0];
403
- if (!file) return;
404
- setUploadedFile(file);
405
- onFileSelect?.(file);
406
- if (isPlaying === undefined) {
407
- setInternalPlaying(true);
408
- }
409
- onPlayStateChange?.(true);
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
- onEnded={handleEnded}
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
- className="sr-only"
464
- onChange={handleFileChange}
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)}>