openreplay/spot/entrypoints/content/SavingControls.tsx

525 lines
16 KiB
TypeScript

// noinspection SpellCheckingInspection
import { createSignal, onCleanup, createEffect } from "solid-js";
import { formatMsToTime } from "@/entrypoints/content/utils";
import "./style.css";
import "./dragControls.css";
interface ISavingControls {
onClose: (
save: boolean,
obj?: {
blob?: Blob;
name?: string;
comment?: string;
useHook?: boolean;
thumbnail?: string;
crop: [number, number] | null;
},
) => void;
getVideoData: () => Promise<any>;
getErrorEvents: () => Promise<{ title: string; time: number }[]>;
}
const base64ToBlob = (base64: string) => {
const splitStr = base64.split(",");
const len = splitStr.length;
const byteString = atob(splitStr[len - 1]);
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
return new Blob([ab], { type: "video/webm" });
};
function SavingControls({
onClose,
getVideoData,
getErrorEvents,
}: ISavingControls) {
const [name, setName] = createSignal(`Issues in — ${document.title}`);
const [description, setDescription] = createSignal("");
const [currentTime, setCurrentTime] = createSignal(0);
const [duration, setDuration] = createSignal(0);
const [playing, setPlaying] = createSignal(false);
const [trimBounds, setTrimBounds] = createSignal([0, 0]);
const [videoData, setVideoData] = createSignal<string | undefined>(undefined);
const [videoBlob, setVideoBlob] = createSignal<Blob | undefined>(undefined);
const [processing, setProcessing] = createSignal(false);
const [startPos, setStartPos] = createSignal(0);
const [endPos, setEndPos] = createSignal(100);
const [dragging, setDragging] = createSignal<string | null>(null);
const [isTyping, setIsTyping] = createSignal(false);
const [errorEvents, setErrorEvents] = createSignal<
{ title: string; time: number }[]
>([]);
createEffect(() => {
setTrimBounds([0, 0]);
getErrorEvents().then((r) => {
setErrorEvents(r);
});
});
const spacePressed = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
isTyping()
) {
return;
}
e.preventDefault();
e.stopPropagation();
if (e.key === " ") {
if (playing()) {
pause();
} else {
resume();
}
}
};
createEffect(() => {
window.addEventListener("keydown", spacePressed);
onCleanup(() => window.removeEventListener("keydown", spacePressed));
});
const convertToPercentage = (clientX: number, element: HTMLElement) => {
const rect = element.getBoundingClientRect();
const x = clientX - rect.left;
return (x / rect.width) * 100;
};
const startDrag =
(marker: "start" | "end" | "body") => (event: MouseEvent) => {
event.stopPropagation();
setDragging(marker);
};
const onDrag = (event: MouseEvent) => {
if (dragging() && event.clientX !== 0) {
const newPos = convertToPercentage(
event.clientX,
event.currentTarget as HTMLElement,
);
if (dragging() === "start") {
if (endPos() - newPos <= 1 || newPos < 0 || newPos > 100) {
return;
}
setStartPos(newPos);
} else if (dragging() === "end") {
if (newPos - startPos() <= 1 || newPos < 0 || newPos > 100) {
return;
}
setEndPos(newPos);
}
onTrimChange(
(startPos() / 100) * duration(),
(endPos() / 100) * duration(),
);
}
};
const endDrag = () => {
setDragging(null);
};
onCleanup(() => {
setDragging(null);
});
if (videoData() === undefined) {
getVideoData().then(async (data: Record<string, any>) => {
const fullData = data.base64data.join("");
const blob = base64ToBlob(fullData);
const blobUrl = URL.createObjectURL(blob);
setVideoBlob(blob);
setVideoData(blobUrl);
});
}
let videoRef: HTMLVideoElement;
const onSave = async () => {
if (!name()) {
return;
}
setProcessing(true);
const thumbnail = await generateThumbnail();
setProcessing(false);
const bounds = trimBounds();
const trim =
bounds[0] + bounds[1] === 0
? null
: (bounds.map((i: number) => Math.round(i * 1000)) as [number, number]);
const dataObj = {
blob: videoBlob(),
name: name(),
comment: description(),
useHook: false,
thumbnail,
crop: trim,
};
onClose(true, dataObj);
};
const onCancel = () => {
onClose(false);
};
const pause = () => {
videoRef.pause();
setPlaying(false);
};
const resume = () => {
void videoRef.play();
setPlaying(true);
};
const updateCurrentTime = () => {
setCurrentTime(videoRef.currentTime);
};
const generateThumbnail = async (): Promise<string> => {
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) return "";
let thumbnailRes = "";
const aspectRatio = videoRef.videoWidth / videoRef.videoHeight;
const width = 1080;
const height = width / aspectRatio;
canvas.width = width;
canvas.height = height;
videoRef.currentTime = duration() ? duration() : 3;
context.drawImage(videoRef, 0, 0, canvas.width, canvas.height);
thumbnailRes = canvas.toDataURL("image/jpeg", 0.7);
return new Promise((res) => {
const interval = setInterval(() => {
if (thumbnailRes) {
clearInterval(interval);
res(thumbnailRes);
}
}, 100);
});
};
const getDuration = async () => {
videoRef.currentTime = 1e101;
await new Promise((resolve) => {
videoRef.ontimeupdate = () => {
videoRef.ontimeupdate = null;
resolve(videoRef.duration);
};
});
setTimeout(() => {
videoRef.currentTime = 0;
}, 25);
return videoRef.duration;
};
const onMetaLoad = async () => {
let videoDuration = videoRef.duration;
if (videoDuration === Infinity || Number.isNaN(videoDuration)) {
videoDuration = await getDuration();
}
setDuration(videoDuration);
setErrorEvents(
errorEvents().filter((ev: { time: number }) => ev.time < videoDuration),
);
void generateThumbnail();
};
const onVideoEnd = () => {
setPlaying(false);
};
const setVideoRef = (el: HTMLVideoElement) => {
videoRef = el;
videoRef.addEventListener("loadedmetadata", onMetaLoad);
videoRef.addEventListener("ended", onVideoEnd);
};
const round = (num: number) => {
return Math.round((num + Number.EPSILON) * 100) / 100;
};
const onTrimChange = (a: number, b: number) => {
const start = round(a);
const end = round(b);
setTrimBounds([start, end]);
};
const getTrimDuration = () => {
const [trimStart, trimEnd] = trimBounds();
return trimEnd - trimStart;
};
const pageUrl = document.location.href;
let dialogRef: HTMLDialogElement;
createEffect(() => {
if (dialogRef) {
dialogRef.showModal();
}
});
const safeUrl = pageUrl.length > 60 ? pageUrl.slice(0, 60) + "..." : pageUrl;
const int = setInterval(() => {
updateCurrentTime();
}, 100);
onCleanup(() => {
clearInterval(int);
videoRef.removeEventListener("loadedmetadata", onMetaLoad);
videoRef.removeEventListener("ended", onVideoEnd);
});
return (
<dialog
ref={(el) => (dialogRef = el)}
id="editRecording"
class="modal save-controls"
>
<div class="modal-box bg-slate-50 p-0 max-w-[82%]">
<div class={"savingcontainer flex xl:flex-row flex-col"}>
{processing() ? (
<div class={"processingloader"}>
<div class="flex flex-col gap-2 justify-center items-center">
<span class="loading loading-spinner text-primary text-center justify-center items-center"></span>
Saving...
</div>
</div>
) : null}
<div class={"replayarea flex-1 p-4 join join-vertical"}>
<div
class={
"card join-item border-t border-r border-l border-slate-100 "
}
>
<div
class={
"urlcontainer text-sm p-2 text-neutral/70 flex gap-1 items-center overflow-hidden"
}
>
<span>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-link-2"
>
<path d="M9 17H7A5 5 0 0 1 7 7h2" />
<path d="M15 7h2a5 5 0 1 1 0 10h-2" />
<line x1="8" x2="16" y1="12" y2="12" />
</svg>
</span>
{safeUrl}
</div>
<video
ref={setVideoRef}
class={"videocontainer"}
src={videoData()}
/>
</div>
<div class={"card py-1 px-2"}>
{errorEvents().length ? (
<div class={"relative w-full h-4"} />
) : null}
<div class={"flex items-center gap-2"}>
<div
class={`${playing() ? "" : "bg-indigo-100"} cursor-pointer btn btn-ghost btn-circle btn-sm hover:bg-indigo-50 border border-slate-100`}
>
{playing() ? (
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
pause();
}}
class={"pause-icon w-5"}
/>
) : (
<div
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
resume();
}}
class={"play-icon ml-0.5 w-5"}
/>
)}
</div>
<div class="flex flex-1 items-center gap-4">
<div class="w-11 text-sm font-medium">
{formatMsToTime(currentTime() * 1000)}
</div>
<div
style={{
position: "relative",
width: "100%",
height: "21px",
}}
onMouseMove={onDrag}
onMouseUp={endDrag}
>
<div class={"absolute h-4 w-full"} style="top:-20px;">
{errorEvents().map((e) => (
<div
class={
"w-3 h-3 rounded-full bg-red-600 absolute tooltip"
}
style={{
top: "2px",
left: `${(e.time / duration()) * 100}%`,
}}
data-tip={e.title}
/>
))}
</div>
<div
class="marker start"
onMouseDown={startDrag("start")}
style={{ left: `calc(${startPos()}% - 1.4%)` }}
>
<div class="handle"></div>
<div class="handle"></div>
</div>
<div
class="slider-body"
// onMouseDown={startDrag("body")}
style={{
left: `calc(${startPos()}%)`,
width: `calc(${endPos() - startPos()}% - 0px)`,
}}
/>
<div
class="marker end"
onMouseDown={startDrag("end")}
style={{ left: `${endPos()}%` }}
>
<div class="handle"></div>
<div class="handle"></div>
</div>
<input
type="range"
min="0"
step={0.01}
max={duration() - 0.1}
value={currentTime()}
onInput={(e) => {
const time = parseFloat(e.currentTarget.value);
videoRef.currentTime = time;
setCurrentTime(time);
}}
/>
</div>
<div class="w-11 text-sm font-medium">
{formatMsToTime(duration() * 1000)}
</div>
</div>
</div>
{getTrimDuration() > 0 ? (
<p class="text-xs block text-center py-2">
<span class="font-meidum me-1">
{formatMsToTime(getTrimDuration() * 1000)}
</span>
The selected portion of the recording will be saved.
</p>
) : null}
</div>
</div>
<div class={"commentarea flex-none p-4 xl:ps-0 gap-2 "}>
<div class="flex flex-col ">
<div>
<div class="flex justify-between items-center">
<h4 class="text-lg font-medium mb-4">Save Spot</h4>
<form method="dialog">
<button class="btn btn-sm btn-circle btn-ghost absolute right-3 top-3.5">
</button>
</form>
</div>
<div class="mb-4">
<label class={"text-base font-medium mb-2"}>Title</label>
<input
type="text"
placeholder="Name this Spot"
maxlength={64}
value={name()}
onFocus={() => setIsTyping(true)}
onBlur={() => setIsTyping(false)}
onInput={(e) => setName(e.currentTarget.value)}
class="input input-bordered w-full input-sm text-base mt-1"
/>
</div>
<div>
<label class={"text-base font-medium"}>Comments</label>
<textarea
placeholder="Add more details..."
value={description()}
maxLength={256}
onFocus={() => setIsTyping(true)}
onBlur={() => setIsTyping(false)}
onInput={(e) => setDescription(e.currentTarget.value)}
class="textarea textarea-bordered w-full textarea-sm text-base leading-normal mt-1"
rows={4}
/>
</div>
</div>
<div class={"flex flex-col gap-3 justify-end mt-4"}>
<div class="flex items-center gap-3">
<div
onClick={onSave}
class={
name().length
? "btn btn-primary btn-sm text-white text-base"
: "btn-disabled btn btn-sm text-white text-base"
}
>
Save Spot
</div>
<div
onClick={onCancel}
class={
"btn btn-outline btn-primary btn-sm text-base hover:bg-white"
}
>
Cancel
</div>
</div>
<p class="text-xs">
Spots are saved to your{" "}
<a
href="https://foss.openreplay.com/spots"
class="text-primary no-underline"
target="blank"
>
OpenReplay account.
</a>
</p>
</div>
</div>
</div>
</div>
</div>
</dialog>
);
}
export default SavingControls;