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:
Ryan Walters
2025-10-24 00:30:35 -05:00
parent 22d73fabfd
commit 9645e1b6b5
7 changed files with 390 additions and 327 deletions

View File

@@ -5,9 +5,10 @@ pub mod strings;
use ff::extract_streams; use ff::extract_streams;
use media::{detect_media_type, is_media_file}; use media::{detect_media_type, is_media_file};
use models::{StreamResult, StreamResultError}; use models::{StreamResult, StreamResultError, File, FileCandidacy, BitrateData, BitrateFrame};
use strings::transform_filename; use strings::transform_filename;
use std::path::Path; use std::path::Path;
use std::process::Command;
use tracing::{debug, error, info, instrument, warn}; use tracing::{debug, error, info, instrument, warn};
// detection, helpers moved to modules above // detection, helpers moved to modules above
@@ -132,12 +133,174 @@ fn has_streams(paths: Vec<String>) -> Result<Vec<StreamResult>, StreamResultErro
results results
} }
#[tauri::command]
#[instrument(skip(paths), fields(file_count = paths.len()))]
fn analyze_files(paths: Vec<String>) -> Vec<File> {
info!(file_count = paths.len(), "Analyzing files for candidacy");
paths
.into_iter()
.enumerate()
.map(|(index, path_str)| {
let path = Path::new(&path_str);
let filename = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("unknown")
.to_string();
// Log full path only on first occurrence, then use truncated filename
if index == 0 {
debug!(full_path = %path_str, filename = %filename, "Processing first file");
} else {
let truncated_name = transform_filename(&filename, 15);
debug!(filename = %truncated_name, "Processing file");
}
// Get file size
let size = std::fs::metadata(&path_str)
.map(|metadata| metadata.len())
.unwrap_or(0) as u32;
let truncated_name = transform_filename(&filename, 15);
debug!(filename = %truncated_name, size = size, "File metadata retrieved");
// Check if file exists
if !path.exists() {
let truncated_name = transform_filename(&filename, 15);
warn!(filename = %truncated_name, "File does not exist");
return File {
filename,
size,
candidacy: FileCandidacy::Error {
reason: "File does not exist".to_string(),
},
};
}
// Check if it's a file (not directory)
if !path.is_file() {
let truncated_name = transform_filename(&filename, 15);
warn!(filename = %truncated_name, "Path is not a file");
return File {
filename,
size,
candidacy: FileCandidacy::Error {
reason: "Not a file (directory or other)".to_string(),
},
};
}
// Detect media type using magic numbers and fallback to extensions
let media_type = detect_media_type(path);
debug!(filename = %truncated_name, media_type = ?media_type, "Media type detected");
// Check if it's a media file
if is_media_file(&media_type) {
info!(filename = %truncated_name, media_type = ?media_type, "Valid media file detected");
File {
filename,
size,
candidacy: FileCandidacy::Success {
file_type: media_type,
},
}
} else {
debug!(filename = %truncated_name, media_type = ?media_type, "Non-media file detected");
File {
filename,
size,
candidacy: FileCandidacy::Error {
reason: format!("Not a media file (detected as {media_type:?})"),
},
}
}
})
.collect()
}
#[tauri::command]
#[instrument(skip(path), fields(path = %path))]
fn extract_bitrate_data(path: String) -> Result<BitrateData, String> {
info!(path = %path, "Extracting bitrate data from video file");
let path_obj = Path::new(&path);
let filename = path_obj
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("unknown")
.to_string();
// Check if file exists
if !path_obj.exists() {
error!(filename = %filename, "File does not exist");
return Err("File does not exist".to_string());
}
// Run ffprobe to get frame packet sizes
// -v quiet: suppress ffprobe info
// -select_streams v:0: only first video stream
// -show_entries frame=pkt_size: only show packet size
// -of csv=p=0: output as CSV without headers
info!(filename = %filename, "Running ffprobe to extract frame data");
let output = Command::new("ffprobe")
.args([
"-v", "quiet",
"-select_streams", "v:0",
"-show_entries", "frame=pkt_size",
"-of", "csv=p=0",
&path
])
.output()
.map_err(|e| {
error!(error = %e, "Failed to execute ffprobe");
format!("Failed to execute ffprobe: {e}")
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
error!(stderr = %stderr, "ffprobe command failed");
return Err(format!("ffprobe failed: {stderr}"));
}
let stdout = String::from_utf8_lossy(&output.stdout);
debug!(line_count = stdout.lines().count(), "Parsing ffprobe output");
let frames: Vec<BitrateFrame> = stdout
.lines()
.enumerate()
.filter_map(|(index, line)| {
line.trim().parse::<u64>().ok().map(|packet_size| BitrateFrame {
frame_num: index as u32,
packet_size,
})
})
.collect();
if frames.is_empty() {
warn!(filename = %filename, "No frame data extracted");
return Err("No frame data could be extracted from file".to_string());
}
info!(
filename = %filename,
frame_count = frames.len(),
"Successfully extracted bitrate data"
);
Ok(BitrateData {
id: filename,
frames,
})
}
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
info!("Initializing Tauri application"); info!("Initializing Tauri application");
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![has_streams]) .invoke_handler(tauri::generate_handler![has_streams, analyze_files, extract_bitrate_data])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View File

