mirror of
https://github.com/neosubhamoy/neodlp.git
synced 2026-03-22 19:25:49 +05:30
(chore): initial MVP release v0.1.0
This commit is contained in:
82
src/components/custom/formatSelectionGroup.tsx
Normal file
82
src/components/custom/formatSelectionGroup.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { VideoFormat } from "@/types/video"
|
||||
import { determineFileType, formatBitrate, formatCodec, formatFileSize } from "@/utils"
|
||||
import { File, Music, Video } from "lucide-react"
|
||||
|
||||
interface FormatSelectionGroupItemProps extends
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> {
|
||||
format: VideoFormat
|
||||
}
|
||||
|
||||
const FormatSelectionGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormatSelectionGroup.displayName = "FormatSelectionGroup"
|
||||
|
||||
const FormatSelectionGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
FormatSelectionGroupItemProps
|
||||
>(({ className, format, ...props }, ref) => {
|
||||
const determineFileTypeIcon = (format: VideoFormat) => {
|
||||
const fileFormat = determineFileType(/*format.video_ext, format.audio_ext,*/ format.vcodec, format.acodec)
|
||||
switch (fileFormat) {
|
||||
case 'video+audio':
|
||||
return (
|
||||
<span className="absolute flex items-center right-2 bottom-2">
|
||||
<Video className="w-3 h-3 mr-2" />
|
||||
<Music className="w-3 h-3" />
|
||||
</span>
|
||||
)
|
||||
case 'video':
|
||||
return (
|
||||
<Video className="w-3 h-3 absolute right-2 bottom-2" />
|
||||
)
|
||||
case 'audio':
|
||||
return (
|
||||
<Music className="w-3 h-3 absolute right-2 bottom-2" />
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<File className="w-3 h-3 absolute right-2 bottom-2" />
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative w-full rounded-lg border-2 border-border bg-card px-3 py-2 shadow-sm transition-all",
|
||||
"data-[state=checked]:border-primary data-[state=checked]:border-2 data-[state=checked]:bg-muted/70",
|
||||
"hover:bg-muted/70",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex flex-col items-start text-start gap-1">
|
||||
<h5 className="text-sm">{format.format}</h5>
|
||||
<p className="text-muted-foreground text-xs">{format.filesize_approx ? formatFileSize(format.filesize_approx) : 'unknown'} {format.tbr ? formatBitrate(format.tbr) : 'unknown'}</p>
|
||||
<p className="text-muted-foreground text-xs">{format.ext ? format.ext.toUpperCase() : 'unknown'} {
|
||||
((format.vcodec && format.vcodec !== 'none') || (format.acodec && format.acodec !== 'none')) && (
|
||||
`(${format.vcodec && format.vcodec !== 'none' ? formatCodec(format.vcodec) : ''}${format.vcodec && format.vcodec !== 'none' && format.acodec && format.acodec !== 'none' ? ' ' : ''}${format.acodec && format.acodec !== 'none' ? formatCodec(format.acodec) : ''})`
|
||||
)}</p>
|
||||
{determineFileTypeIcon(format)}
|
||||
</div>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
FormatSelectionGroupItem.displayName = "FormatSelectionGroupItem"
|
||||
|
||||
export { FormatSelectionGroup, FormatSelectionGroupItem }
|
||||
36
src/components/custom/indeterminateProgress.tsx
Normal file
36
src/components/custom/indeterminateProgress.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface ProgressProps
|
||||
extends React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> {
|
||||
indeterminate?: boolean;
|
||||
}
|
||||
|
||||
const IndeterminateProgress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
ProgressProps
|
||||
>(({ className, value, indeterminate = false, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className={cn(
|
||||
"h-full w-full flex-1 bg-primary transition-all",
|
||||
indeterminate && "animate-indeterminate-progress origin-left"
|
||||
)}
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
));
|
||||
IndeterminateProgress.displayName = ProgressPrimitive.Root.displayName;
|
||||
|
||||
export { IndeterminateProgress };
|
||||
82
src/components/custom/playlistSelectionGroup.tsx
Normal file
82
src/components/custom/playlistSelectionGroup.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { RawVideoInfo } from "@/types/video"
|
||||
import { formatDurationString} from "@/utils"
|
||||
import { Clock } from "lucide-react"
|
||||
import { AspectRatio } from "@/components/ui/aspect-ratio"
|
||||
import { ProxyImage } from "@/components/custom/proxyImage"
|
||||
import clsx from "clsx"
|
||||
|
||||
interface PlaylistSelectionGroupItemProps extends
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> {
|
||||
video: RawVideoInfo;
|
||||
}
|
||||
|
||||
const PlaylistSelectionGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
})
|
||||
PlaylistSelectionGroup.displayName = "PlaylistSelectionGroup"
|
||||
|
||||
const PlaylistSelectionGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
PlaylistSelectionGroupItemProps
|
||||
>(({ className, video, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative w-full rounded-lg border-2 border-border bg-card p-2 shadow-sm transition-all",
|
||||
"data-[state=checked]:border-primary data-[state=checked]:border-2 data-[state=checked]:bg-muted/70",
|
||||
"hover:bg-muted/70",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex gap-2 w-full relative">
|
||||
<div className="w-[7rem] xl:w-[10rem]">
|
||||
<AspectRatio
|
||||
ratio={16 / 9}
|
||||
className={clsx(
|
||||
"w-full rounded overflow-hidden border border-border",
|
||||
video.aspect_ratio && video.aspect_ratio === 0.56 && "relative"
|
||||
)}
|
||||
>
|
||||
<ProxyImage
|
||||
src={video.thumbnail}
|
||||
alt="thumbnail"
|
||||
className={clsx(
|
||||
video.aspect_ratio && video.aspect_ratio === 0.56 &&
|
||||
"absolute h-full w-auto top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
|
||||
)}
|
||||
/>
|
||||
</AspectRatio>
|
||||
</div>
|
||||
|
||||
<div className="flex w-[10rem] lg:w-[12rem] xl:w-[15rem] flex-col items-start text-start">
|
||||
<h3 className="text-sm text-nowrap w-full overflow-hidden text-ellipsis mb-1" title={video.title}>{video.title}</h3>
|
||||
<p className="text-xs text-muted-foreground mb-2">{video.channel || video.uploader || 'unknown'}</p>
|
||||
<div className="flex items-center">
|
||||
<span className="text-xs text-muted-foreground flex items-center pr-3">
|
||||
<Clock className="w-4 h-4 mr-2"/>
|
||||
{video.duration_string ? formatDurationString(video.duration_string) : 'unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
})
|
||||
PlaylistSelectionGroupItem.displayName = "PlaylistSelectionGroupItem"
|
||||
|
||||
export { PlaylistSelectionGroup, PlaylistSelectionGroupItem }
|
||||
177
src/components/custom/playlistToggleGroup.tsx
Normal file
177
src/components/custom/playlistToggleGroup.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import * as React from "react";
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toggleVariants } from "@/components/ui/toggle";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||
import { ProxyImage } from "@/components/custom/proxyImage";
|
||||
import { Clock } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import { formatDurationString } from "@/utils";
|
||||
import { RawVideoInfo } from "@/types/video";
|
||||
|
||||
// Create a context to share toggle group props
|
||||
const PlaylistToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & { toggleType?: "single" | "multiple" }
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
toggleType: "multiple",
|
||||
});
|
||||
|
||||
// Helper type for the PlaylistToggleGroup
|
||||
type PlaylistToggleGroupProps =
|
||||
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
|
||||
VariantProps<typeof toggleVariants> & { type: "single", value?: string, onValueChange?: (value: string) => void })
|
||||
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
|
||||
VariantProps<typeof toggleVariants> & { type: "multiple", value?: string[], onValueChange?: (value: string[]) => void });
|
||||
|
||||
// Main PlaylistToggleGroup component with proper type handling
|
||||
export const PlaylistToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
PlaylistToggleGroupProps
|
||||
>(({ className, variant, size, children, type = "multiple", ...props }, ref) => {
|
||||
// Pass props based on the type
|
||||
if (type === "single") {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
type="single"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...(props as any)}
|
||||
>
|
||||
<PlaylistToggleGroupContext.Provider value={{ variant, size, toggleType: "single" }}>
|
||||
{children}
|
||||
</PlaylistToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
type="multiple"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...(props as any)}
|
||||
>
|
||||
<PlaylistToggleGroupContext.Provider value={{ variant, size, toggleType: "multiple" }}>
|
||||
{children}
|
||||
</PlaylistToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
);
|
||||
});
|
||||
PlaylistToggleGroup.displayName = "PlaylistToggleGroup";
|
||||
|
||||
// Rest of your component remains the same
|
||||
// PlaylistToggleGroupItem component with checkbox and item layout
|
||||
export const PlaylistToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
video: RawVideoInfo;
|
||||
}
|
||||
>(({ className, children, variant, size, video, value, ...props }, ref) => {
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
const [checked, setChecked] = React.useState(false);
|
||||
|
||||
// Instead of a ref + useEffect approach
|
||||
const [itemElement, setItemElement] = React.useState<HTMLButtonElement | null>(null);
|
||||
|
||||
// Handle checkbox click separately by simulating a click on the parent item
|
||||
const handleCheckboxClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Manually trigger the item's click to toggle selection
|
||||
if (itemElement) {
|
||||
// This simulates a click on the toggle item itself
|
||||
itemElement.click();
|
||||
}
|
||||
};
|
||||
|
||||
// Use an effect that triggers when itemElement changes
|
||||
React.useEffect(() => {
|
||||
if (itemElement) {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'data-state') {
|
||||
setChecked(itemElement.getAttribute('data-state') === 'on');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setChecked(itemElement.getAttribute('data-state') === 'on');
|
||||
observer.observe(itemElement, { attributes: true });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
}, [itemElement]);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={(el) => {
|
||||
// Handle both our ref and the forwarded ref
|
||||
if (typeof ref === 'function') {
|
||||
ref(el);
|
||||
} else if (ref) {
|
||||
ref.current = el;
|
||||
}
|
||||
setItemElement(el);
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full p-2 rounded-md transition-colors border-2 border-border",
|
||||
"hover:bg-muted/50 data-[state=on]:bg-muted/70",
|
||||
"data-[state=on]:border-primary",
|
||||
className
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
value={value}
|
||||
{...props}
|
||||
>
|
||||
|
||||
<div className="flex gap-2 w-full relative">
|
||||
<div className="absolute top-2 left-2 z-10">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onClick={handleCheckboxClick}
|
||||
className={cn(
|
||||
"transition-opacity",
|
||||
isHovered || checked ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[7rem] xl:w-[10rem]">
|
||||
<AspectRatio
|
||||
ratio={16 / 9}
|
||||
className={clsx(
|
||||
"w-full rounded overflow-hidden border border-border",
|
||||
video.aspect_ratio && video.aspect_ratio === 0.56 && "relative"
|
||||
)}
|
||||
>
|
||||
<ProxyImage
|
||||
src={video.thumbnail}
|
||||
alt="thumbnail"
|
||||
className={clsx(
|
||||
video.aspect_ratio && video.aspect_ratio === 0.56 &&
|
||||
"absolute h-full w-auto top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
|
||||
)}
|
||||
/>
|
||||
</AspectRatio>
|
||||
</div>
|
||||
|
||||
<div className="flex w-[10rem] lg:w-[12rem] xl:w-[15rem] flex-col items-start text-start">
|
||||
<h3 className="text-sm text-nowrap w-full overflow-hidden text-ellipsis mb-1">{video.title}</h3>
|
||||
<p className="text-xs text-muted-foreground mb-2">{video.channel || video.uploader || 'unknown'}</p>
|
||||
<div className="flex items-center">
|
||||
<span className="text-xs text-muted-foreground flex items-center pr-3">
|
||||
<Clock className="w-4 h-4 mr-2"/>
|
||||
{video.duration_string ? formatDurationString(video.duration_string) : 'unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
PlaylistToggleGroupItem.displayName = "PlaylistToggleGroupItem";
|
||||
116
src/components/custom/proxyImage.tsx
Normal file
116
src/components/custom/proxyImage.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Image } from "lucide-react";
|
||||
|
||||
interface ProxyImageProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProxyImage({ src, alt, className }: ProxyImageProps) {
|
||||
const [proxiedSrc, setProxiedSrc] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [needsProxy, setNeedsProxy] = useState(false);
|
||||
const [proxiedImageFailed, setProxiedImageFailed] = useState(false);
|
||||
|
||||
// Try direct loading first, fall back to proxy if needed
|
||||
useEffect(() => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setNeedsProxy(false);
|
||||
setProxiedSrc(null);
|
||||
setProxiedImageFailed(false);
|
||||
|
||||
if (!src) {
|
||||
setError("No image source provided");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Cleanup
|
||||
};
|
||||
}, [src]);
|
||||
|
||||
// Load via proxy if direct loading failed
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
|
||||
async function loadImageViaProxy() {
|
||||
if (!needsProxy || !src) return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const localPath = await invoke<string>("fetch_image", { url: src });
|
||||
|
||||
if (isMounted) {
|
||||
setProxiedSrc(localPath);
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err) {
|
||||
// console.error("Error fetching image:", err);
|
||||
if (isMounted) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load image");
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (needsProxy) {
|
||||
loadImageViaProxy();
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [src, needsProxy]);
|
||||
|
||||
// Direct loading render
|
||||
if (!needsProxy && !error) {
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={className}
|
||||
style={{ display: isLoading ? 'none' : 'block' }}
|
||||
onLoad={() => setIsLoading(false)}
|
||||
onError={() => {
|
||||
// console.log("Direct image loading failed, falling back to proxy");
|
||||
setNeedsProxy(true);
|
||||
}}
|
||||
/>
|
||||
{isLoading && <Skeleton className={`w-full h-full ${className}`} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Proxy loading or error states
|
||||
if (isLoading) {
|
||||
return <Skeleton className={`w-full h-full ${className}`} />;
|
||||
}
|
||||
|
||||
if (error || !proxiedSrc || proxiedImageFailed) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center h-full bg-muted ${className}`}>
|
||||
<Image className="w-7" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Successful proxy image
|
||||
return (
|
||||
<img
|
||||
src={proxiedSrc}
|
||||
alt={alt}
|
||||
className={className}
|
||||
onError={() => {
|
||||
// console.log("Proxied image failed to render properly");
|
||||
setProxiedImageFailed(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user