feat/playlist: Add UI for playlist

This commit is contained in:
mbaharip
2025-03-06 10:12:35 +07:00
parent 2499c9cd78
commit a69ac2b00e
7 changed files with 413 additions and 13 deletions
+14 -1
View File
@@ -1,5 +1,6 @@
import { type Metadata, type ResolvedMetadata } from "next";
import { notFound } from "next/navigation";
import { type z } from "zod";
import { FileActions, FileBreadcrumb, FileExplorerLayout, FileReadme } from "~/components/explorer";
import { ErrorComponent, Password } from "~/components/layout";
@@ -9,7 +10,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { getFileType } from "~/lib/previewHelper";
import { formatPathToBreadcrumb } from "~/lib/utils";
import { GetBanner, GetFile, GetReadme, ListFiles } from "~/actions/files";
import { type Schema_File } from "~/types/schema";
import { GetBanner, GetFile, GetReadme, GetSiblingsMedia, ListFiles } from "~/actions/files";
import { CheckPagePassword } from "~/actions/password";
import { ValidatePaths } from "~/actions/paths";
import { CreateFileToken } from "~/actions/token";
@@ -131,6 +134,14 @@ export default async function RestPage({ params }: Props) {
const token = await CreateFileToken(file.data);
if (!token.success) return <ErrorComponent error={new Error(token.error)} />;
// Check if file is media (video / audio)
let playlistFiles: z.infer<typeof Schema_File>[] = [];
if (file.data.mimeType.includes("video") || file.data.mimeType.includes("audio")) {
const sib = await GetSiblingsMedia(rest);
if (!sib.success) return <ErrorComponent error={new Error(sib.error)} />;
playlistFiles = sib.data;
}
return (
<Layout>
<PreviewLayout
@@ -141,6 +152,8 @@ export default async function RestPage({ params }: Props) {
: "unknown"
}
token={token.data}
playlist={playlistFiles}
paths={rest}
/>
</Layout>
);
+2 -3
View File
@@ -39,11 +39,11 @@ type Props = {
type: "video" | "audio";
};
export default function PreviewMedia({ file, type }: Props) {
const loading = useLoading();
const player = useRef<MediaPlayerInstance>(null);
const [canPlay, setCanPlay] = useState<boolean>(false);
const [isLoop, setIsLoop] = useState<boolean>(false);
const loading = useLoading();
const smallAudioLayoutQuery = useCallback<MediaPlayerQuery>(({ width }) => {
return width < 576;
@@ -53,7 +53,7 @@ export default function PreviewMedia({ file, type }: Props) {
}, []);
return (
<div className='flex h-full w-full items-center justify-center py-3'>
<div className='flex h-full w-full items-center justify-center gap-2 py-3 pb-0'>
{loading ? (
<PageLoader message='Loading media...' />
) : (
@@ -65,7 +65,6 @@ export default function PreviewMedia({ file, type }: Props) {
type: file.mimeType as AudioMimeType | VideoMimeType,
}}
loop={isLoop}
autoPlay
playsInline
crossOrigin
viewType={type === "audio" ? "audio" : "video"}
@@ -0,0 +1,296 @@
"use client";
import Link from "next/link";
import { Fragment, useEffect, useMemo, useState } from "react";
import { type z } from "zod";
import { useResponsive } from "~/context/responsiveContext";
import useLoading from "~/hooks/useLoading";
import { getPreviewIcon } from "~/lib/previewHelper";
import { cn } from "~/lib/utils";
import { type Schema_File } from "~/types/schema";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "../ui/accordion";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import {
Carousel,
type CarouselApi,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "../ui/carousel";
import Icon from "../ui/icon";
import { Skeleton } from "../ui/skeleton";
type MediaPlaylistLayoutProps = {
type?: "separated" | "inside";
paths: string[];
currentItem: z.infer<typeof Schema_File>;
playlist: z.infer<typeof Schema_File>[];
};
export default function MediaPlaylistLayout({
type = "separated",
paths,
currentItem,
playlist,
}: MediaPlaylistLayoutProps) {
const loading = useLoading();
const { isDesktop } = useResponsive();
const [api, setApi] = useState<CarouselApi>();
const [currentIndex, setCurrentIndex] = useState(0);
const [totalIndex, setTotalIndex] = useState(0);
const uniquePlaylist = useMemo<z.infer<typeof Schema_File>[]>(
() => playlist.filter((item) => `${item.name}#${item.size}` !== `${currentItem.name}#${currentItem.size}`),
[currentItem, playlist],
);
useEffect(() => {
if (!api) return;
setTotalIndex(api.scrollSnapList().length);
setCurrentIndex(api.selectedScrollSnap());
api.on("select", () => {
setCurrentIndex(api.selectedScrollSnap());
});
}, [api]);
// <= 1, because it always include current media
if (!uniquePlaylist.length) return null;
if (type === "separated")
return (
<Card>
<CardHeader>
<CardTitle>Playlist</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className='grid w-full place-items-center'>
<div className='mx-auto inline-flex w-full items-center gap-4 tablet:w-[calc(100%-4rem)]'>
<Skeleton className='aspect-video w-full basis-1/2 tablet:basis-1/3' />
<Skeleton className='aspect-video w-full basis-1/2 tablet:basis-1/3' />
{isDesktop && <Skeleton className='aspect-video w-full basis-1/2 tablet:basis-1/3' />}
</div>
</div>
) : (
<Carousel
className='mx-auto h-full w-full'
opts={{
loop: true,
startIndex: (playlist.findIndex((item) => item.encryptedId === currentItem.encryptedId) ?? 0) + 1,
containScroll: "keepSnaps",
}}
>
<CarouselContent className='h-full pb-2'>
{playlist.map((item) => {
const isCurrent = `${item.name}#${item.size}` === `${currentItem.name}#${currentItem.size}`;
const itemUrl = paths.slice(0, paths.length - 1).join("/") + `/${encodeURIComponent(item.name)}`;
const Wrapper = ({ children }: { children: React.ReactNode }) =>
isCurrent ? <Fragment>{children}</Fragment> : <Link href={itemUrl}>{children}</Link>;
return (
<CarouselItem
key={item.encryptedId}
className='basis-1/2 tablet:basis-1/3'
>
<Wrapper>
<div
className={cn(
"relative flex flex-col rounded-lg border transition",
isCurrent ? "border-primary opacity-100" : "opacity-70 hover:opacity-80",
)}
>
{isCurrent && <Badge className='absolute right-2 top-2'>Playing</Badge>}
{item.mimeType.includes("video") ? (
<img
src={`/api/thumb/${item.encryptedId}`}
alt={`Preview of ${item.name}`}
className='aspect-video w-full rounded-lg bg-black object-contain'
/>
) : (
<div className='grid aspect-video w-full place-items-center rounded-lg bg-black'>
<Icon
name={getPreviewIcon(item.fileExtension ?? "", item.mimeType)}
className='size-6'
/>
</div>
)}
<div className='w-full p-2'>
<p
className='line-clamp-1 break-all'
title={item.name}
>
{item.name}
</p>
</div>
</div>
</Wrapper>
</CarouselItem>
);
})}
</CarouselContent>
<CarouselPrevious className='-left-4 hidden tablet:flex' />
<CarouselNext className='-right-4 hidden tablet:flex' />
</Carousel>
)}
</CardContent>
</Card>
);
if (type === "inside")
return (
<Accordion
type='single'
collapsible
className={"w-full"}
defaultValue={"playlist"}
>
<AccordionItem
value='playlist'
className={"w-full"}
>
<AccordionTrigger>
<div className={"inline-flex w-full items-center gap-2 "}>
<Icon name='List' />
Playlist
</div>
</AccordionTrigger>
<AccordionContent>
{loading ? (
<div className={"grid w-full grid-cols-3 gap-4 rounded-lg tablet:grid-cols-4 tablet:items-center"}>
<Skeleton className='aspect-video w-full basis-1/3 tablet:basis-1/4' />
<Skeleton className='aspect-video w-full basis-1/3 tablet:basis-1/4' />
<Skeleton className='aspect-video w-full basis-1/3 tablet:basis-1/4' />
<Skeleton className='hidden aspect-video w-full basis-1/3 tablet:block tablet:basis-1/4' />
</div>
) : (
<div className='grid w-full grid-cols-3 gap-4 rounded-lg pb-8 tablet:grid-cols-4 tablet:items-center'>
{/* Current media */}
<Item
item={currentItem}
isCurrent
className={"shrink-0 grow-0 basis-1/3 tablet:basis-1/4"}
/>
{/* Playlist */}
<Carousel
className={"relative col-span-2 tablet:col-span-3"}
// plugins={[WheelGesturesPlugin({ forceWheelAxis: "y" })]}
setApi={setApi}
opts={{
dragFree: true,
}}
>
<CarouselContent className={"py-1"}>
{uniquePlaylist.map((item) => {
const itemUrl = paths.slice(0, paths.length - 1).join("/") + `/${encodeURIComponent(item.name)}`;
return (
<CarouselItem
key={item.encryptedId}
className='basis-1/2 tablet:basis-1/3'
>
<Link href={itemUrl}>
<Item
item={item}
isCurrent={false}
/>
</Link>
</CarouselItem>
);
})}
</CarouselContent>
<div className={"absolute -bottom-8 right-0 inline-flex w-full items-center justify-between"}>
<div className={"inline-flex items-center gap-1"}>
{Array.from({
length: totalIndex,
}).map((_, index) => (
<div
key={`indicator-${index}`}
className={cn(
"size-2 rounded-full",
index === currentIndex ? "bg-primary" : "bg-muted-foreground",
)}
/>
))}
</div>
<div className={"relative inline-flex items-center gap-1"}>
<Button
variant={"outline"}
size='icon'
className={"size-6 rounded-full p-0"}
disabled={!api?.canScrollPrev()}
onClick={() => api?.scrollPrev()}
>
<Icon name='ChevronLeft' />
</Button>
<Button
variant={"outline"}
size='icon'
className={"size-6 rounded-full p-0"}
disabled={!api?.canScrollNext()}
onClick={() => api?.scrollNext()}
>
<Icon name='ChevronRight' />
</Button>
</div>
</div>
</Carousel>
</div>
)}
</AccordionContent>
</AccordionItem>
</Accordion>
);
return <></>;
}
type ItemProps = {
item: z.infer<typeof Schema_File>;
isCurrent: boolean;
className?: string;
};
function Item({ item, isCurrent, className }: ItemProps) {
return (
<div
className={cn(
"relative flex flex-col rounded-lg border transition",
isCurrent ? "border-primary opacity-100" : "opacity-70 hover:opacity-80",
className,
)}
>
{isCurrent && <Badge className='absolute right-2 top-2'>Playing</Badge>}
{item.mimeType.includes("video") ? (
<img
src={`/api/thumb/${item.encryptedId}`}
alt={`Preview of ${item.name}`}
className='aspect-video w-full rounded-lg bg-black object-contain'
/>
) : (
<div className='grid aspect-video w-full place-items-center rounded-lg bg-black'>
<Icon
name={getPreviewIcon(item.fileExtension ?? "", item.mimeType)}
className='size-6'
/>
</div>
)}
<div className='w-full p-2'>
<p
className='line-clamp-1 break-all'
title={item.name}
>
{item.name}
</p>
</div>
</div>
);
}
+16 -9
View File
@@ -14,7 +14,7 @@ import {
PreviewUnknown,
} from "~/components/preview";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
import { type getFileType } from "~/lib/previewHelper";
import { cn } from "~/lib/utils";
@@ -23,29 +23,27 @@ import { type Schema_File } from "~/types/schema";
import config from "config";
import MediaPlaylistLayout from "./MediaPlaylistLayout";
type Props = {
data: z.infer<typeof Schema_File>;
fileType: "unknown" | ReturnType<typeof getFileType>;
token: string;
playlist: z.infer<typeof Schema_File>[];
paths: string[];
};
export default function PreviewLayout({ data, fileType, token }: Props) {
export default function PreviewLayout({ data, paths, fileType, token, playlist }: Props) {
const [view, setView] = useState<"markdown" | "raw">("markdown");
const PreviewComponent = useMemo(() => {
switch (fileType) {
case "image":
return <PreviewImage file={data} />;
case "video":
return (
<PreviewMedia
file={data}
type='video'
/>
);
case "audio":
return (
<PreviewMedia
file={data}
type='audio'
type={fileType}
/>
);
case "code":
@@ -135,6 +133,15 @@ export default function PreviewLayout({ data, fileType, token }: Props) {
<>{PreviewComponent}</>
)}
</CardContent>
<CardFooter>
<MediaPlaylistLayout
type={"inside"}
paths={paths}
currentItem={data}
playlist={playlist}
/>
</CardFooter>
</Card>
<PreviewInformation
+57
View File
@@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"
import { cn } from "~/lib/utils"
const Accordion = AccordionPrimitive.Root
const AccordionItem = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
<AccordionPrimitive.Item
ref={ref}
className={cn("border-b", className)}
{...props}
/>
))
AccordionItem.displayName = "AccordionItem"
const AccordionTrigger = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
ref={ref}
className={cn(
"flex flex-1 items-center justify-between py-4 text-sm font-medium transition-all hover:underline text-left [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
const AccordionContent = React.forwardRef<
React.ElementRef<typeof AccordionPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<AccordionPrimitive.Content
ref={ref}
className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
{...props}
>
<div className={cn("pb-4 pt-0", className)}>{children}</div>
</AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+9
View File
@@ -177,3 +177,12 @@ body {
}
@layer base {
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}