@@ -51,6 +51,39 @@ pub struct StreamResultError {
pub error_type: String, pub error_type: String,
} }
// New types for simplified drop overlay
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
pub struct File {
pub filename: String,
pub size: u32,
pub candidacy: FileCandidacy,
}
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
pub enum FileCandidacy {
Success {
#[serde(rename = "type")]
file_type: MediaType,
},
Error {
reason: String,
},
Loading,
}
// Bitrate visualization types
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
pub struct BitrateFrame {
pub frame_num: u32,
pub packet_size: u64,
}
#[derive(Serialize, Deserialize, Debug, Clone, TS)]
pub struct BitrateData {
pub id: String,
pub frames: Vec<BitrateFrame>,
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[test] #[test]
@@ -62,5 +95,9 @@ mod tests {
StreamResult::export_all_to("../src/bindings").expect("Failed to export bindings"); StreamResult::export_all_to("../src/bindings").expect("Failed to export bindings");
StreamResultError::export_all_to("../src/bindings").expect("Failed to export bindings"); StreamResultError::export_all_to("../src/bindings").expect("Failed to export bindings");
MediaType::export_all_to("../src/bindings").expect("Failed to export bindings"); MediaType::export_all_to("../src/bindings").expect("Failed to export bindings");
File::export_all_to("../src/bindings").expect("Failed to export bindings");
FileCandidacy::export_all_to("../src/bindings").expect("Failed to export bindings");
BitrateFrame::export_all_to("../src/bindings").expect("Failed to export bindings");
BitrateData::export_all_to("../src/bindings").expect("Failed to export bindings");
} }
} }

View File

@@ -1,13 +1,45 @@
import { useEffect, useState } from "react";
import { useDragDropPaths } from "@/hooks/useDragDropPaths"; import { useDragDropPaths } from "@/hooks/useDragDropPaths";
import Graph from "@/components/graph"; import Graph from "@/components/graph";
import DropOverlay from "@/components/drop-overlay"; import DropOverlay from "@/components/drop-overlay";
import type { Frame } from "@/types/graph"; import type { Frame } from "@/types/graph";
import { commands } from "@/bindings";
import type { BitrateData } from "@/bindings";
function App() { function App() {
const data: Frame[] = []; const [data, setData] = useState<Frame[]>([]);
const [isLoading, setIsLoading] = useState(false);
const paths = useDragDropPaths(); 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} />; const graph = <Graph data={data} />;
return ( return (
@@ -17,6 +49,11 @@ function App() {
style={{ "--wails-drop-target": "drop" } as React.CSSProperties} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
> >
<DropOverlay paths={paths} /> <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} {graph}
</div> </div>
); );

View File

@@ -3,7 +3,11 @@ import type { StreamResult } from "@/bindings/StreamResult";
import type { StreamDetail } from "@/bindings/StreamDetail"; import type { StreamDetail } from "@/bindings/StreamDetail";
import type { StreamResultError } from "@/bindings/StreamResultError"; import type { StreamResultError } from "@/bindings/StreamResultError";
import type { MediaType } from "@/bindings/MediaType"; 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 // Tauri invoke wrapper
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
@@ -21,5 +25,13 @@ export const commands = {
if (e instanceof Error) throw e; if (e instanceof Error) throw e;
else return { status: "error", error: e as any }; 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 });
} }
}; };

