add enjoy app
This commit is contained in:
199
enjoy/src/renderer/components/audios/audible-books-segment.tsx
Normal file
199
enjoy/src/renderer/components/audios/audible-books-segment.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
149
enjoy/src/renderer/components/audios/audio-caption.tsx
Normal file
149
enjoy/src/renderer/components/audios/audio-caption.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
32
enjoy/src/renderer/components/audios/audio-card.tsx
Normal file
32
enjoy/src/renderer/components/audios/audio-card.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
156
enjoy/src/renderer/components/audios/audio-detail.tsx
Normal file
156
enjoy/src/renderer/components/audios/audio-detail.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
108
enjoy/src/renderer/components/audios/audio-edit-form.tsx
Normal file
108
enjoy/src/renderer/components/audios/audio-edit-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
165
enjoy/src/renderer/components/audios/audio-transcription.tsx
Normal file
165
enjoy/src/renderer/components/audios/audio-transcription.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
227
enjoy/src/renderer/components/audios/audios-component.tsx
Normal file
227
enjoy/src/renderer/components/audios/audios-component.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
81
enjoy/src/renderer/components/audios/audios-segment.tsx
Normal file
81
enjoy/src/renderer/components/audios/audios-segment.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
129
enjoy/src/renderer/components/audios/audios-table.tsx
Normal file
129
enjoy/src/renderer/components/audios/audios-table.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
8
enjoy/src/renderer/components/audios/index.ts
Normal file
8
enjoy/src/renderer/components/audios/index.ts
Normal 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";
|
||||
Reference in New Issue
Block a user