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

View File

@@ -15,9 +15,11 @@
"@tailwindcss/vite": "^4.1.11",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"lucide-react": "^0.525.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"tailwindcss": "^4.1.11"
"tailwindcss": "^4.1.11",
"ts-pattern": "^5.7.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2",

20
pnpm-lock.yaml generated
View File

@@ -23,6 +23,9 @@ importers:
'@tauri-apps/plugin-opener':
specifier: ^2
version: 2.4.0
lucide-react:
specifier: ^0.525.0
version: 0.525.0(react@18.3.1)
react:
specifier: ^18.3.1
version: 18.3.1
@@ -32,6 +35,9 @@ importers:
tailwindcss:
specifier: ^4.1.11
version: 4.1.11
ts-pattern:
specifier: ^5.7.1
version: 5.7.1
devDependencies:
'@tauri-apps/cli':
specifier: ^2
@@ -951,6 +957,11 @@ packages:
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
lucide-react@0.525.0:
resolution: {integrity: sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==}
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
@@ -1042,6 +1053,9 @@ packages:
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
engines: {node: '>=12.0.0'}
ts-pattern@5.7.1:
resolution: {integrity: sha512-EGs8PguQqAAUIcQfK4E9xdXxB6s2GK4sJfT/vcc9V1ELIvC4LH/zXu2t/5fajtv6oiRCxdv7BgtVK3vWgROxag==}
typescript@5.6.3:
resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==}
engines: {node: '>=14.17'}
@@ -1948,6 +1962,10 @@ snapshots:
dependencies:
yallist: 3.1.1
lucide-react@0.525.0(react@18.3.1):
dependencies:
react: 18.3.1
magic-string@0.30.17:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.4
@@ -2047,6 +2065,8 @@ snapshots:
fdir: 6.4.6(picomatch@4.0.2)
picomatch: 4.0.2
ts-pattern@5.7.1: {}
typescript@5.6.3: {}
update-browserslist-db@1.1.3(browserslist@4.25.1):

90
src-tauri/Cargo.lock generated
View File

@@ -1,6 +1,12 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
version = 4
[[package]]
name = "Inflector"
version = "0.11.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
[[package]]
name = "addr2line"
@@ -346,9 +352,12 @@ dependencies = [
"ffprobe",
"serde",
"serde_json",
"specta",
"specta-typescript",
"tauri",
"tauri-build",
"tauri-plugin-opener",
"tauri-specta",
]
[[package]]
@@ -2460,6 +2469,12 @@ dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -3394,6 +3409,50 @@ dependencies = [
"system-deps",
]
[[package]]
name = "specta"
version = "2.0.0-rc.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab7f01e9310a820edd31c80fde3cae445295adde21a3f9416517d7d65015b971"
dependencies = [
"paste",
"specta-macros",
"thiserror 1.0.69",
]
[[package]]
name = "specta-macros"
version = "2.0.0-rc.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0074b9e30ed84c6924eb63ad8d2fe71cdc82628525d84b1fcb1f2fd40676517"
dependencies = [
"Inflector",
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]]
name = "specta-serde"
version = "0.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77216504061374659e7245eac53d30c7b3e5fe64b88da97c753e7184b0781e63"
dependencies = [
"specta",
"thiserror 1.0.69",
]
[[package]]
name = "specta-typescript"
version = "0.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3220a0c365e51e248ac98eab5a6a32f544ff6f961906f09d3ee10903a4f52b2d"
dependencies = [
"specta",
"specta-serde",
"thiserror 1.0.69",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
@@ -3592,6 +3651,7 @@ dependencies = [
"serde_json",
"serde_repr",
"serialize-to-javascript",
"specta",
"swift-rs",
"tauri-build",
"tauri-macros",
@@ -3760,6 +3820,34 @@ dependencies = [
"wry",
]
[[package]]
name = "tauri-specta"
version = "2.0.0-rc.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b23c0132dd3cf6064e5cd919b82b3f47780e9280e7b5910babfe139829b76655"
dependencies = [
"heck 0.5.0",
"serde",
"serde_json",
"specta",
"specta-typescript",
"tauri",
"tauri-specta-macros",
"thiserror 2.0.12",
]
[[package]]
name = "tauri-specta-macros"
version = "2.0.0-rc.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a4aa93823e07859546aa796b8a5d608190cd8037a3a5dce3eb63d491c34bda8"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]]
name = "tauri-utils"
version = "2.5.0"

View File

@@ -18,9 +18,12 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2.0", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ffprobe = "0.4.0"
specta = "=2.0.0-rc.22"
specta-typescript = "0.0.9"
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }

View File

@@ -1,34 +1,91 @@
use serde::{Deserialize, Serialize};
use specta::Type;
use specta_typescript::Typescript;
use std::path::Path;
use tauri_specta::{collect_commands, Builder};
#[derive(Serialize, Deserialize, Debug, Clone, Type)]
struct StreamResult {
path: String,
filename: String,
streams: Vec<StreamDetail>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Type)]
enum StreamDetail {
Video { codec: String },
Audio { codec: String },
Subtitle { codec: String },
}
#[derive(Serialize, Deserialize, Debug, Clone, Type)]
struct StreamResultError {
filename: Option<String>,
reason: String,
}
#[tauri::command]
fn has_streams(paths: Vec<String>) -> Result<Vec<bool>, String> {
let mut results = Vec::with_capacity(paths.len());
for path_str in paths {
let path = Path::new(&path_str);
if !path.is_file() {
results.push(false);
continue;
}
#[specta::specta]
fn has_streams(paths: Vec<String>) -> Result<Vec<StreamResult>, StreamResultError> {
paths
.into_iter()
.map(|path_str| {
let path = Path::new(&path_str);
let filename = path.file_name().unwrap().to_str().unwrap().to_string();
match ffprobe::ffprobe(&path_str) {
Ok(info) => {
dbg!(info);
results.push(true);
},
Err(err) => {
eprintln!("Could not analyze file with ffprobe: {:?}", err);
results.push(false);
if !path.exists() {
return Err(StreamResultError {
filename: Some(filename),
reason: "File does not exist".to_string(),
});
}
}
}
Ok(results)
if !path.is_file() {
return Err(StreamResultError {
filename: Some(filename),
reason: "Not a file".to_string(),
});
}
match ffprobe::ffprobe(&path_str) {
Ok(info) => {
dbg!(info);
Ok(StreamResult {
filename,
path: path_str,
streams: vec![],
})
}
Err(err) => {
eprintln!("Could not analyze file with ffprobe: {:?}", err);
Err(StreamResultError {
filename: Some(filename),
reason: "Could not analyze file with ffprobe".to_string(),
})
}
}
})
.collect::<Result<Vec<_>, _>>()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = Builder::<tauri::Wry>::new()
// Then register them (separated by a comma)
.commands(collect_commands![has_streams,]);
#[cfg(debug_assertions)] // <- Only export on non-release builds
builder
.export(Typescript::default(), "../src/bindings.ts")
.expect("Failed to export typescript bindings");
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![has_streams])
.setup(move |app| {
// Ensure you mount your events!
builder.mount_events(app);
Ok(())
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

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
src/bindings.ts Normal file
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);
}
},
});
},
},
);
}

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;