@hexdn/react 0.1.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/LICENSE +21 -0
- package/README.md +160 -0
- package/dist/player/bounded-surface-position.d.ts +8 -0
- package/dist/player/bounded-surface-position.js +8 -0
- package/dist/player/hexdn-player-control-popover.module.css +113 -0
- package/dist/player/index.d.ts +25 -0
- package/dist/player/index.js +32 -0
- package/dist/player/media-video-player-control-hold.d.ts +2 -0
- package/dist/player/media-video-player-control-hold.js +9 -0
- package/dist/player/media-video-player-debug-hud-loader-contract.d.ts +5 -0
- package/dist/player/media-video-player-debug-hud-loader-contract.js +1 -0
- package/dist/player/media-video-player-keyboard.d.ts +5 -0
- package/dist/player/media-video-player-keyboard.js +52 -0
- package/dist/player/media-video-player-layout.d.ts +64 -0
- package/dist/player/media-video-player-layout.js +110 -0
- package/dist/player/media-video-player-scrubber.d.ts +16 -0
- package/dist/player/media-video-player-scrubber.js +333 -0
- package/dist/player/media-video-player-surface-click.d.ts +8 -0
- package/dist/player/media-video-player-surface-click.js +14 -0
- package/dist/player/media-video-player-view-state.d.ts +21 -0
- package/dist/player/media-video-player-view-state.js +31 -0
- package/dist/player/media-video-player.module.css +1254 -0
- package/dist/player/playback-landscape-presentation.d.ts +16 -0
- package/dist/player/playback-landscape-presentation.js +39 -0
- package/dist/player/playback-loading-indicator.d.ts +7 -0
- package/dist/player/playback-loading-indicator.js +35 -0
- package/dist/player/player-control-popover.d.ts +16 -0
- package/dist/player/player-control-popover.js +130 -0
- package/dist/player/player-host.d.ts +31 -0
- package/dist/player/player-host.js +29 -0
- package/dist/player/player-icon.d.ts +19 -0
- package/dist/player/player-icon.js +59 -0
- package/dist/player/player-labels.d.ts +27 -0
- package/dist/player/player-labels.js +35 -0
- package/dist/player/player-slots.d.ts +18 -0
- package/dist/player/player-slots.js +1 -0
- package/dist/player/ready-player.d.ts +34 -0
- package/dist/player/ready-player.js +293 -0
- package/dist/player/tooltip-surface.d.ts +2 -0
- package/dist/player/tooltip-surface.js +8 -0
- package/dist/player/ui-primitives.d.ts +17 -0
- package/dist/player/ui-primitives.js +15 -0
- package/dist/player/ui.module.css +113 -0
- package/dist/player/use-media-video-player-controls.d.ts +17 -0
- package/dist/player/use-media-video-player-controls.js +87 -0
- package/dist/player/use-media-video-player-fullscreen.d.ts +24 -0
- package/dist/player/use-media-video-player-fullscreen.js +38 -0
- package/dist/player/use-media-video-player-interactions.d.ts +25 -0
- package/dist/player/use-media-video-player-interactions.js +163 -0
- package/dist/player/use-media-video-player-presentation.d.ts +50 -0
- package/dist/player/use-media-video-player-presentation.js +125 -0
- package/dist/player/use-media-video-player.d.ts +81 -0
- package/dist/player/use-media-video-player.js +263 -0
- package/dist/player/use-player-analytics.d.ts +13 -0
- package/dist/player/use-player-analytics.js +79 -0
- package/dist/player/use-slider-pointer.d.ts +20 -0
- package/dist/player/use-slider-pointer.js +85 -0
- package/dist/player/utils.d.ts +1 -0
- package/dist/player/utils.js +3 -0
- package/dist/upload/index.d.ts +37 -0
- package/dist/upload/index.js +60 -0
- package/dist/upload/upload.module.css +41 -0
- package/dist/upload/use-uploader.d.ts +42 -0
- package/dist/upload/use-uploader.js +179 -0
- package/package.json +51 -0
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type MonitorPlaybackOptions, type PlaybackMonitor, type PlaybackObservation } from "@hexdn/analytics";
|
|
2
|
+
import { type RefObject } from "react";
|
|
3
|
+
export type PlayerAnalyticsOptions = Omit<MonitorPlaybackOptions, "nativeErrorsFatal"> & {
|
|
4
|
+
readonly onError?: ((error: unknown) => void) | undefined;
|
|
5
|
+
readonly onObservation?: ((observation: PlaybackObservation) => void) | undefined;
|
|
6
|
+
};
|
|
7
|
+
export declare function usePlayerAnalytics(input: {
|
|
8
|
+
readonly analytics?: PlayerAnalyticsOptions | undefined;
|
|
9
|
+
readonly autoPlay: boolean;
|
|
10
|
+
readonly monitorRef: RefObject<PlaybackMonitor | null>;
|
|
11
|
+
readonly sourceError?: string | null | undefined;
|
|
12
|
+
readonly videoRef: RefObject<HTMLVideoElement | null>;
|
|
13
|
+
}): void;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { monitorPlayback, } from "@hexdn/analytics";
|
|
3
|
+
import { useLayoutEffect, useRef } from "react";
|
|
4
|
+
export function usePlayerAnalytics(input) {
|
|
5
|
+
const latest = useRef(input.analytics);
|
|
6
|
+
useLayoutEffect(() => {
|
|
7
|
+
latest.current = input.analytics;
|
|
8
|
+
});
|
|
9
|
+
const { monitorRef, videoRef } = input;
|
|
10
|
+
const enabled = Boolean(input.analytics);
|
|
11
|
+
const id = input.analytics?.video.id;
|
|
12
|
+
const assetId = input.analytics?.video.assetId;
|
|
13
|
+
const outputId = input.analytics?.video.outputId;
|
|
14
|
+
const pendingAttempt = input.analytics?.pendingAttempt;
|
|
15
|
+
const attachment = useRef(null);
|
|
16
|
+
// Callback identity and credential changes do not detach the existing view.
|
|
17
|
+
useLayoutEffect(() => {
|
|
18
|
+
const media = videoRef.current;
|
|
19
|
+
const analytics = latest.current;
|
|
20
|
+
if (!enabled || !media || !analytics)
|
|
21
|
+
return;
|
|
22
|
+
let current = attachment.current;
|
|
23
|
+
if (!current ||
|
|
24
|
+
current.media !== media ||
|
|
25
|
+
current.pendingAttempt !== pendingAttempt) {
|
|
26
|
+
current?.monitor.destroy();
|
|
27
|
+
try {
|
|
28
|
+
const monitor = monitorPlayback(media, {
|
|
29
|
+
video: analytics.video,
|
|
30
|
+
transport: (report, context) => (latest.current ?? analytics).transport(report, context),
|
|
31
|
+
nativeErrorsFatal: false,
|
|
32
|
+
...(analytics.pendingAttempt
|
|
33
|
+
? { pendingAttempt: analytics.pendingAttempt }
|
|
34
|
+
: {}),
|
|
35
|
+
...(analytics.reportIntervalMs === undefined
|
|
36
|
+
? {}
|
|
37
|
+
: { reportIntervalMs: analytics.reportIntervalMs }),
|
|
38
|
+
});
|
|
39
|
+
monitor.subscribe((observation) => latest.current?.onObservation?.(observation));
|
|
40
|
+
current = { media, monitor, pendingAttempt, disposal: null };
|
|
41
|
+
attachment.current = current;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
analytics.onError?.(error);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
current.disposal = null;
|
|
49
|
+
monitorRef.current = current.monitor;
|
|
50
|
+
return () => {
|
|
51
|
+
const token = {};
|
|
52
|
+
current.disposal = token;
|
|
53
|
+
// React's development effect replay reuses this exact element. Give its
|
|
54
|
+
// replacement setup one microtask to retain the same viewing attempt.
|
|
55
|
+
queueMicrotask(() => {
|
|
56
|
+
if (current.disposal !== token)
|
|
57
|
+
return;
|
|
58
|
+
if (monitorRef.current === current.monitor)
|
|
59
|
+
monitorRef.current = null;
|
|
60
|
+
if (attachment.current === current)
|
|
61
|
+
attachment.current = null;
|
|
62
|
+
current.monitor.destroy({ flush: latest.current !== undefined });
|
|
63
|
+
});
|
|
64
|
+
};
|
|
65
|
+
}, [enabled, pendingAttempt, monitorRef, videoRef]);
|
|
66
|
+
useLayoutEffect(() => {
|
|
67
|
+
const analytics = latest.current;
|
|
68
|
+
if (!analytics)
|
|
69
|
+
return;
|
|
70
|
+
monitorRef.current?.setVideo(analytics.video);
|
|
71
|
+
}, [enabled, pendingAttempt, id, assetId, outputId, monitorRef]);
|
|
72
|
+
useLayoutEffect(() => {
|
|
73
|
+
if (input.sourceError && input.autoPlay) {
|
|
74
|
+
monitorRef.current?.recordError("source_acquisition_failed", {
|
|
75
|
+
fatal: true,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}, [input.sourceError, input.autoPlay, monitorRef]);
|
|
79
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type PointerEvent } from "react";
|
|
2
|
+
export declare function useSliderPointer<TElement extends HTMLElement>({ disabled, readValue, onPreviewChange, onCommit, }: {
|
|
3
|
+
readonly disabled: boolean;
|
|
4
|
+
readonly readValue: (point: {
|
|
5
|
+
readonly clientX: number;
|
|
6
|
+
readonly clientY: number;
|
|
7
|
+
}, dragging: boolean) => number | null;
|
|
8
|
+
readonly onPreviewChange: (value: number | null) => void;
|
|
9
|
+
readonly onCommit: (value: number) => void;
|
|
10
|
+
}): {
|
|
11
|
+
cancel: () => void;
|
|
12
|
+
pointerHandlers: {
|
|
13
|
+
onPointerDown(event: PointerEvent<TElement>): void;
|
|
14
|
+
onPointerMove(event: PointerEvent<TElement>): void;
|
|
15
|
+
onPointerUp(event: PointerEvent<TElement>): void;
|
|
16
|
+
onPointerLeave(): void;
|
|
17
|
+
onPointerCancel: (event: PointerEvent<TElement>) => void;
|
|
18
|
+
onLostPointerCapture: (event: PointerEvent<TElement>) => void;
|
|
19
|
+
};
|
|
20
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useLayoutEffect, useRef } from "react";
|
|
3
|
+
export function useSliderPointer({ disabled, readValue, onPreviewChange, onCommit, }) {
|
|
4
|
+
const activePointer = useRef(null);
|
|
5
|
+
const releasePointer = useCallback(() => {
|
|
6
|
+
const active = activePointer.current;
|
|
7
|
+
activePointer.current = null;
|
|
8
|
+
if (active?.element.hasPointerCapture(active.id)) {
|
|
9
|
+
active.element.releasePointerCapture(active.id);
|
|
10
|
+
}
|
|
11
|
+
return active;
|
|
12
|
+
}, []);
|
|
13
|
+
function cancel() {
|
|
14
|
+
releasePointer();
|
|
15
|
+
onPreviewChange(null);
|
|
16
|
+
}
|
|
17
|
+
useLayoutEffect(() => {
|
|
18
|
+
if (disabled)
|
|
19
|
+
releasePointer()?.clearPreview();
|
|
20
|
+
return () => {
|
|
21
|
+
// Activity can hide a retained page during a gesture. Cancel ownership
|
|
22
|
+
// without coupling cleanup to callbacks that change on every preview.
|
|
23
|
+
releasePointer()?.clearPreview();
|
|
24
|
+
};
|
|
25
|
+
}, [disabled, releasePointer]);
|
|
26
|
+
function cancelPointer(event) {
|
|
27
|
+
if (activePointer.current?.id === event.pointerId)
|
|
28
|
+
cancel();
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
cancel,
|
|
32
|
+
pointerHandlers: {
|
|
33
|
+
onPointerDown(event) {
|
|
34
|
+
if (disabled ||
|
|
35
|
+
!event.isPrimary ||
|
|
36
|
+
event.button !== 0 ||
|
|
37
|
+
activePointer.current) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const value = readValue(event, false);
|
|
41
|
+
if (value === null)
|
|
42
|
+
return;
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
45
|
+
activePointer.current = {
|
|
46
|
+
id: event.pointerId,
|
|
47
|
+
element: event.currentTarget,
|
|
48
|
+
clearPreview: () => onPreviewChange(null),
|
|
49
|
+
};
|
|
50
|
+
event.currentTarget.focus({ preventScroll: true });
|
|
51
|
+
onPreviewChange(value);
|
|
52
|
+
},
|
|
53
|
+
onPointerMove(event) {
|
|
54
|
+
if (disabled)
|
|
55
|
+
return;
|
|
56
|
+
const active = activePointer.current;
|
|
57
|
+
if (active) {
|
|
58
|
+
if (active.id === event.pointerId) {
|
|
59
|
+
onPreviewChange(readValue(event, true));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else if (event.isPrimary &&
|
|
63
|
+
event.pointerType !== "touch" &&
|
|
64
|
+
event.buttons === 0) {
|
|
65
|
+
onPreviewChange(readValue(event, false));
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
onPointerUp(event) {
|
|
69
|
+
if (activePointer.current?.id !== event.pointerId)
|
|
70
|
+
return;
|
|
71
|
+
const value = disabled ? null : readValue(event, true);
|
|
72
|
+
releasePointer();
|
|
73
|
+
onPreviewChange(event.pointerType === "touch" ? null : value);
|
|
74
|
+
if (value !== null)
|
|
75
|
+
onCommit(value);
|
|
76
|
+
},
|
|
77
|
+
onPointerLeave() {
|
|
78
|
+
if (!activePointer.current)
|
|
79
|
+
onPreviewChange(null);
|
|
80
|
+
},
|
|
81
|
+
onPointerCancel: cancelPointer,
|
|
82
|
+
onLostPointerCapture: cancelPointer,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function cn(...values: (string | false | null | undefined)[]): string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type ReactNode, type CSSProperties } from "react";
|
|
2
|
+
import type { UploadResult } from "@hexdn/upload";
|
|
3
|
+
import { type UploaderController, type UseUploaderOptions } from "./use-uploader.js";
|
|
4
|
+
export { useUploader } from "./use-uploader.js";
|
|
5
|
+
export type { UploaderController, UploaderProgress, UploaderState, UploaderTransfer, UploaderTransferContext, UseUploaderOptions, } from "./use-uploader.js";
|
|
6
|
+
export interface UploaderLabels {
|
|
7
|
+
readonly title: string;
|
|
8
|
+
readonly choose: string;
|
|
9
|
+
readonly drop: string;
|
|
10
|
+
readonly pause: string;
|
|
11
|
+
readonly pausing: string;
|
|
12
|
+
readonly resume: string;
|
|
13
|
+
readonly retry: string;
|
|
14
|
+
readonly complete: string;
|
|
15
|
+
readonly preparing: string;
|
|
16
|
+
readonly uploading: string;
|
|
17
|
+
readonly completing: string;
|
|
18
|
+
readonly paused: string;
|
|
19
|
+
readonly error: string;
|
|
20
|
+
}
|
|
21
|
+
interface UploaderPresentation<Result> {
|
|
22
|
+
readonly accept?: string | undefined;
|
|
23
|
+
readonly disabled?: boolean | undefined;
|
|
24
|
+
readonly dropEnabled?: boolean | undefined;
|
|
25
|
+
readonly className?: string | undefined;
|
|
26
|
+
readonly style?: CSSProperties | undefined;
|
|
27
|
+
readonly inputClassName?: string | undefined;
|
|
28
|
+
readonly labels?: Partial<UploaderLabels> | undefined;
|
|
29
|
+
/** Replace the presentation while keeping the same input and transfer controller. */
|
|
30
|
+
readonly children?: ReactNode | ((controller: UploaderController<Result>) => ReactNode);
|
|
31
|
+
}
|
|
32
|
+
export type UploaderProps<Result = UploadResult> = UploaderPresentation<Result> & ({
|
|
33
|
+
readonly controller: UploaderController<Result>;
|
|
34
|
+
} | (UseUploaderOptions<Result> & {
|
|
35
|
+
readonly controller?: never;
|
|
36
|
+
}));
|
|
37
|
+
export declare function Uploader<Result = UploadResult>(props: UploaderProps<Result>): import("react").JSX.Element;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
|
+
import {} from "react";
|
|
4
|
+
import { useUploader, } from "./use-uploader.js";
|
|
5
|
+
import styles from "./upload.module.css";
|
|
6
|
+
export { useUploader } from "./use-uploader.js";
|
|
7
|
+
const defaultLabels = {
|
|
8
|
+
title: "Upload video",
|
|
9
|
+
choose: "Choose video",
|
|
10
|
+
drop: "Drop a video here, or choose a file.",
|
|
11
|
+
pause: "Pause",
|
|
12
|
+
pausing: "Pausing…",
|
|
13
|
+
resume: "Resume",
|
|
14
|
+
retry: "Retry",
|
|
15
|
+
complete: "Upload complete",
|
|
16
|
+
preparing: "Preparing upload",
|
|
17
|
+
uploading: "Uploading",
|
|
18
|
+
completing: "Finishing upload",
|
|
19
|
+
paused: "Upload paused",
|
|
20
|
+
error: "Upload failed. Please try again.",
|
|
21
|
+
};
|
|
22
|
+
export function Uploader(props) {
|
|
23
|
+
if (props.controller)
|
|
24
|
+
return _jsx(UploaderView, { ...props, controller: props.controller });
|
|
25
|
+
return _jsx(ManagedUploader, { ...props });
|
|
26
|
+
}
|
|
27
|
+
function ManagedUploader(props) {
|
|
28
|
+
const controller = useUploader(props);
|
|
29
|
+
return _jsx(UploaderView, { ...props, controller: controller });
|
|
30
|
+
}
|
|
31
|
+
function UploaderView({ controller, children, accept = "video/*,.mkv", disabled = false, dropEnabled = true, className, inputClassName, labels: overrides, style, }) {
|
|
32
|
+
const labels = { ...defaultLabels, ...overrides };
|
|
33
|
+
const blocked = disabled || controller.isBusy;
|
|
34
|
+
const { state } = controller;
|
|
35
|
+
const status = state.status === "idle" ? labels.drop : labels[state.status];
|
|
36
|
+
const custom = children !== undefined;
|
|
37
|
+
return (_jsxs("section", { "aria-label": labels.title, "aria-busy": controller.isBusy, className: [custom ? "" : styles.root, className].filter(Boolean).join(" ") ||
|
|
38
|
+
undefined, style: style, "data-upload-state": state.status, onDragOver: (event) => {
|
|
39
|
+
if (dropEnabled &&
|
|
40
|
+
!blocked &&
|
|
41
|
+
event.dataTransfer.types.includes("Files"))
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
}, onDrop: (event) => {
|
|
44
|
+
if (!dropEnabled)
|
|
45
|
+
return;
|
|
46
|
+
event.preventDefault();
|
|
47
|
+
if (blocked)
|
|
48
|
+
return;
|
|
49
|
+
const file = event.dataTransfer.files[0];
|
|
50
|
+
if (file)
|
|
51
|
+
void controller.start(file).catch(() => undefined);
|
|
52
|
+
}, children: [_jsx("input", { type: "file", accept: accept, disabled: blocked, ref: (element) => controller.bindInput(element), hidden: true, className: inputClassName, "aria-label": labels.choose, onChange: (event) => {
|
|
53
|
+
const file = event.currentTarget.files?.[0];
|
|
54
|
+
event.currentTarget.value = "";
|
|
55
|
+
if (file && !blocked)
|
|
56
|
+
void controller.start(file).catch(() => undefined);
|
|
57
|
+
} }), custom ? (typeof children === "function" ? (children(controller)) : (children)) : (_jsxs(_Fragment, { children: [_jsx("div", { className: styles.heading, children: labels.title }), state.file ? _jsx("p", { className: styles.file, children: state.file.name }) : null, _jsx("p", { role: state.status === "error" ? "alert" : "status", "aria-live": "polite", children: status }), state.progress ? (_jsx("progress", { className: styles.progress, "aria-label": labels.uploading, max: 100, value: state.progress.percent })) : null, _jsxs("div", { className: styles.actions, children: [_jsx("button", { type: "button", disabled: blocked, onClick: controller.openPicker, children: labels.choose }), controller.isBusy ? (_jsx("button", { type: "button", disabled: state.status === "pausing", onClick: controller.pause, children: state.status === "pausing" ? labels.pausing : labels.pause })) : null, state.status === "error" || state.status === "paused" ? (_jsx("button", { type: "button", disabled: disabled, onClick: () => {
|
|
58
|
+
void controller.retry().catch(() => undefined);
|
|
59
|
+
}, children: state.status === "paused" ? labels.resume : labels.retry })) : null] })] }))] }));
|
|
60
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
.root {
|
|
2
|
+
color: var(--hexdn-upload-color, #eee);
|
|
3
|
+
background: var(--hexdn-upload-background, #171717);
|
|
4
|
+
border: 1px solid var(--hexdn-upload-border, #525252);
|
|
5
|
+
border-radius: var(--hexdn-upload-radius, 0.75rem);
|
|
6
|
+
padding: 1.5rem;
|
|
7
|
+
font: inherit;
|
|
8
|
+
}
|
|
9
|
+
.heading {
|
|
10
|
+
font-weight: 600;
|
|
11
|
+
}
|
|
12
|
+
.file {
|
|
13
|
+
overflow-wrap: anywhere;
|
|
14
|
+
}
|
|
15
|
+
.progress {
|
|
16
|
+
width: 100%;
|
|
17
|
+
accent-color: var(--hexdn-upload-accent, #70cda5);
|
|
18
|
+
}
|
|
19
|
+
.actions {
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-wrap: wrap;
|
|
22
|
+
gap: 0.75rem;
|
|
23
|
+
margin-top: 1rem;
|
|
24
|
+
}
|
|
25
|
+
.actions button {
|
|
26
|
+
color: inherit;
|
|
27
|
+
background: transparent;
|
|
28
|
+
border: 1px solid currentColor;
|
|
29
|
+
border-radius: 0.375rem;
|
|
30
|
+
padding: 0.5rem 0.875rem;
|
|
31
|
+
font: inherit;
|
|
32
|
+
cursor: pointer;
|
|
33
|
+
}
|
|
34
|
+
.actions button:disabled {
|
|
35
|
+
cursor: default;
|
|
36
|
+
opacity: 0.55;
|
|
37
|
+
}
|
|
38
|
+
.actions button:focus-visible {
|
|
39
|
+
outline: 2px solid var(--hexdn-upload-accent, #70cda5);
|
|
40
|
+
outline-offset: 3px;
|
|
41
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type UploadCapability, type UploadResult } from "@hexdn/upload";
|
|
2
|
+
export interface UploaderProgress {
|
|
3
|
+
readonly phase: "preparing" | "uploading" | "completing";
|
|
4
|
+
readonly percent: number;
|
|
5
|
+
readonly uploadedBytes?: number;
|
|
6
|
+
readonly totalBytes?: number;
|
|
7
|
+
}
|
|
8
|
+
export interface UploaderTransferContext {
|
|
9
|
+
readonly signal: AbortSignal;
|
|
10
|
+
/** Stable across retries of this file selection, including capability creation. */
|
|
11
|
+
readonly idempotencyKey: string;
|
|
12
|
+
readonly onProgress: (progress: UploaderProgress) => void;
|
|
13
|
+
}
|
|
14
|
+
export type UploaderTransfer<Result> = (file: File, context: UploaderTransferContext) => Promise<Result>;
|
|
15
|
+
export interface UploaderState<Result> {
|
|
16
|
+
readonly status: "idle" | "preparing" | "uploading" | "completing" | "pausing" | "paused" | "complete" | "error";
|
|
17
|
+
readonly file: File | null;
|
|
18
|
+
readonly progress: UploaderProgress | null;
|
|
19
|
+
readonly result: Result | null;
|
|
20
|
+
readonly error: unknown;
|
|
21
|
+
}
|
|
22
|
+
export interface UseUploaderOptions<Result = UploadResult> {
|
|
23
|
+
readonly upload?: UploadCapability;
|
|
24
|
+
readonly getUpload?: (file: File, context: Pick<UploaderTransferContext, "signal" | "idempotencyKey">) => Promise<UploadCapability>;
|
|
25
|
+
/** An application-owned workflow may wrap the same transfer core. */
|
|
26
|
+
readonly transfer?: UploaderTransfer<Result>;
|
|
27
|
+
readonly onComplete?: (result: Result, file: File) => void;
|
|
28
|
+
/** Defaults to stopping local work. No remote cancellation is issued. */
|
|
29
|
+
readonly abortOnUnmount?: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface UploaderController<Result = UploadResult> {
|
|
32
|
+
readonly state: UploaderState<Result>;
|
|
33
|
+
readonly isBusy: boolean;
|
|
34
|
+
readonly bindInput: (element: HTMLInputElement | null) => void;
|
|
35
|
+
openPicker(): void;
|
|
36
|
+
start(file: File): Promise<Result | undefined>;
|
|
37
|
+
retry(): Promise<Result | undefined>;
|
|
38
|
+
/** Stops local work only. Remote cancellation belongs to an explicit tenant action. */
|
|
39
|
+
pause(): void;
|
|
40
|
+
reset(): void;
|
|
41
|
+
}
|
|
42
|
+
export declare function useUploader<Result = UploadResult>(options: UseUploaderOptions<Result>): UploaderController<Result>;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { isUploadCancellation, uploadSource, } from "@hexdn/upload";
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
const idle = {
|
|
5
|
+
status: "idle",
|
|
6
|
+
file: null,
|
|
7
|
+
progress: null,
|
|
8
|
+
result: null,
|
|
9
|
+
error: null,
|
|
10
|
+
};
|
|
11
|
+
export function useUploader(options) {
|
|
12
|
+
const latest = useRef(options);
|
|
13
|
+
latest.current = options;
|
|
14
|
+
const inputRef = useRef(null);
|
|
15
|
+
const mounted = useRef(true);
|
|
16
|
+
const lifecycle = useRef({ generation: 0 });
|
|
17
|
+
const active = useRef(null);
|
|
18
|
+
const selection = useRef(null);
|
|
19
|
+
const [state, setState] = useState(idle);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
mounted.current = true;
|
|
22
|
+
const owner = lifecycle.current;
|
|
23
|
+
const generation = ++owner.generation;
|
|
24
|
+
return () => {
|
|
25
|
+
mounted.current = false;
|
|
26
|
+
queueMicrotask(() => {
|
|
27
|
+
if (owner.generation === generation &&
|
|
28
|
+
latest.current.abortOnUnmount !== false)
|
|
29
|
+
active.current?.abort();
|
|
30
|
+
});
|
|
31
|
+
};
|
|
32
|
+
}, []);
|
|
33
|
+
const start = useCallback(async (file) => {
|
|
34
|
+
if (active.current)
|
|
35
|
+
return undefined;
|
|
36
|
+
const configuration = latest.current;
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const entry = selection.current?.file === file
|
|
39
|
+
? selection.current
|
|
40
|
+
: { file, idempotencyKey: crypto.randomUUID() };
|
|
41
|
+
selection.current = entry;
|
|
42
|
+
active.current = controller;
|
|
43
|
+
if (mounted.current)
|
|
44
|
+
setState({
|
|
45
|
+
...idle,
|
|
46
|
+
status: "preparing",
|
|
47
|
+
file,
|
|
48
|
+
progress: {
|
|
49
|
+
phase: "preparing",
|
|
50
|
+
percent: 0,
|
|
51
|
+
uploadedBytes: 0,
|
|
52
|
+
totalBytes: file.size,
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
const context = {
|
|
56
|
+
signal: controller.signal,
|
|
57
|
+
idempotencyKey: entry.idempotencyKey,
|
|
58
|
+
onProgress(progress) {
|
|
59
|
+
if (active.current !== controller ||
|
|
60
|
+
controller.signal.aborted ||
|
|
61
|
+
!mounted.current)
|
|
62
|
+
return;
|
|
63
|
+
const percent = Number.isFinite(progress.percent)
|
|
64
|
+
? Math.max(0, Math.min(100, progress.percent))
|
|
65
|
+
: 0;
|
|
66
|
+
setState((previous) => ({
|
|
67
|
+
...previous,
|
|
68
|
+
status: progress.phase,
|
|
69
|
+
progress: { ...progress, percent },
|
|
70
|
+
}));
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
try {
|
|
74
|
+
let result;
|
|
75
|
+
if (configuration.transfer) {
|
|
76
|
+
result = await configuration.transfer(file, context);
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
if (file.size <= 0)
|
|
80
|
+
throw new Error("The selected file is empty.");
|
|
81
|
+
if (!entry.upload) {
|
|
82
|
+
const upload = configuration.upload ??
|
|
83
|
+
(await configuration.getUpload?.(file, context));
|
|
84
|
+
if (!upload)
|
|
85
|
+
throw new Error("Provide an upload capability, getUpload, or transfer callback.");
|
|
86
|
+
entry.upload = upload;
|
|
87
|
+
}
|
|
88
|
+
if (controller.signal.aborted)
|
|
89
|
+
throw controller.signal.reason;
|
|
90
|
+
result = (await uploadSource({
|
|
91
|
+
source: file,
|
|
92
|
+
upload: entry.upload,
|
|
93
|
+
signal: controller.signal,
|
|
94
|
+
onProgress: (progress) => context.onProgress({
|
|
95
|
+
phase: progress.phase,
|
|
96
|
+
percent: progress.totalBytes > 0
|
|
97
|
+
? Math.round((progress.uploadedBytes / progress.totalBytes) * 100)
|
|
98
|
+
: 0,
|
|
99
|
+
uploadedBytes: progress.uploadedBytes,
|
|
100
|
+
totalBytes: progress.totalBytes,
|
|
101
|
+
}),
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
// A confirmed completion wins a concurrent local pause request.
|
|
105
|
+
if (mounted.current)
|
|
106
|
+
setState({
|
|
107
|
+
status: "complete",
|
|
108
|
+
file,
|
|
109
|
+
progress: {
|
|
110
|
+
phase: "completing",
|
|
111
|
+
percent: 100,
|
|
112
|
+
uploadedBytes: file.size,
|
|
113
|
+
totalBytes: file.size,
|
|
114
|
+
},
|
|
115
|
+
result,
|
|
116
|
+
error: null,
|
|
117
|
+
});
|
|
118
|
+
try {
|
|
119
|
+
configuration.onComplete?.(result, file);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
/* Observer failures cannot undo a completed transfer. */
|
|
123
|
+
}
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
if (mounted.current)
|
|
128
|
+
setState((previous) => ({
|
|
129
|
+
...previous,
|
|
130
|
+
status: controller.signal.aborted && isUploadCancellation(error)
|
|
131
|
+
? "paused"
|
|
132
|
+
: "error",
|
|
133
|
+
error: controller.signal.aborted && isUploadCancellation(error)
|
|
134
|
+
? null
|
|
135
|
+
: error,
|
|
136
|
+
}));
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
if (active.current === controller)
|
|
141
|
+
active.current = null;
|
|
142
|
+
}
|
|
143
|
+
}, []);
|
|
144
|
+
const pause = useCallback(() => {
|
|
145
|
+
if (!active.current || active.current.signal.aborted)
|
|
146
|
+
return;
|
|
147
|
+
if (mounted.current)
|
|
148
|
+
setState((previous) => ({ ...previous, status: "pausing" }));
|
|
149
|
+
active.current.abort();
|
|
150
|
+
}, []);
|
|
151
|
+
const reset = useCallback(() => {
|
|
152
|
+
if (active.current)
|
|
153
|
+
return;
|
|
154
|
+
selection.current = null;
|
|
155
|
+
if (inputRef.current)
|
|
156
|
+
inputRef.current.value = "";
|
|
157
|
+
if (mounted.current)
|
|
158
|
+
setState(idle);
|
|
159
|
+
}, []);
|
|
160
|
+
const setInputElement = useCallback((element) => {
|
|
161
|
+
inputRef.current = element;
|
|
162
|
+
}, []);
|
|
163
|
+
const openPicker = useCallback(() => {
|
|
164
|
+
inputRef.current?.click();
|
|
165
|
+
}, []);
|
|
166
|
+
const retry = useCallback(() => selection.current
|
|
167
|
+
? start(selection.current.file)
|
|
168
|
+
: Promise.resolve(undefined), [start]);
|
|
169
|
+
return {
|
|
170
|
+
state,
|
|
171
|
+
isBusy: ["preparing", "uploading", "completing", "pausing"].includes(state.status),
|
|
172
|
+
bindInput: setInputElement,
|
|
173
|
+
openPicker,
|
|
174
|
+
start,
|
|
175
|
+
retry,
|
|
176
|
+
pause,
|
|
177
|
+
reset,
|
|
178
|
+
};
|
|
179
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hexdn/react",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Customizable React playback and upload components for HexDN.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": [
|
|
7
|
+
"**/*.css"
|
|
8
|
+
],
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE"
|
|
13
|
+
],
|
|
14
|
+
"exports": {
|
|
15
|
+
"./player": {
|
|
16
|
+
"types": "./dist/player/index.d.ts",
|
|
17
|
+
"import": "./dist/player/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./upload": {
|
|
20
|
+
"types": "./dist/upload/index.d.ts",
|
|
21
|
+
"import": "./dist/upload/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@hexdn/playback": "0.1.0",
|
|
26
|
+
"@hexdn/upload": "^0.1.0",
|
|
27
|
+
"@hexdn/analytics": "^0.1.0"
|
|
28
|
+
},
|
|
29
|
+
"peerDependencies": {
|
|
30
|
+
"react": "^19.0.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/react": "^19.0.0",
|
|
34
|
+
"@types/react-dom": "^19.2.5",
|
|
35
|
+
"jsdom": "^26.1.0",
|
|
36
|
+
"react": "19.2.8",
|
|
37
|
+
"react-dom": "19.2.8",
|
|
38
|
+
"typescript": "^5.9.3",
|
|
39
|
+
"vitest": "^4.1.11"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"license": "MIT",
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "node -e \"require('node:fs').rmSync('dist', {recursive:true, force:true})\" && tsc --project tsconfig.build.json && node scripts/copy-styles.mjs",
|
|
47
|
+
"typecheck": "tsc --project tsconfig.json",
|
|
48
|
+
"test": "vitest run",
|
|
49
|
+
"lint": "eslint ."
|
|
50
|
+
}
|
|
51
|
+
}
|