mirror of
https://github.com/Xevion/byte-me.git
synced 2025-12-11 00:06:46 -06:00
feat: add bitrate visualization with file analysis
Add comprehensive bitrate data extraction and visualization capabilities: - Implement analyze_files command for file candidacy detection - Add extract_bitrate_data command using ffprobe - Create BitrateData, BitrateFrame, File, and FileCandidacy types with TS bindings - Update App to fetch and display bitrate data from dropped files - Refactor DropOverlay to use new file analysis system - Configure Graph component for packet size visualization - Simplify drag-drop flow to trigger on drop event only
This commit is contained in:
41
src/App.tsx
41
src/App.tsx
@@ -1,13 +1,45 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDragDropPaths } from "@/hooks/useDragDropPaths";
|
||||
import Graph from "@/components/graph";
|
||||
import DropOverlay from "@/components/drop-overlay";
|
||||
import type { Frame } from "@/types/graph";
|
||||
import { commands } from "@/bindings";
|
||||
import type { BitrateData } from "@/bindings";
|
||||
|
||||
function App() {
|
||||
const data: Frame[] = [];
|
||||
|
||||
const [data, setData] = useState<Frame[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const paths = useDragDropPaths();
|
||||
|
||||
useEffect(() => {
|
||||
if (paths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// For minimal prototype, just process the first file
|
||||
const firstPath = paths[0];
|
||||
setIsLoading(true);
|
||||
|
||||
commands
|
||||
.extractBitrateData(firstPath)
|
||||
.then((bitrateData: BitrateData) => {
|
||||
// Transform BitrateData to Nivo's Frame format
|
||||
const frame: Frame = {
|
||||
id: bitrateData.id,
|
||||
data: bitrateData.frames.map((frame) => ({
|
||||
x: frame.frame_num,
|
||||
y: Number(frame.packet_size),
|
||||
})),
|
||||
};
|
||||
setData([frame]);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to extract bitrate data:", error);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [paths]);
|
||||
|
||||
const graph = <Graph data={data} />;
|
||||
|
||||
return (
|
||||
@@ -17,6 +49,11 @@ function App() {
|
||||
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
|
||||
>
|
||||
<DropOverlay paths={paths} />
|
||||
{isLoading && (
|
||||
<div className="absolute z-20 top-4 right-4 text-white bg-blue-600 px-4 py-2 rounded-lg">
|
||||
Extracting bitrate data...
|
||||
</div>
|
||||
)}
|
||||
{graph}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,11 @@ import type { StreamResult } from "@/bindings/StreamResult";
|
||||
import type { StreamDetail } from "@/bindings/StreamDetail";
|
||||
import type { StreamResultError } from "@/bindings/StreamResultError";
|
||||
import type { MediaType } from "@/bindings/MediaType";
|
||||
export type { StreamResult, StreamDetail, StreamResultError, MediaType };
|
||||
import type { File } from "@/bindings/File";
|
||||
import type { FileCandidacy } from "@/bindings/FileCandidacy";
|
||||
import type { BitrateData } from "@/bindings/BitrateData";
|
||||
import type { BitrateFrame } from "@/bindings/BitrateFrame";
|
||||
export type { StreamResult, StreamDetail, StreamResultError, MediaType, File, FileCandidacy, BitrateData, BitrateFrame };
|
||||
|
||||
// Tauri invoke wrapper
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
@@ -21,5 +25,13 @@ export const commands = {
|
||||
if (e instanceof Error) throw e;
|
||||
else return { status: "error", error: e as any };
|
||||
}
|
||||
},
|
||||
|
||||
async analyzeFiles(paths: string[]): Promise<File[]> {
|
||||
return await invoke<File[]>("analyze_files", { paths });
|
||||
},
|
||||
|
||||
async extractBitrateData(path: string): Promise<BitrateData> {
|
||||
return await invoke<BitrateData>("extract_bitrate_data", { path });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { match, P } from "ts-pattern";
|
||||
import {
|
||||
CheckCircle,
|
||||
File as FileIcon,
|
||||
FileText,
|
||||
Film,
|
||||
@@ -11,38 +10,12 @@ import {
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import { commands } from "@/bindings";
|
||||
import type { MediaType, StreamDetail } from "@/bindings";
|
||||
import type { File, FileCandidacy, MediaType } from "@/bindings";
|
||||
|
||||
type DropOverlayProps = {
|
||||
paths: string[];
|
||||
};
|
||||
|
||||
type State =
|
||||
| { status: "hidden" }
|
||||
| { status: "loading"; count: number }
|
||||
| {
|
||||
status: "ready";
|
||||
files: {
|
||||
name: string;
|
||||
key: string;
|
||||
media_type: MediaType;
|
||||
duration?: number | null;
|
||||
size: number;
|
||||
streams: StreamDetail[];
|
||||
}[];
|
||||
}
|
||||
| { status: "error"; reason: string; filename?: string; error_type?: string };
|
||||
|
||||
type FileItemProps = {
|
||||
filename: string;
|
||||
media_type: MediaType;
|
||||
duration?: number | null;
|
||||
size: number;
|
||||
streams: StreamDetail[];
|
||||
error?: string;
|
||||
error_type?: string;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
@@ -51,139 +24,76 @@ const formatFileSize = (bytes: number): string => {
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs
|
||||
.toString()
|
||||
.padStart(2, "0")}`;
|
||||
}
|
||||
return `${minutes}:${secs.toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
const getFileIcon = (
|
||||
mediaType: MediaType,
|
||||
error?: string,
|
||||
errorType?: string,
|
||||
) => {
|
||||
// For non-media files, show a neutral icon instead of error icon
|
||||
if (errorType === "not_media") {
|
||||
switch (mediaType) {
|
||||
case "Executable":
|
||||
return <FileIcon className="w-5 h-5 text-orange-400" />;
|
||||
case "Archive":
|
||||
return <FileIcon className="w-5 h-5 text-yellow-400" />;
|
||||
case "Library":
|
||||
return <FileIcon className="w-5 h-5 text-indigo-400" />;
|
||||
case "Document":
|
||||
return <FileText className="w-5 h-5 text-green-400" />;
|
||||
default:
|
||||
return <FileIcon className="w-5 h-5 text-neutral-300" />;
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <XCircle className="w-5 h-5 text-red-400" />;
|
||||
}
|
||||
|
||||
switch (mediaType) {
|
||||
case "Audio":
|
||||
return <Music className="w-5 h-5 text-blue-400" />;
|
||||
case "Video":
|
||||
return <Film className="w-5 h-5 text-purple-400" />;
|
||||
case "Image":
|
||||
return <Image className="w-5 h-5 text-pink-400" />;
|
||||
case "Document":
|
||||
return <FileText className="w-5 h-5 text-green-400" />;
|
||||
case "Executable":
|
||||
return <FileIcon className="w-5 h-5 text-orange-400" />;
|
||||
case "Archive":
|
||||
return <FileIcon className="w-5 h-5 text-yellow-400" />;
|
||||
case "Library":
|
||||
return <FileIcon className="w-5 h-5 text-indigo-400" />;
|
||||
default:
|
||||
return <FileIcon className="w-5 h-5 text-neutral-300" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStreamInfo = (
|
||||
streams: StreamDetail[],
|
||||
mediaType: MediaType,
|
||||
): string => {
|
||||
// For non-media files, return file type description
|
||||
if (!["Audio", "Video", "Image"].includes(mediaType)) {
|
||||
switch (mediaType) {
|
||||
case "Executable":
|
||||
return "Executable file";
|
||||
case "Archive":
|
||||
return "Archive file";
|
||||
case "Library":
|
||||
return "Library file";
|
||||
case "Document":
|
||||
return "Document file";
|
||||
default:
|
||||
return "Unknown file type";
|
||||
}
|
||||
}
|
||||
|
||||
// For media files, analyze streams
|
||||
const videoStreams = streams.filter((s: any) => "Video" in s);
|
||||
const audioStreams = streams.filter((s: any) => "Audio" in s);
|
||||
const subtitleStreams = streams.filter((s: any) => "Subtitle" in s);
|
||||
|
||||
const parts = [] as string[];
|
||||
if (videoStreams.length > 0) {
|
||||
const video: any = videoStreams[0] as any;
|
||||
if ("Video" in video) {
|
||||
const width = (video as any).Video.width;
|
||||
const height = (video as any).Video.height;
|
||||
const codec = (video as any).Video.codec;
|
||||
if (width && height) {
|
||||
parts.push(`${width}x${height} ${codec}`);
|
||||
} else {
|
||||
parts.push(codec);
|
||||
const getFileIcon = (candidacy: FileCandidacy): ReactNode => {
|
||||
return match(candidacy)
|
||||
.with("Loading", () => (
|
||||
<Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
|
||||
))
|
||||
.with({ Error: P._ }, () => <XCircle className="w-5 h-5 text-red-400" />)
|
||||
.with({ Success: { type: P.select() } }, (mediaType: MediaType) => {
|
||||
switch (mediaType) {
|
||||
case "Audio":
|
||||
return <Music className="w-5 h-5 text-blue-400" />;
|
||||
case "Video":
|
||||
return <Film className="w-5 h-5 text-purple-400" />;
|
||||
case "Image":
|
||||
return <Image className="w-5 h-5 text-pink-400" />;
|
||||
case "Document":
|
||||
return <FileText className="w-5 h-5 text-green-400" />;
|
||||
case "Executable":
|
||||
return <FileIcon className="w-5 h-5 text-orange-400" />;
|
||||
case "Archive":
|
||||
return <FileIcon className="w-5 h-5 text-yellow-400" />;
|
||||
case "Library":
|
||||
return <FileIcon className="w-5 h-5 text-indigo-400" />;
|
||||
default:
|
||||
return <FileIcon className="w-5 h-5 text-neutral-300" />;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (audioStreams.length > 0) {
|
||||
const audio: any = audioStreams[0] as any;
|
||||
if ("Audio" in audio) {
|
||||
parts.push(`${(audio as any).Audio.codec} audio`);
|
||||
}
|
||||
}
|
||||
if (subtitleStreams.length > 0) {
|
||||
parts.push(`${subtitleStreams.length} subtitle(s)`);
|
||||
}
|
||||
|
||||
return parts.join(", ");
|
||||
})
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
const Item = ({
|
||||
icon,
|
||||
text,
|
||||
subtitle,
|
||||
status,
|
||||
}: {
|
||||
icon: ReactNode;
|
||||
text: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
status?: "success" | "error" | "loading";
|
||||
}) => {
|
||||
const statusColor =
|
||||
status === "success"
|
||||
? "border-green-500"
|
||||
: status === "error"
|
||||
? "border-red-500"
|
||||
: status === "loading"
|
||||
? "border-blue-500"
|
||||
: "border-neutral-600";
|
||||
const getStatusColor = (candidacy: FileCandidacy): string => {
|
||||
return match(candidacy)
|
||||
.with("Loading", () => "border-blue-500/50")
|
||||
.with({ Error: P._ }, () => "border-red-500/50")
|
||||
.with({ Success: P._ }, () => "border-green-500/50")
|
||||
.exhaustive();
|
||||
};
|
||||
|
||||
const FileItem = ({ file }: { file: File }) => {
|
||||
const icon = getFileIcon(file.candidacy);
|
||||
const statusColor = getStatusColor(file.candidacy);
|
||||
const fileSize = formatFileSize(file.size);
|
||||
|
||||
const subtitle = match(file.candidacy)
|
||||
.with("Loading", () => "Analyzing...")
|
||||
.with({ Error: { reason: P.select() } }, (reason: string) => reason)
|
||||
.with({ Success: { type: P.select() } }, (mediaType: MediaType) => {
|
||||
switch (mediaType) {
|
||||
case "Audio":
|
||||
return "Audio file";
|
||||
case "Video":
|
||||
return "Video file";
|
||||
case "Image":
|
||||
return "Image file";
|
||||
case "Document":
|
||||
return "Document file";
|
||||
case "Executable":
|
||||
return "Executable file";
|
||||
case "Archive":
|
||||
return "Archive file";
|
||||
case "Library":
|
||||
return "Library file";
|
||||
default:
|
||||
return "Unknown file type";
|
||||
}
|
||||
})
|
||||
.exhaustive();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-3 bg-neutral-800 rounded-lg shadow-lg border-2 ${statusColor} transition-all duration-200`}
|
||||
className={`flex items-center gap-3 px-4 py-3 rounded-lg bg-neutral-800 border ${statusColor} transition-all duration-200`}
|
||||
style={{
|
||||
maxWidth: "100%",
|
||||
marginBottom: "0.75rem",
|
||||
@@ -191,187 +101,92 @@ const Item = ({
|
||||
>
|
||||
{icon}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="truncate text-neutral-100 font-medium">{text}</div>
|
||||
{subtitle && (
|
||||
<div className="truncate text-neutral-400 text-sm mt-1">
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
<div className="truncate text-neutral-100 font-medium">
|
||||
{file.filename}
|
||||
</div>
|
||||
<div className="truncate text-neutral-400 text-sm mt-1">
|
||||
{fileSize} • {subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FileItem = ({
|
||||
filename,
|
||||
media_type,
|
||||
duration,
|
||||
size,
|
||||
streams,
|
||||
error,
|
||||
error_type,
|
||||
}: FileItemProps) => {
|
||||
const icon = getFileIcon(media_type, error, error_type);
|
||||
const fileSize = formatFileSize(size);
|
||||
|
||||
let subtitle: ReactNode;
|
||||
let status: "success" | "error" | "loading" | undefined;
|
||||
|
||||
if (error) {
|
||||
subtitle = error;
|
||||
// For non-media files, show as neutral instead of error
|
||||
status = error_type === "not_media" ? undefined : "error";
|
||||
} else {
|
||||
const streamInfo = getStreamInfo(streams, media_type);
|
||||
const durationStr = duration ? formatDuration(duration) : null;
|
||||
const details = [streamInfo, durationStr, fileSize].filter(
|
||||
Boolean,
|
||||
) as string[];
|
||||
subtitle = details.join(" • ");
|
||||
status = "success";
|
||||
}
|
||||
|
||||
return (
|
||||
<Item icon={icon} text={filename} subtitle={subtitle} status={status} />
|
||||
);
|
||||
};
|
||||
|
||||
const DropOverlay = ({ paths }: DropOverlayProps) => {
|
||||
const [state, setState] = useState<State>({ status: "hidden" });
|
||||
const aborterRef = useRef<AbortController | null>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (paths.length === 0) {
|
||||
setState({ status: "hidden" });
|
||||
setFiles([]);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setState({ status: "loading", count: paths.length });
|
||||
setIsLoading(true);
|
||||
setFiles([]);
|
||||
|
||||
aborterRef.current = new AbortController();
|
||||
|
||||
commands.hasStreams(paths).then((result) => {
|
||||
setState((_state) => {
|
||||
return match(result)
|
||||
.with({ status: "ok" }, (r) => ({
|
||||
status: "ready" as const,
|
||||
files: r.data.map((item) => ({
|
||||
name: item.filename,
|
||||
key: item.path,
|
||||
media_type: item.media_type,
|
||||
duration: item.duration,
|
||||
size: Number(item.size),
|
||||
streams: item.streams,
|
||||
})),
|
||||
}))
|
||||
.with({ status: "error" }, (r) => {
|
||||
if (r.error.filename) {
|
||||
return {
|
||||
status: "error" as const,
|
||||
reason: r.error.reason,
|
||||
filename: r.error.filename,
|
||||
error_type: r.error.error_type,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "error" as const,
|
||||
reason: r.error.reason,
|
||||
error_type: r.error.error_type,
|
||||
};
|
||||
})
|
||||
.exhaustive();
|
||||
});
|
||||
// Initialize with loading state for all files
|
||||
const loadingFiles: File[] = paths.map((path) => {
|
||||
const filename = path.split(/[/\\]/).pop() || "unknown";
|
||||
return {
|
||||
filename,
|
||||
size: 0,
|
||||
candidacy: "Loading" as const,
|
||||
};
|
||||
});
|
||||
setFiles(loadingFiles);
|
||||
|
||||
// Analyze files
|
||||
commands
|
||||
.analyzeFiles(paths)
|
||||
.then((analyzedFiles) => {
|
||||
setFiles(analyzedFiles);
|
||||
setIsLoading(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to analyze files:", error);
|
||||
// Set all files to error state
|
||||
const errorFiles: File[] = paths.map((path) => {
|
||||
const filename = path.split(/[/\\]/).pop() || "unknown";
|
||||
return {
|
||||
filename,
|
||||
size: 0,
|
||||
candidacy: {
|
||||
Error: {
|
||||
reason: "Failed to analyze file",
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
setFiles(errorFiles);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [paths]);
|
||||
|
||||
if (state.status === "hidden") {
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inner = match(state)
|
||||
.with({ status: "loading" }, ({ count }) => (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Loader2 className="w-8 h-8 text-blue-400 animate-spin" />
|
||||
<div className="text-white text-lg font-medium">
|
||||
Analyzing {count} file{count > 1 ? "s" : ""}...
|
||||
</div>
|
||||
{Array.from({ length: Math.min(count, 3) }).map((_, i) => (
|
||||
<Item
|
||||
key={i}
|
||||
icon={
|
||||
<Loader2 className="w-5 h-5 text-neutral-300/50 animate-spin" />
|
||||
}
|
||||
text={
|
||||
<span className="inline-block w-32 h-5 bg-neutral-300/10 rounded animate-pulse" />
|
||||
}
|
||||
status="loading"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))
|
||||
.with({ status: "ready" }, (r) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-green-400">
|
||||
<CheckCircle className="w-6 h-6" />
|
||||
<span className="text-lg font-medium">Files Ready</span>
|
||||
</div>
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
{r.files.slice(0, 8).map((file) => (
|
||||
<FileItem
|
||||
key={file.key}
|
||||
filename={file.name}
|
||||
media_type={file.media_type}
|
||||
duration={file.duration}
|
||||
size={file.size}
|
||||
streams={file.streams}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
.with({ status: "error", filename: P.string }, (r) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-red-400">
|
||||
<XCircle className="w-6 h-6" />
|
||||
<span className="text-lg font-medium">Error</span>
|
||||
</div>
|
||||
<FileItem
|
||||
filename={r.filename}
|
||||
media_type="Unknown"
|
||||
size={0}
|
||||
streams={[]}
|
||||
error={r.reason}
|
||||
error_type={r.error_type}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
.with({ status: "error" }, ({ reason }) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex items-center gap-2 text-red-400">
|
||||
<XCircle className="w-6 h-6" />
|
||||
<span className="text-lg font-medium">Error</span>
|
||||
</div>
|
||||
<Item
|
||||
icon={<XCircle className="w-5 h-5 text-red-400" />}
|
||||
text={reason}
|
||||
status="error"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
.exhaustive();
|
||||
|
||||
return (
|
||||
<div className="absolute z-10 top-0 left-0 w-full h-full bg-black/60 backdrop-blur-sm transition-all duration-300 ease-in-out">
|
||||
<div className="absolute z-10 top-0 left-0 w-full h-full backdrop-blur-[1px] backdrop-saturate-0 transition-all duration-300 ease-in-out">
|
||||
<div className="flex flex-col justify-center items-center h-full p-8">
|
||||
<div className="bg-neutral-900 rounded-xl p-6 shadow-2xl max-w-2xl w-full">
|
||||
{inner}
|
||||
<div className="rounded-xl p-6 max-w-2xl w-full">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-blue-400 mb-4">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
<span className="text-lg font-medium">
|
||||
Analyzing {files.length} file{files.length > 1 ? "s" : ""}...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-96 overflow-y-auto w-full">
|
||||
{files.map((file, index) => (
|
||||
<FileItem key={`${file.filename}-${index}`} file={file} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,11 +51,11 @@ const Graph = ({ data }: GraphProps) => (
|
||||
fill: "#6e6a86",
|
||||
},
|
||||
}}
|
||||
axisBottom={{ legend: "transportation", legendOffset: 36 }}
|
||||
axisBottom={{ legend: "Frame Number", legendOffset: 36 }}
|
||||
axisLeft={{
|
||||
legend: "count",
|
||||
legend: "Packet Size",
|
||||
legendOffset: -40,
|
||||
format: (v) => formatBytes(v * 1024 * 53),
|
||||
format: (v) => formatBytes(v),
|
||||
}}
|
||||
pointSize={10}
|
||||
colors={["#3e8faf", "#c4a7e7", "#f5c276", "#EA9B96", "#EB7092", "#9CCFD8"]}
|
||||
|
||||
@@ -7,14 +7,13 @@ export function useDragDropPaths(): string[] {
|
||||
useEffect(() => {
|
||||
const unlistenPromise = getCurrentWebview().onDragDropEvent(
|
||||
async ({ payload }) => {
|
||||
if (payload.type === "enter") {
|
||||
if (payload.type === "drop") {
|
||||
setPaths(payload.paths);
|
||||
} else if (payload.type === "leave" || payload.type === "drop") {
|
||||
} else if (payload.type === "leave") {
|
||||
setPaths([]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
unlistenPromise.then((unlisten) => unlisten());
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user