@slopmachine/react 0.6.0 → 0.7.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.
@@ -1,12 +1,15 @@
1
- import React, { useMemo, useState, useRef, useEffect } from "react";
1
+ import React, { useEffect, useMemo, useRef, useState } from "react";
2
2
  import { clsx, type ClassValue } from "clsx";
3
3
  import { twMerge } from "tailwind-merge";
4
4
 
5
5
  import {
6
6
  buildVideoUrl,
7
+ createRenderUrlMonitor,
8
+ type SlopMachineError,
7
9
  type SlopVideoOptions,
8
10
  type VideoAspectRatio,
9
11
  } from "@slopmachine/core";
12
+ import { ErrorOverlay, type SlopErrorFallback } from "./ErrorOverlay";
10
13
 
11
14
  // Utility for Tailwind classes
12
15
  function cn(...inputs: ClassValue[]) {
@@ -26,6 +29,17 @@ export interface SlopVideoProps
26
29
  * If not provided, a default spinner and shimmer effect will be shown.
27
30
  */
28
31
  loader?: React.ReactNode;
32
+ /**
33
+ * Content to display if the video fails to generate or load.
34
+ * Pass a node, or a function that receives the `SlopMachineError` and returns a node.
35
+ * If not provided, a default error message is shown in place of the video.
36
+ */
37
+ errorFallback?: SlopErrorFallback;
38
+ /**
39
+ * Called once per URL when the video fails to generate or load.
40
+ * `error.status` holds the HTTP status when the API reported the failure.
41
+ */
42
+ onGenerationError?: (error: SlopMachineError) => void;
29
43
  }
30
44
 
31
45
  /**
@@ -56,7 +70,7 @@ export interface SlopVideoProps
56
70
  * />
57
71
  * ```
58
72
  *
59
- * @version 0.6.0
73
+ * @version 0.7.0
60
74
  */
61
75
  export const SlopVideo: React.FC<SlopVideoProps> = ({
62
76
  bucketId,
@@ -74,6 +88,8 @@ export const SlopVideo: React.FC<SlopVideoProps> = ({
74
88
  original,
75
89
  attachments,
76
90
  loader,
91
+ errorFallback,
92
+ onGenerationError,
77
93
  autoPlay = true,
78
94
  loop = true,
79
95
  muted = true,
@@ -127,40 +143,38 @@ export const SlopVideo: React.FC<SlopVideoProps> = ({
127
143
  }, [rawSrc]);
128
144
 
129
145
  const [isLoading, setIsLoading] = useState(true);
146
+ const [error, setError] = useState<SlopMachineError | null>(null);
130
147
  const [currentSrc, setCurrentSrc] = useState(src);
131
148
  const videoRef = useRef<HTMLVideoElement>(null);
132
-
133
149
  if (src !== currentSrc) {
134
150
  setCurrentSrc(src);
135
151
  setIsLoading(true);
152
+ setError(null);
136
153
  }
137
154
 
155
+ // Keep the latest callback without re-running effects when it changes identity
156
+ const onGenerationErrorRef = useRef(onGenerationError);
138
157
  useEffect(() => {
139
- if (!src) return;
158
+ onGenerationErrorRef.current = onGenerationError;
159
+ });
140
160
 
141
- fetch(src, { method: "HEAD" })
142
- .then(async (res) => {
143
- if (!res.ok) {
144
- let errorMessage = res.statusText;
145
- try {
146
- const errRes = await fetch(src);
147
- const errorText = await errRes.text();
148
- const errorData = JSON.parse(errorText);
149
- if (errorData.error) {
150
- errorMessage = errorData.error;
151
- }
152
- } catch (e) {
153
- // Failed to fetch or parse error details
154
- }
155
- console.error("SlopVideo error:", res.status, errorMessage);
156
- setIsLoading(false);
157
- }
158
- })
159
- .catch((err) => {
160
- console.error(`Error fetching video from ${src}:`, err);
161
+ // Shares one URL check between the HEAD probe and the media element's error event
162
+ const monitor = useMemo(
163
+ () =>
164
+ createRenderUrlMonitor((err) => {
165
+ console.error("SlopVideo error:", err.status ?? "", err.message);
161
166
  setIsLoading(false);
162
- });
163
- }, [src]);
167
+ setError(err);
168
+ onGenerationErrorRef.current?.(err);
169
+ }),
170
+ [],
171
+ );
172
+
173
+ useEffect(() => {
174
+ if (!src) return;
175
+ monitor.watch(src);
176
+ return () => monitor.stop();
177
+ }, [src, monitor]);
164
178
 
165
179
  return (
166
180
  <>
@@ -313,6 +327,14 @@ export const SlopVideo: React.FC<SlopVideoProps> = ({
313
327
  </>
314
328
  )}
315
329
 
330
+ {error && (
331
+ <ErrorOverlay
332
+ error={error}
333
+ errorFallback={errorFallback}
334
+ label="Failed to load video"
335
+ />
336
+ )}
337
+
316
338
  {/* Video */}
317
339
  <video
318
340
  key={src}
@@ -328,19 +350,20 @@ export const SlopVideo: React.FC<SlopVideoProps> = ({
328
350
  onLoadedData?.(e);
329
351
  }}
330
352
  onError={(e) => {
331
- setIsLoading(false);
353
+ monitor.mediaFailed(src, "Failed to load video");
332
354
  onError?.(e);
333
355
  }}
356
+ aria-hidden={error ? true : props["aria-hidden"]}
334
357
  className={cn(
335
358
  "h-full w-full object-cover transition-opacity duration-500",
336
- isLoading ? "opacity-0" : "opacity-100",
359
+ isLoading || error ? "opacity-0" : "opacity-100",
337
360
  )}
338
361
  style={{
339
362
  height: "100%",
340
363
  width: "100%",
341
364
  objectFit: "cover",
342
365
  transition: "opacity 500ms",
343
- opacity: isLoading ? 0 : 1,
366
+ opacity: isLoading || error ? 0 : 1,
344
367
  }}
345
368
  />
346
369
  </div>
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { SlopImage, type SlopImageProps } from "./components/SlopImage";
2
2
  export { SlopVideo, type SlopVideoProps } from "./components/SlopVideo";
3
3
  export { SlopText, type SlopTextProps } from "./components/SlopText";
4
+ export type { SlopErrorFallback } from "./components/ErrorOverlay";
4
5
 
5
6
  export type {
6
7
  ImageAspectRatio,
@@ -18,4 +19,5 @@ export {
18
19
  preloadVideo,
19
20
  preloadText,
20
21
  rateResult,
22
+ SlopMachineError,
21
23
  } from "@slopmachine/core";