View File

@@ -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 { match, P } from "ts-pattern";
import { import {
CheckCircle,
File as FileIcon, File as FileIcon,
FileText, FileText,
Film, Film,
@@ -11,38 +10,12 @@ import {
XCircle, XCircle,
} from "lucide-react"; } from "lucide-react";
import { commands } from "@/bindings"; import { commands } from "@/bindings";
import type { MediaType, StreamDetail } from "@/bindings"; import type { File, FileCandidacy, MediaType } from "@/bindings";
type DropOverlayProps = { type DropOverlayProps = {
paths: string[]; 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 => { const formatFileSize = (bytes: number): string => {
if (bytes === 0) return "0 B"; if (bytes === 0) return "0 B";
const k = 1024; const k = 1024;
@@ -51,139 +24,76 @@ const formatFileSize = (bytes: number): string => {
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
}; };
const formatDuration = (seconds: number): string => { const getFileIcon = (candidacy: FileCandidacy): ReactNode => {
const hours = Math.floor(seconds / 3600); return match(candidacy)
const minutes = Math.floor((seconds % 3600) / 60); .with("Loading", () => (
const secs = Math.floor(seconds % 60); <Loader2 className="w-5 h-5 text-blue-400 animate-spin" />
))
if (hours > 0) { .with({ Error: P._ }, () => <XCircle className="w-5 h-5 text-red-400" />)
return `${hours}:${minutes.toString().padStart(2, "0")}:${secs .with({ Success: { type: P.select() } }, (mediaType: MediaType) => {
.toString() switch (mediaType) {
.padStart(2, "0")}`; case "Audio":
} return <Music className="w-5 h-5 text-blue-400" />;
return `${minutes}:${secs.toString().padStart(2, "0")}`; case "Video":
}; return <Film className="w-5 h-5 text-purple-400" />;
case "Image":
const getFileIcon = ( return <Image className="w-5 h-5 text-pink-400" />;
mediaType: MediaType, case "Document":
error?: string, return <FileText className="w-5 h-5 text-green-400" />;
errorType?: string, case "Executable":
) => { return <FileIcon className="w-5 h-5 text-orange-400" />;
// For non-media files, show a neutral icon instead of error icon case "Archive":
if (errorType === "not_media") { return <FileIcon className="w-5 h-5 text-yellow-400" />;
switch (mediaType) { case "Library":
case "Executable": return <FileIcon className="w-5 h-5 text-indigo-400" />;
return <FileIcon className="w-5 h-5 text-orange-400" />; default:
case "Archive": return <FileIcon className="w-5 h-5 text-neutral-300" />;
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);
} }
} })
} .exhaustive();
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(", ");
}; };
const Item = ({ const getStatusColor = (candidacy: FileCandidacy): string => {
icon, return match(candidacy)
text, .with("Loading", () => "border-blue-500/50")
subtitle, .with({ Error: P._ }, () => "border-red-500/50")
status, .with({ Success: P._ }, () => "border-green-500/50")
}: { .exhaustive();
icon: ReactNode; };
text: ReactNode;
subtitle?: ReactNode; const FileItem = ({ file }: { file: File }) => {
status?: "success" | "error" | "loading"; const icon = getFileIcon(file.candidacy);
}) => { const statusColor = getStatusColor(file.candidacy);
const statusColor = const fileSize = formatFileSize(file.size);
status === "success"
? "border-green-500" const subtitle = match(file.candidacy)
: status === "error" .with("Loading", () => "Analyzing...")
? "border-red-500" .with({ Error: { reason: P.select() } }, (reason: string) => reason)
: status === "loading" .with({ Success: { type: P.select() } }, (mediaType: MediaType) => {
? "border-blue-500" switch (mediaType) {
: "border-neutral-600"; 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 ( return (
<div <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={{ style={{
maxWidth: "100%", maxWidth: "100%",
marginBottom: "0.75rem", marginBottom: "0.75rem",
@@ -191,187 +101,92 @@ const Item = ({
> >
{icon} {icon}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="truncate text-neutral-100 font-medium">{text}</div> <div className="truncate text-neutral-100 font-medium">
{subtitle && ( {file.filename}
<div className="truncate text-neutral-400 text-sm mt-1"> </div>
{subtitle} <div className="truncate text-neutral-400 text-sm mt-1">
</div> {fileSize} {subtitle}
)} </div>
</div> </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 DropOverlay = ({ paths }: DropOverlayProps) => {
const [state, setState] = useState<State>({ status: "hidden" }); const [files, setFiles] = useState<File[]>([]);
const aborterRef = useRef<AbortController | null>(null); const [isLoading, setIsLoading] = useState(false);
useEffect(() => { useEffect(() => {
if (paths.length === 0) { if (paths.length === 0) {
setState({ status: "hidden" }); setFiles([]);
setIsLoading(false);
return; return;
} }
setState({ status: "loading", count: paths.length }); setIsLoading(true);
setFiles([]);
aborterRef.current = new AbortController(); // Initialize with loading state for all files
const loadingFiles: File[] = paths.map((path) => {
commands.hasStreams(paths).then((result) => { const filename = path.split(/[/\\]/).pop() || "unknown";
setState((_state) => { return {
return match(result) filename,
.with({ status: "ok" }, (r) => ({ size: 0,
status: "ready" as const, candidacy: "Loading" 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();
});
}); });
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]); }, [paths]);
if (state.status === "hidden") { if (files.length === 0) {
return null; 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 ( 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="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"> <div className="rounded-xl p-6 max-w-2xl w-full">
{inner} <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> </div>
</div> </div>

View File

@@ -51,11 +51,11 @@ const Graph = ({ data }: GraphProps) => (
fill: "#6e6a86", fill: "#6e6a86",
}, },
}} }}
axisBottom={{ legend: "transportation", legendOffset: 36 }} axisBottom={{ legend: "Frame Number", legendOffset: 36 }}
axisLeft={{ axisLeft={{
legend: "count", legend: "Packet Size",
legendOffset: -40, legendOffset: -40,
format: (v) => formatBytes(v * 1024 * 53), format: (v) => formatBytes(v),
}} }}
pointSize={10} pointSize={10}
colors={["#3e8faf", "#c4a7e7", "#f5c276", "#EA9B96", "#EB7092", "#9CCFD8"]} colors={["#3e8faf", "#c4a7e7", "#f5c276", "#EA9B96", "#EB7092", "#9CCFD8"]}

View File

@@ -7,14 +7,13 @@ export function useDragDropPaths(): string[] {
useEffect(() => { useEffect(() => {
const unlistenPromise = getCurrentWebview().onDragDropEvent( const unlistenPromise = getCurrentWebview().onDragDropEvent(
async ({ payload }) => { async ({ payload }) => {
if (payload.type === "enter") { if (payload.type === "drop") {
setPaths(payload.paths); setPaths(payload.paths);
} else if (payload.type === "leave" || payload.type === "drop") { } else if (payload.type === "leave") {
setPaths([]); setPaths([]);
} }
}, },
); );
return () => { return () => {
unlistenPromise.then((unlisten) => unlisten()); unlistenPromise.then((unlisten) => unlisten());
}; };