add enjoy app

This commit is contained in:
an-lee
2024-01-09 15:19:32 +08:00
parent b88c52d5d8
commit aebd9ee213
434 changed files with 34955 additions and 62 deletions

View File

@@ -0,0 +1,199 @@
import { useState, useEffect, useContext } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import {
Button,
ScrollArea,
ScrollBar,
Dialog,
DialogHeader,
DialogTitle,
DialogContent,
DialogFooter,
} from "@renderer/components/ui";
import { t } from "i18next";
import { MediaPlayer, MediaProvider } from "@vidstack/react";
import {
DefaultAudioLayout,
defaultLayoutIcons,
} from "@vidstack/react/player/layouts/default";
import { useNavigate } from "react-router-dom";
import { LoaderIcon } from "lucide-react";
export const AudibleBooksSegment = () => {
const navigate = useNavigate();
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const [books, setBooks] = useState<AudibleBookType[]>([]);
const [selectedBook, setSelectedBook] = useState<AudibleBookType | null>(
null
);
const [downloading, setDownloading] = useState(false);
const downloadSample = () => {
if (!selectedBook.sample) return;
setDownloading(true);
EnjoyApp.audios
.create(selectedBook.sample, {
name: selectedBook.title,
coverUrl: selectedBook.cover,
})
.then((audio) => {
if (!audio) return;
navigate(`/audios/${audio.id}`);
})
.finally(() => {
setDownloading(false);
});
};
const fetchAudibleBooks = async () => {
const cachedBooks = await EnjoyApp.cacheObjects.get("audible-books");
if (cachedBooks) {
setBooks(cachedBooks);
return;
}
EnjoyApp.providers.audible
.bestsellers()
.then(({ books }) => {
const filteredBooks =
books?.filter((book) => book.language === "English") || [];
if (filteredBooks.length) {
EnjoyApp.cacheObjects.set("audible-books", filteredBooks, 60 * 60);
setBooks(filteredBooks);
}
})
.catch((err) => {
console.error(err);
});
};
useEffect(() => {
fetchAudibleBooks();
}, []);
if (!books?.length) return null;
return (
<div>
<div className="flex items-start justify-between mb-4">
<div className="space-y-1">
<h2 className="text-2xl font-semibold tracking-tight capitalize">
{t("from")} Audible.com
</h2>
</div>
<div className="ml-auto mr-4"></div>
</div>
<ScrollArea>
<div className="flex items-center space-x-4 pb-4">
{books.map((book) => {
return (
<AudioBookCard
key={book.title}
book={book}
onClick={() => setSelectedBook(book)}
/>
);
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<Dialog
open={Boolean(selectedBook)}
onOpenChange={(value) => {
if (!value) setSelectedBook(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{selectedBook?.title}</DialogTitle>
</DialogHeader>
{selectedBook && <AudioBookPlayer book={selectedBook} />}
<div className="flex items-center mb-4 bg-muted rounded-lg">
<div className="aspect-square h-28 overflow-hidden rounded-l-lg">
<img
src={selectedBook?.cover}
alt={selectedBook?.title}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 py-3 px-4 h-28">
<div className="text-lg font-semibold line-clamp-1">
{selectedBook?.title}
</div>
<div className="text-sm line-clamp-1 mb-2">
{selectedBook?.subtitle}
</div>
<div className="text-xs text-muted-foreground text-right">
{t("author")}: {selectedBook?.author}
</div>
<div className="text-xs text-muted-foreground text-right">
{t("narrator")}: {selectedBook?.narrator}
</div>
</div>
</div>
<DialogFooter>
<Button
onClick={() => EnjoyApp.shell.openExternal(selectedBook?.url)}
variant="ghost"
className="mr-auto"
>
{t("buy")}
</Button>
<Button onClick={() => setSelectedBook(null)} variant="secondary">
{t("cancel")}
</Button>
<Button onClick={downloadSample} disabled={downloading}>
{downloading && (
<LoaderIcon className="w-4 h-4 animate-spin mr-2" />
)}
{t("downloadSample")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
const AudioBookPlayer = (props: { book: AudibleBookType }) => {
const { book } = props;
return (
<MediaPlayer src={book.sample}>
<MediaProvider />
<DefaultAudioLayout icons={defaultLayoutIcons} />
</MediaPlayer>
);
};
const AudioBookCard = (props: {
book: AudibleBookType;
onClick?: () => void;
}) => {
const { book, onClick } = props;
return (
<div onClick={onClick} className="w-36 cursor-pointer">
<div className="aspect-square border rounded-lg overflow-hidden">
<img
src={book.cover}
alt={book.title}
className="hover:scale-105 object-cover w-full h-full"
/>
</div>
<div className="text-sm font-semibold mt-2 max-w-full line-clamp-1 h-5">
{book.title}
</div>
<div className="text-xs font-muted-foreground max-w-full line-clamp-1 h-4">
{book.author}
</div>
</div>
);
};

View File

@@ -0,0 +1,149 @@
import { useState, useEffect } from "react";
import { cn } from "@renderer/lib/utils";
import {
Button,
Popover,
PopoverContent,
PopoverAnchor,
} from "@renderer/components/ui";
import { LookupResult } from "@renderer/components";
import { LanguagesIcon, PlayIcon } from "lucide-react";
export const AudioCaption = (props: {
audioId: string;
currentTime: number;
transcription: TranscriptionGroupType;
onSeek?: (time: number) => void;
className?: string;
isPlaying: boolean;
setIsPlaying: (isPlaying: boolean) => void;
}) => {
const {
transcription,
currentTime,
onSeek,
className,
isPlaying,
setIsPlaying,
} = props;
const [activeIndex, setActiveIndex] = useState<number>(0);
const [selected, setSelected] = useState<{
index: number;
word: string;
position?: {
top: number;
left: number;
};
}>();
useEffect(() => {
if (!transcription) return;
const time = Math.round(currentTime * 1000);
const index = transcription.segments.findIndex(
(w) => time >= w.offsets.from && time < w.offsets.to
);
if (index !== activeIndex) {
setActiveIndex(index);
}
}, [currentTime, transcription]);
if (!transcription) return null;
if (Math.round(currentTime * 1000) < transcription.offsets.from) return null;
return (
<div className={cn("relative px-4 py-2 text-lg", className)}>
<div className="flex flex-wrap">
{(transcription.segments || []).map((w, index) => (
<span
key={index}
className={`mr-1 cursor-pointer hover:bg-red-500/10 ${
index === activeIndex ? "text-red-500" : ""
}`}
onClick={(event) => {
setSelected({
index,
word: w.text,
position: {
top:
event.currentTarget.offsetTop +
event.currentTarget.offsetHeight,
left: event.currentTarget.offsetLeft,
},
});
setIsPlaying(false);
if (onSeek) onSeek(w.offsets.from / 1000);
}}
>
{w.text}
</span>
))}
<Popover
open={Boolean(selected) && !isPlaying}
onOpenChange={(value) => {
if (!value) setSelected(null);
}}
>
<PopoverAnchor
className="absolute w-0 h-0"
style={{
top: selected?.position?.top,
left: selected?.position?.left,
}}
></PopoverAnchor>
<PopoverContent
className="w-full max-w-md p-0"
updatePositionStrategy="always"
>
{selected?.word && (
<AudioCaptionSelectionMenu
word={selected.word}
context={transcription.segments.map((w) => w.text).join(" ").trim()}
audioId={props.audioId}
onPlay={() => {
setIsPlaying(true);
}}
/>
)}
</PopoverContent>
</Popover>
</div>
</div>
);
};
const AudioCaptionSelectionMenu = (props: {
word: string;
context: string;
audioId: string;
onPlay: () => void;
}) => {
const { word, context, audioId, onPlay } = props;
const [translating, setTranslating] = useState<boolean>(false);
if (!word) return null;
if (translating) {
return (
<LookupResult
word={word}
context={context}
sourceId={audioId}
sourceType={"Audio"}
/>
);
}
return (
<div className="flex items-center p-1">
<Button onClick={onPlay} variant="ghost" size="icon">
<PlayIcon size={16} />
</Button>
<Button onClick={() => setTranslating(true)} variant="ghost" size="icon">
<LanguagesIcon size={16} />
</Button>
</div>
);
};

View File

@@ -0,0 +1,32 @@
import { Link } from "react-router-dom";
import { cn } from "@renderer/lib/utils";
export const AudioCard = (props: {
audio: ResourceType;
className?: string;
}) => {
const { audio, className } = props;
return (
<div className={cn("w-full", className)}>
<Link to={`/audios/${audio.id}`}>
<div
className="aspect-square border rounded-lg overflow-hidden"
style={{
borderBottomColor: `#${audio.md5.substr(0, 6)}`,
borderBottomWidth: 3,
}}
>
<img
src={audio.coverUrl ? audio.coverUrl : "./assets/sound-waves.png"}
className="hover:scale-105 object-cover w-full h-full"
/>
)
</div>
</Link>
<div className="text-sm font-semibold mt-2 max-w-full line-clamp-2 h-10">
{audio.name}
</div>
</div>
);
};

View File

@@ -0,0 +1,156 @@
import { useEffect, useState, useContext } from "react";
import {
DbProviderContext,
AppSettingsProviderContext,
} from "@renderer/context";
import {
LoaderSpin,
RecordingsList,
PagePlaceholder,
MediaPlayer,
MediaTranscription,
} from "@renderer/components";
import { LoaderIcon } from "lucide-react";
import { ScrollArea } from "@renderer/components/ui";
export const AudioDetail = (props: { id?: string; md5?: string }) => {
const { id, md5 } = props;
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const [audio, setAudio] = useState<AudioType | null>(null);
const [transcription, setTranscription] = useState<TranscriptionType>(null);
const [initialized, setInitialized] = useState<boolean>(false);
// Player controls
const [currentTime, setCurrentTime] = useState<number>(0);
const [seek, setSeek] = useState<{
seekTo: number;
timestamp: number;
}>();
const [currentSegmentIndex, setCurrentSegmentIndex] = useState<number>(0);
const [zoomRatio, setZoomRatio] = useState<number>(1.0);
const [isPlaying, setIsPlaying] = useState(false);
const [isLooping, setIsLooping] = useState(false);
const [playBackRate, setPlaybackRate] = useState<number>(1);
const [displayInlineCaption, setDisplayInlineCaption] =
useState<boolean>(true);
const onTransactionUpdate = (event: CustomEvent) => {
const { model, action, record } = event.detail || {};
if (model === "Transcription" && action === "update") {
setTranscription(record);
}
};
useEffect(() => {
const where = id ? { id } : { md5 };
EnjoyApp.audios.findOne(where).then((audio) => {
if (!audio) return;
setAudio(audio);
});
}, [id, md5]);
useEffect(() => {
if (!audio) return;
EnjoyApp.transcriptions
.findOrCreate({
targetId: audio.id,
targetType: "Audio",
})
.then((transcription) => {
setTranscription(transcription);
});
}, [audio]);
useEffect(() => {
addDblistener(onTransactionUpdate);
return () => {
removeDbListener(onTransactionUpdate);
};
}, [transcription]);
if (!audio) {
return <LoaderSpin />;
}
if (!audio.src) {
return (
<PagePlaceholder placeholder="invalid" extra="cannot find play source" />
);
}
return (
<div className="relative">
<div className={`grid grid-cols-7 gap-4 ${initialized ? "" : "blur-sm"}`}>
<div className="col-span-5 h-[calc(100vh-6.5rem)] flex flex-col">
<MediaPlayer
mediaId={audio.id}
mediaType="Audio"
mediaUrl={audio.src}
waveformCacheKey={`waveform-audio-${audio.md5}`}
transcription={transcription}
currentTime={currentTime}
setCurrentTime={setCurrentTime}
currentSegmentIndex={currentSegmentIndex}
setCurrentSegmentIndex={setCurrentSegmentIndex}
recordButtonVisible={true}
seek={seek}
initialized={initialized}
setInitialized={setInitialized}
zoomRatio={zoomRatio}
setZoomRatio={setZoomRatio}
isPlaying={isPlaying}
setIsPlaying={setIsPlaying}
isLooping={isLooping}
setIsLooping={setIsLooping}
playBackRate={playBackRate}
setPlaybackRate={setPlaybackRate}
displayInlineCaption={displayInlineCaption}
setDisplayInlineCaption={setDisplayInlineCaption}
/>
<ScrollArea className={`flex-1 relative bg-muted`}>
<RecordingsList
key={`recordings-list-${audio.id}-${currentSegmentIndex}`}
targetId={audio.id}
targetType="Audio"
referenceText={transcription?.result?.[currentSegmentIndex]?.text}
referenceId={currentSegmentIndex}
/>
</ScrollArea>
</div>
<div className="col-span-2 h-[calc(100vh-6.5rem)]">
<MediaTranscription
mediaId={audio.id}
mediaType="Audio"
mediaName={audio.name}
transcription={transcription}
currentSegmentIndex={currentSegmentIndex}
onSelectSegment={(index) => {
if (currentSegmentIndex === index) return;
const segment = transcription?.result?.[index];
if (!segment) return;
if (isLooping && isPlaying) setIsPlaying(false);
setSeek({
seekTo: segment.offsets.from / 1000,
timestamp: Date.now(),
});
}}
/>
</div>
</div>
{!initialized && (
<div className="top-0 w-full h-full absolute z-30 bg-white/10 flex items-center justify-center">
<LoaderIcon className="text-muted-foreground animate-spin w-8 h-8" />
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,108 @@
import * as z from "zod";
import { t } from "i18next";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Button,
FormField,
Form,
FormItem,
FormLabel,
FormControl,
FormMessage,
Input,
Textarea,
} from "@renderer/components/ui";
import { AppSettingsProviderContext } from "@renderer/context";
import { useContext } from "react";
const audioFormSchema = z.object({
name: z
.string()
.min(3, {
message: t("form.lengthMustBeAtLeast", {
field: t("models.audio.name"),
length: 3,
}),
})
.max(50, {
message: t("form.lengthMustBeLessThan", {
field: t("models.audio.name"),
length: 50,
}),
}),
description: z.string().optional(),
});
export const AudioEditForm = (props: {
audio: Partial<AudioType>;
onCancel: () => void;
onFinish: () => void;
}) => {
const { audio, onCancel, onFinish } = props;
const { EnjoyApp } = useContext(AppSettingsProviderContext);
if (!audio) return null;
const form = useForm<z.infer<typeof audioFormSchema>>({
resolver: zodResolver(audioFormSchema),
defaultValues: {
name: audio.name,
description: audio.description || "",
},
});
const onSubmit = async (data: z.infer<typeof audioFormSchema>) => {
const { name, description } = data;
await EnjoyApp.audios.update(audio.id, {
name,
description,
});
onFinish();
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("models.audio.name")}</FormLabel>
<FormControl>
<Input
placeholder={t("models.audio.namePlaceholder")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>{t("models.audio.description")}</FormLabel>
<FormControl>
<Textarea
placeholder={t("models.audio.descriptionPlaceholder")}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end space-x-4">
<Button variant="secondary" onClick={onCancel}>
{t("cancel")}
</Button>
<Button type="submit">{t("save")}</Button>
</div>
</form>
</Form>
);
};

View File

@@ -0,0 +1,165 @@
import {
AlertDialog,
AlertDialogTrigger,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogContent,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogCancel,
AlertDialogAction,
Skeleton,
ScrollArea,
Button,
PingPoint,
} from "@renderer/components/ui";
import React, { useEffect, useContext } from "react";
import { t } from "i18next";
import { LoaderIcon, CheckCircleIcon, MicIcon } from "lucide-react";
import {
DbProviderContext,
AppSettingsProviderContext,
} from "@renderer/context";
export const AudioTranscription = (props: {
audio: AudioType | null;
currentSegmentIndex?: number;
onSelectSegment?: (index: number) => void;
}) => {
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const { audio, currentSegmentIndex, onSelectSegment } = props;
const containerRef = React.createRef<HTMLDivElement>();
const [recordingStats, setRecordingStats] =
React.useState<SegementRecordingStatsType>([]);
const regenerate = async () => {
if (!audio) return;
EnjoyApp.audios.transcribe(audio.id);
};
const fetchSegmentStats = async () => {
if (!audio) return;
EnjoyApp.recordings.groupBySegment(audio.id).then((stats) => {
setRecordingStats(stats);
});
};
useEffect(() => {
addDblistener(fetchSegmentStats);
fetchSegmentStats();
return () => {
removeDbListener(fetchSegmentStats);
};
}, [audio]);
useEffect(() => {
containerRef.current
?.querySelector(`#segment-${currentSegmentIndex}`)
?.scrollIntoView({
block: "center",
inline: "center",
} as ScrollIntoViewOptions);
}, [currentSegmentIndex, audio?.transcription]);
if (!audio)
return (
<div className="p-4 w-full">
<TranscriptionPlaceholder />
</div>
);
return (
<div className="w-full h-full flex flex-col">
<div className="mb-4 flex items-cener justify-between">
<div className="flex items-center space-x-2">
{audio.transcribing ? (
<PingPoint colorClassName="bg-yellow-500" />
) : audio.isTranscribed ? (
<CheckCircleIcon className="text-green-500 w-4 h-4" />
) : (
<PingPoint colorClassName="bg-mute" />
)}
<span className="">{t("transcription")}</span>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button disabled={audio.transcribing} className="capitalize">
{audio.transcribing && (
<LoaderIcon className="animate-spin w-4 mr-2" />
)}
{audio.isTranscribed ? t("regenerate") : t("transcribe")}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("transcribe")}</AlertDialogTitle>
<AlertDialogDescription>
{t("transcribeAudioConfirmation", {
name: audio.name,
})}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive"
onClick={regenerate}
>
{t("transcribe")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{audio.transcription ? (
<ScrollArea ref={containerRef} className="flex-1">
{audio.transcription.map((t, index) => (
<div
key={index}
id={`segment-${index}`}
className={`py-1 px-2 mb-2 cursor-pointer hover:bg-yellow-400/25 ${
currentSegmentIndex === index ? "bg-yellow-400/25" : ""
}`}
onClick={() => {
onSelectSegment?.(index);
}}
>
<div className="flex items-center justify-between">
<span className="text-xs opacity-50">#{index + 1}</span>
<div className="flex items-center space-x-2">
{(recordingStats || []).findIndex(
(s) => s.segmentIndex === index
) !== -1 && <MicIcon className="w-3 h-3 text-sky-500" />}
<span className="text-xs opacity-50">
{t.timestamps.from.split(",")[0]}
</span>
</div>
</div>
<p className="">{t.text}</p>
</div>
))}
</ScrollArea>
) : (
<TranscriptionPlaceholder />
)}
</div>
);
};
export const TranscriptionPlaceholder = () => {
return (
<div className="p-4">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full mb-4" />
))}
<Skeleton className="h-4 w-3/5" />
</div>
);
};

View File

@@ -0,0 +1,227 @@
import { useEffect, useState, useReducer, useContext } from "react";
import {
AudioCard,
AddMediaButton,
AudiosTable,
AudioEditForm,
} from "@renderer/components";
import { t } from "i18next";
import {
Tabs,
TabsContent,
TabsList,
TabsTrigger,
AlertDialog,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogContent,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogCancel,
AlertDialogAction,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@renderer/components/ui";
import {
DbProviderContext,
AppSettingsProviderContext,
} from "@renderer/context";
import { LayoutGridIcon, LayoutListIcon } from "lucide-react";
import { audiosReducer } from "@renderer/reducers";
import { useNavigate } from "react-router-dom";
export const AudiosComponent = () => {
const [audios, dispatchAudios] = useReducer(audiosReducer, []);
const [editing, setEditing] = useState<Partial<AudioType> | null>(null);
const [deleting, setDeleting] = useState<Partial<AudioType> | null>(null);
const [transcribing, setTranscribing] = useState<Partial<AudioType> | null>(
null
);
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const navigate = useNavigate();
useEffect(() => {
fetchResources();
}, []);
useEffect(() => {
addDblistener(onAudiosUpdate);
fetchResources();
return () => {
removeDbListener(onAudiosUpdate);
};
}, []);
const fetchResources = async () => {
const audios = await EnjoyApp.audios.findAll({
limit: 10,
});
if (!audios) return;
dispatchAudios({ type: "set", records: audios });
};
const onAudiosUpdate = (event: CustomEvent) => {
const { record, action, model } = event.detail || {};
if (!record) return;
if (model === "Audio") {
if (action === "create") {
dispatchAudios({ type: "create", record });
navigate(`/audios/${record.id}`);
} else if (action === "destroy") {
dispatchAudios({ type: "destroy", record });
}
} else if (model === "Video" && action === "create") {
navigate(`/videos/${record.id}`);
} else if (model === "Transcription" && action === "update") {
dispatchAudios({
type: "update",
record: {
id: record.targetId,
transcribing: record.state === "processing",
transcribed: record.state === "finished",
},
});
}
};
if (audios.length === 0) {
return (
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
<AddMediaButton />
</div>
);
}
return (
<>
<div className="">
<Tabs defaultValue="grid">
<div className="flex justify-between mb-4">
<div className="flex items-center space-x-4">
<TabsList>
<TabsTrigger value="grid">
<LayoutGridIcon className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="list">
<LayoutListIcon className="h-4 w-4" />
</TabsTrigger>
</TabsList>
</div>
<AddMediaButton />
</div>
<TabsContent value="grid">
<div className="grid gap-4 grid-cols-5">
{audios.map((audio) => (
<AudioCard audio={audio} key={audio.id} />
))}
</div>
</TabsContent>
<TabsContent value="list">
<AudiosTable
audios={audios}
onEdit={(audio) => setEditing(audio)}
onDelete={(audio) => setDeleting(audio)}
onTranscribe={(audio) => setTranscribing(audio)}
/>
</TabsContent>
</Tabs>
</div>
<Dialog
open={!!editing}
onOpenChange={(value) => {
if (value) return;
setEditing(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("editRecourse")}</DialogTitle>
</DialogHeader>
<AudioEditForm
audio={editing}
onCancel={() => setEditing(null)}
onFinish={() => setEditing(null)}
/>
</DialogContent>
</Dialog>
<AlertDialog
open={!!deleting}
onOpenChange={(value) => {
if (value) return;
setDeleting(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteRecourse")}</AlertDialogTitle>
<AlertDialogDescription>
<p className="break-all">
{t("deleteRecourseConfirmation", {
name: deleting?.name || "",
})}
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive"
onClick={async () => {
if (!deleting) return;
await EnjoyApp.audios.destroy(deleting.id);
setDeleting(null);
}}
>
{t("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={!!transcribing}
onOpenChange={(value) => {
if (value) return;
setTranscribing(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("transcribe")}</AlertDialogTitle>
<AlertDialogDescription>
<p className="break-all">
{t("transcribeResourceConfirmation", {
name: transcribing?.name || "",
})}
</p>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive"
onClick={async () => {
if (!transcribing) return;
await EnjoyApp.audios.transcribe(transcribing.id);
setTranscribing(null);
}}
>
{t("transcribe")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};

View File

@@ -0,0 +1,81 @@
import { useState, useEffect, useContext } from "react";
import {
DbProviderContext,
AppSettingsProviderContext,
} from "@renderer/context";
import { Button, ScrollArea, ScrollBar } from "@renderer/components/ui";
import { AudioCard, AddMediaButton } from "@renderer/components";
import { t } from "i18next";
import { Link } from "react-router-dom";
export const AudiosSegment = (props: { limit?: number }) => {
const { limit = 10 } = props;
const [audios, setAudios] = useState<AudioType[]>([]);
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
const { EnjoyApp } = useContext(AppSettingsProviderContext);
useEffect(() => {
fetchResources();
addDblistener(onAudioUpdate);
return () => {
removeDbListener(onAudioUpdate);
};
}, []);
const fetchResources = async () => {
const resources = await EnjoyApp.audios.findAll({
limit,
});
if (!resources) return;
setAudios(resources);
};
const onAudioUpdate = (event: CustomEvent) => {
const { record, action, model } = event.detail || {};
if (model !== "Audio") return;
if (!record) return;
if (action === "create") {
setAudios([record as AudioType, ...audios]);
} else if (action === "destroy") {
setAudios(audios.filter((r) => r.id !== record.id));
}
};
return (
<div>
<div className="flex items-start justify-between mb-4">
<div className="space-y-1">
<h2 className="text-2xl font-semibold tracking-tight capitalize">
{t("addedAudios")}
</h2>
</div>
<div className="ml-auto mr-4">
<Link to="/audios">
<Button variant="link" className="capitalize">
{t("seeMore")}
</Button>
</Link>
</div>
</div>
{audios.length === 0 ? (
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
<AddMediaButton />
</div>
) : (
<ScrollArea>
<div className="flex items-center space-x-4 pb-4">
{audios.map((audio) => {
return (
<AudioCard className="w-36" key={audio.id} audio={audio} />
);
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
)}
</div>
);
};

View File

@@ -0,0 +1,129 @@
import { t } from "i18next";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
Button,
PingPoint,
} from "@renderer/components/ui";
import {
EditIcon,
TrashIcon,
CheckCircleIcon,
AudioWaveformIcon,
} from "lucide-react";
import dayjs from "dayjs";
import { secondsToTimestamp } from "@renderer/lib/utils";
import { Link } from "react-router-dom";
export const AudiosTable = (props: {
audios: Partial<AudioType>[];
onEdit: (audio: Partial<AudioType>) => void;
onDelete: (audio: Partial<AudioType>) => void;
onTranscribe: (audio: Partial<AudioType>) => void;
}) => {
const { audios, onEdit, onDelete, onTranscribe } = props;
return (
<Table>
<TableHeader>
<TableRow>
<TableHead className="capitalize">{t("models.audio.name")}</TableHead>
<TableHead className="capitalize">
{t("models.audio.duration")}
</TableHead>
<TableHead className="capitalize">
{t("models.audio.recordingsCount")}
</TableHead>
<TableHead className="capitalize">
{t("models.audio.recordingsDuration")}
</TableHead>
<TableHead className="capitalize">
{t("models.audio.createdAt")}
</TableHead>
<TableHead className="capitalize">
{t("models.audio.isTranscribed")}
</TableHead>
<TableHead className="capitalize">{t("actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{audios.map((audio) => (
<TableRow key={audio.id}>
<TableCell>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Link to={`/audios/${audio.id}`}>
<div className="cursor-pointer truncate max-w-[12rem]">
{audio.name}
</div>
</Link>
</TooltipTrigger>
<TooltipContent>
<div className="p-2">
<p className="text-sm">{audio.name}</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableCell>
<TableCell>
{audio.metadata?.format?.duration
? secondsToTimestamp(audio.metadata.format.duration)
: "-"}
</TableCell>
<TableCell>{audio.recordingsCount}</TableCell>
<TableCell>
{secondsToTimestamp(audio.recordingsDuration / 1000)}
</TableCell>
<TableCell>
{dayjs(audio.createdAt).format("YYYY-MM-DD HH:mm")}
</TableCell>
<TableCell>
{audio.transcribing ? (
<PingPoint colorClassName="bg-yellow-500" />
) : audio.transcribed ? (
<CheckCircleIcon className="text-green-500 w-4 h-4" />
) : (
<PingPoint colorClassName="bg-gray-500" />
)}
</TableCell>
<TableCell>
<div className="flex items-center">
<Button
title={t("transcribe")}
variant="ghost"
onClick={() => onTranscribe(Object.assign({}, audio))}
>
<AudioWaveformIcon className="h-4 w-4" />
</Button>
<Button
title={t("edit")}
variant="ghost"
onClick={() => onEdit(Object.assign({}, audio))}
>
<EditIcon className="h-4 w-4" />
</Button>
<Button
title={t("delete")}
variant="ghost"
onClick={() => onDelete(Object.assign({}, audio))}
>
<TrashIcon className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
};

View File

@@ -0,0 +1,8 @@
export * from "./audios-table";
export * from "./audio-edit-form";
export * from "./audio-detail";
export * from "./audios-component";
export * from "./audible-books-segment";
export * from "./audios-segment";
export * from "./audio-card";