feat: invoke bindings generation, drop overlay preview

This commit is contained in:
2025-07-14 13:44:18 -05:00
parent 2e03281a79
commit 2f41943959
8 changed files with 420 additions and 58 deletions
+2 -2
View File
@@ -4,8 +4,8 @@ type Frame = {
};
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { useEffect, useRef, useState } from "react";
import Graph from "./components/Graph.js";
import { useEffect, useState } from "react";
import Graph from "./components/graph.js";
import DropOverlay from "./components/drop-overlay.js";
function App() {
+90
View File
@@ -0,0 +1,90 @@
// This file was generated by [tauri-specta](https://github.com/oscartbeaumont/tauri-specta). Do not edit this file manually.
/** user-defined commands **/
export const commands = {
async hasStreams(paths: string[]) : Promise<Result<StreamResult[], StreamResultError>> {
try {
return { status: "ok", data: await TAURI_INVOKE("has_streams", { paths }) };
} catch (e) {
if(e instanceof Error) throw e;
else return { status: "error", error: e as any };
}
}
}
/** user-defined events **/
/** user-defined constants **/
/** user-defined types **/
export type StreamDetail = { Video: { codec: string } } | { Audio: { codec: string } } | { Subtitle: { codec: string } }
export type StreamResult = { path: string; filename: string; streams: StreamDetail[] }
export type StreamResultError = { filename: string | null; reason: string }
/** tauri-specta globals **/
import {
invoke as TAURI_INVOKE,
Channel as TAURI_CHANNEL,
} from "@tauri-apps/api/core";
import * as TAURI_API_EVENT from "@tauri-apps/api/event";
import { type WebviewWindow as __WebviewWindow__ } from "@tauri-apps/api/webviewWindow";
type __EventObj__<T> = {
listen: (
cb: TAURI_API_EVENT.EventCallback<T>,
) => ReturnType<typeof TAURI_API_EVENT.listen<T>>;
once: (
cb: TAURI_API_EVENT.EventCallback<T>,
) => ReturnType<typeof TAURI_API_EVENT.once<T>>;
emit: null extends T
? (payload?: T) => ReturnType<typeof TAURI_API_EVENT.emit>
: (payload: T) => ReturnType<typeof TAURI_API_EVENT.emit>;
};
export type Result<T, E> =
| { status: "ok"; data: T }
| { status: "error"; error: E };
function __makeEvents__<T extends Record<string, any>>(
mappings: Record<keyof T, string>,
) {
return new Proxy(
{} as unknown as {
[K in keyof T]: __EventObj__<T[K]> & {
(handle: __WebviewWindow__): __EventObj__<T[K]>;
};
},
{
get: (_, event) => {
const name = mappings[event as keyof T];
return new Proxy((() => {}) as any, {
apply: (_, __, [window]: [__WebviewWindow__]) => ({
listen: (arg: any) => window.listen(name, arg),
once: (arg: any) => window.once(name, arg),
emit: (arg: any) => window.emit(name, arg),
}),
get: (_, command: keyof __EventObj__<any>) => {
switch (command) {
case "listen":
return (arg: any) => TAURI_API_EVENT.listen(name, arg);
case "once":
return (arg: any) => TAURI_API_EVENT.once(name, arg);
case "emit":
return (arg: any) => TAURI_API_EVENT.emit(name, arg);
}
},
});
},
},
);
}
+136 -34
View File
@@ -1,46 +1,148 @@
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { ReactNode, useEffect, useRef, useState } from "react";
import { match, P } from "ts-pattern";
type DropOverlayProps = {
paths: string[];
paths: string[];
};
type Status = "hidden" | "loading" | "ready" | "error";
type State =
| { status: "hidden" }
| { status: "loading"; count: number }
| { status: "ready"; files: { name: string; key: string }[] }
| { status: "error"; reason: string; filename?: string };
import {
CircleQuestionMarkIcon,
File as FileIcon,
Film,
Image,
Music,
} from "lucide-react";
import { commands } from "../bindings";
type FileItemProps = {
filename: string;
error?: string;
};
const Item = ({ icon, text }: { icon: ReactNode; text: ReactNode }) => {
return (
<div
className="flex items-center gap-2 px-3 py-2 bg-neutral-800 rounded-md shadow-sm"
style={{
maxWidth: "100%",
marginBottom: "0.5rem",
}}
>
{icon}
<span className="truncate text-neutral-100 max-w-md">{text}</span>
</div>
);
};
const FileItem = ({ filename, error }: FileItemProps) => {
const ext = filename.split(".").pop()?.toLowerCase();
const icon =
error == null ? (
match(ext)
.with("mp3", "wav", "flac", "ogg", "m4a", "aac", () => (
<Music className="w-5 h-5 text-blue-400" />
))
.with("mp4", "mkv", "webm", "mov", "avi", () => (
<Film className="w-5 h-5 text-purple-400" />
))
.with("gif", () => <Image className="w-5 h-5 text-pink-400" />)
.otherwise(() => <FileIcon className="w-5 h-5 text-neutral-300" />)
) : (
<CircleQuestionMarkIcon className="w-5 h-5 text-neutral-300" />
);
return <Item icon={icon} text={filename} />;
};
const DropOverlay = ({ paths }: DropOverlayProps) => {
const [status, setStatus] = useState<Status>("hidden");
const [state, setState] = useState<State>({ status: "hidden" });
const aborterRef = useRef<AbortController | null>(null);
useEffect(() => {
if (paths.length === 0) {
setStatus("hidden");
return;
}
useEffect(() => {
if (paths.length === 0) {
setState({ status: "hidden" });
return;
}
setStatus("loading");
invoke("has_streams", { paths }).then((result) => {
setStatus(result ? "ready" : "error");
});
}, [paths]);
setState({ status: "loading", count: paths.length });
return (
<div
className={`absolute z-10 top-0 left-0 w-full h-full transition-[opacity] bg-black/20 duration-200 ease-in-out ${
status === "hidden" ? "opacity-0 pointer-events-none" : "opacity-100"
}`}
>
<div className="flex flex-col items-center justify-center shadow h-full">
<div className="text-2xl font-bold text-zinc-200">
{status === "loading"
? "Loading..."
: status === "ready"
? "Ready"
: status === "error"
? "Error"
: "Hidden"}
</div>
</div>
</div>
);
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,
})),
}))
.with({ status: "error" }, (r) => {
if (r.error.filename) {
return {
status: "error" as const,
reason: r.error.reason,
filename: r.error.filename,
};
}
return { status: "error" as const, reason: r.error.reason };
})
.exhaustive();
});
});
}, [paths]);
if (state.status === "hidden") {
return null;
}
const inner = match(state)
.with({ status: "loading" }, ({ count }) =>
Array.from({ length: count }).map((_, i) => (
<Item
key={i}
icon={
<CircleQuestionMarkIcon className="w-5 h-5 text-neutral-300/50" />
}
text={
<span className="inline-block w-32 h-5 bg-neutral-300/10 rounded animate-pulse" />
}
/>
))
)
.with({ status: "ready" }, (r) => {
return r.files
.slice(0, 8)
.map((file) => <FileItem key={file.key} filename={file.name} />);
})
.with({ status: "error", filename: P.string }, (r) => {
return <FileItem filename={r.filename} error={r.reason} />;
})
.with({ status: "error" }, ({ reason }) => {
return (
<Item
icon={<CircleQuestionMarkIcon className="w-5 h-5 text-neutral-300" />}
text={reason}
/>
);
})
.exhaustive();
return (
<div className="absolute z-10 top-0 left-0 w-full h-full bg-black/40 backdrop-blur-sm transition-all duration-300 ease-in-out">
<div className="flex flex-col justify-center items-center h-full">
<span className="text-white text-2xl">{inner}</span>
</div>
</div>
);
};
export default DropOverlay;