Improve audio video page (#975)

* remember tab value

* fix locale

* tips for no source file resources

* add alert for no source file

* improve code

* clean up resources
This commit is contained in:
an-lee
2024-08-15 15:12:52 +08:00
committed by GitHub
parent b56d6a34b4
commit 7da9fb8095
15 changed files with 261 additions and 47 deletions

View File

@@ -667,7 +667,7 @@
"scoreDesc": "Score desc",
"scoreAsc": "Score asc",
"recordingsDurationDesc": "Recordings duration",
"recordingsCountDesc": "Recordings duration",
"recordingsCountDesc": "Recordings count",
"all": "All",
"allLanguages": "All languages",
"search": "Search",
@@ -719,5 +719,9 @@
"textInput": "Text input",
"increasePlaybackRate": "Increase playback rate",
"descreasePlaybackRate": "Descrease playback rate",
"usage": "Usage"
"usage": "Usage",
"cannotFindSourceFile": "Cannot find source file",
"cleanUp": "Clean up",
"cleanUpConfirmation": "Are you sure to remove resources without source file?",
"cleanedUpSuccessfully": "Cleaned up successfully"
}

View File

@@ -719,5 +719,9 @@
"textInput": "文字输入",
"increasePlaybackRate": "加快播放速度",
"decreasePlaybackRate": "减慢播放速度",
"usage": "使用情况"
"usage": "使用情况",
"cannotFindSourceFile": "无法找到源文件,可能已经被删除",
"cleanUp": "清理",
"cleanUpConfirmation": "您确定要移除所有找不到源文件的资源吗?",
"cleanedUpSuccessfully": "清理成功"
}

View File

@@ -168,6 +168,16 @@ class AudiosHandler {
return pathToEnjoyUrl(output);
}
private async cleanUp() {
const audios = await Audio.findAll();
for (const audio of audios) {
if (!audio.src) {
audio.destroy();
}
}
}
register() {
ipcMain.handle("audios-find-all", this.findAll);
ipcMain.handle("audios-find-one", this.findOne);
@@ -176,6 +186,7 @@ class AudiosHandler {
ipcMain.handle("audios-destroy", this.destroy);
ipcMain.handle("audios-upload", this.upload);
ipcMain.handle("audios-crop", this.crop);
ipcMain.handle("audios-clean-up", this.cleanUp);
}
}

View File

@@ -158,6 +158,16 @@ class VideosHandler {
return pathToEnjoyUrl(output);
}
private async cleanUp() {
const videos = await Video.findAll();
for (const video of videos) {
if (!video.src) {
video.destroy();
}
}
}
register() {
ipcMain.handle("videos-find-all", this.findAll);
ipcMain.handle("videos-find-one", this.findOne);
@@ -166,6 +176,7 @@ class VideosHandler {
ipcMain.handle("videos-destroy", this.destroy);
ipcMain.handle("videos-upload", this.upload);
ipcMain.handle("videos-crop", this.crop);
ipcMain.handle("videos-clean-up", this.cleanUp);
}
}

View File

@@ -121,11 +121,15 @@ export class Audio extends Model<Audio> {
@Column(DataType.VIRTUAL)
get src(): string {
return `enjoy://${path.posix.join(
"library",
"audios",
this.getDataValue("md5") + this.extname
)}`;
if (this.filePath) {
return `enjoy://${path.posix.join(
"library",
"audios",
this.getDataValue("md5") + this.extname
)}`;
} else {
return null;
}
}
@Column(DataType.VIRTUAL)
@@ -152,11 +156,17 @@ export class Audio extends Model<Audio> {
}
get filePath(): string {
return path.join(
const file = path.join(
settings.userDataPath(),
"audios",
this.getDataValue("md5") + this.extname
);
if (fs.existsSync(file)) {
return file;
} else {
return null;
}
}
async upload(force: boolean = false) {
@@ -248,7 +258,9 @@ export class Audio extends Model<Audio> {
@AfterDestroy
static cleanupFile(audio: Audio) {
fs.remove(audio.filePath);
if (audio.filePath) {
fs.remove(audio.filePath);
}
Recording.destroy({
where: {
targetId: audio.id,

View File

@@ -121,11 +121,15 @@ export class Video extends Model<Video> {
@Column(DataType.VIRTUAL)
get src(): string {
return `enjoy://${path.posix.join(
"library",
"videos",
this.getDataValue("md5") + this.extname
)}`;
if (this.filePath) {
return `enjoy://${path.posix.join(
"library",
"videos",
this.getDataValue("md5") + this.extname
)}`;
} else {
return null;
}
}
@Column(DataType.VIRTUAL)
@@ -152,11 +156,17 @@ export class Video extends Model<Video> {
}
get filePath(): string {
return path.join(
const file = path.join(
settings.userDataPath(),
"videos",
this.getDataValue("md5") + this.extname
);
if (fs.existsSync(file)) {
return file;
} else {
return null;
}
}
// generate cover and upload
@@ -270,7 +280,9 @@ export class Video extends Model<Video> {
@AfterDestroy
static cleanupFile(video: Video) {
fs.remove(video.filePath);
if (video.filePath) {
fs.remove(video.filePath);
}
Recording.destroy({
where: {
targetId: video.id,

View File

@@ -280,6 +280,9 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
crop: (id: string, params: { startTime: number; endTime: number }) => {
return ipcRenderer.invoke("audios-crop", id, params);
},
cleanUp: () => {
return ipcRenderer.invoke("audios-clean-up");
},
},
videos: {
findAll: (params: {
@@ -306,6 +309,9 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
crop: (id: string, params: { startTime: number; endTime: number }) => {
return ipcRenderer.invoke("videos-crop", id, params);
},
cleanUp: () => {
return ipcRenderer.invoke("videos-clean-up");
},
},
recordings: {
findAll: (params?: {
@@ -655,7 +661,11 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
},
},
chatMessages: {
findAll: (params: { chatSessionId: string; offset?: number; limit?: number }) => {
findAll: (params: {
chatSessionId: string;
offset?: number;
limit?: number;
}) => {
return ipcRenderer.invoke("chat-messages-find-all", params);
},
findOne: (params: any) => {

View File

@@ -1,7 +1,8 @@
import { Link } from "react-router-dom";
import { cn } from "@renderer/lib/utils";
import { AudioLinesIcon } from "lucide-react";
import { AudioLinesIcon, CircleAlertIcon } from "lucide-react";
import { Badge } from "@renderer/components/ui";
import { t } from "i18next";
export const AudioCard = (props: {
audio: Partial<AudioType>;
@@ -32,6 +33,15 @@ export const AudioCard = (props: {
{audio.language && (
<Badge className="absolute left-2 top-2">{audio.language}</Badge>
)}
{!audio.src && (
<div
data-tooltip-content={t("cannotFindSourceFile")}
data-tooltip-id="global-tooltip"
className="absolute right-2 top-2"
>
<CircleAlertIcon className="text-destructive w-4 h-4" />
</div>
)}
</div>
</Link>
<div className="text-sm font-semibold mt-2 max-w-full line-clamp-2 h-10">

View File

@@ -32,6 +32,8 @@ import {
SelectContent,
SelectGroup,
SelectItem,
DialogDescription,
AlertDialogTrigger,
} from "@renderer/components/ui";
import {
DbProviderContext,
@@ -58,6 +60,8 @@ export const AudiosComponent = () => {
const [deleting, setDeleting] = useState<Partial<AudioType> | null>(null);
const [loading, setLoading] = useState(false);
const [tab, setTab] = useState("grid");
useEffect(() => {
addDblistener(onAudiosUpdate);
@@ -66,6 +70,18 @@ export const AudiosComponent = () => {
};
}, []);
useEffect(() => {
EnjoyApp.cacheObjects.get("audios-page-tab").then((value) => {
if (value) {
setTab(value);
}
});
}, []);
useEffect(() => {
EnjoyApp.cacheObjects.set("audios-page-tab", tab);
}, [tab]);
const fetchAudios = async (options?: { offset: number }) => {
if (loading) return;
const { offset = audios.length } = options || {};
@@ -154,7 +170,7 @@ export const AudiosComponent = () => {
return (
<>
<div className="">
<Tabs defaultValue="grid">
<Tabs value={tab} onValueChange={setTab}>
<div className="flex flex-wrap items-center gap-4 mb-4">
<TabsList>
<TabsTrigger value="grid">
@@ -213,6 +229,29 @@ export const AudiosComponent = () => {
/>
<AddMediaButton type="Audio" />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="secondary">{t("cleanUp")}</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogTitle>{t("cleanUp")}</AlertDialogTitle>
<AlertDialogDescription>
{t("cleanUpConfirmation")}
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
EnjoyApp.audios
.cleanUp()
.then(() => toast.success(t("cleanedUpSuccessfully")))
}
>
{t("confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{audios.length === 0 ? (
@@ -259,6 +298,9 @@ export const AudiosComponent = () => {
<DialogContent>
<DialogHeader>
<DialogTitle>{t("editResource")}</DialogTitle>
<DialogDescription className="sr-only">
edit audio
</DialogDescription>
</DialogHeader>
<AudioEditForm
@@ -270,7 +312,7 @@ export const AudiosComponent = () => {
</Dialog>
<AlertDialog
open={!!deleting}
open={Boolean(deleting)}
onOpenChange={(value) => {
if (value) return;
setDeleting(null);
@@ -280,21 +322,23 @@ export const AudiosComponent = () => {
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteResource")}</AlertDialogTitle>
<AlertDialogDescription>
<p className="break-all">
<span className="break-all">
{t("deleteResourceConfirmation", {
name: deleting?.name || "",
})}
</p>
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive"
onClick={async () => {
onClick={() => {
if (!deleting) return;
await EnjoyApp.audios.destroy(deleting.id);
setDeleting(null);
EnjoyApp.audios
.destroy(deleting.id)
.catch((err) => toast.error(err.message))
.finally(() => setDeleting(null));
}}
>
{t("delete")}

View File

@@ -12,9 +12,13 @@ import {
TooltipTrigger,
Button,
PingPoint,
Badge,
} from "@renderer/components/ui";
import { EditIcon, TrashIcon, CheckCircleIcon } from "lucide-react";
import {
EditIcon,
TrashIcon,
CheckCircleIcon,
CircleAlertIcon,
} from "lucide-react";
import dayjs from "@renderer/lib/dayjs";
import { secondsToTimestamp } from "@renderer/lib/utils";
import { Link } from "react-router-dom";
@@ -58,8 +62,17 @@ export const AudiosTable = (props: {
<Tooltip>
<TooltipTrigger>
<Link to={`/audios/${audio.id}`}>
<div className="cursor-pointer truncate max-w-[12rem]">
{audio.name}
<div className="flex items-center space-x-2">
{!audio.src && (
<CircleAlertIcon
data-tooltip-content={t("cannotFindSourceFile")}
data-tooltip-id="global-tooltip"
className="text-destructive w-4 h-4"
/>
)}
<div className="cursor-pointer truncate max-w-[12rem]">
{audio.name}
</div>
</div>
</Link>
</TooltipTrigger>

View File

@@ -14,7 +14,11 @@ import {
TabsList,
TabsTrigger,
} from "@renderer/components/ui";
import { CheckCircleIcon, LoaderIcon, XCircleIcon } from "lucide-react";
import {
CheckCircleIcon,
CircleAlertIcon,
LoaderIcon,
} from "lucide-react";
import { t } from "i18next";
import { useNavigate } from "react-router-dom";
import { TranscriptionCreateForm, TranscriptionsList } from "../transcriptions";
@@ -53,7 +57,9 @@ export const MediaLoadingModal = () => {
<Tabs defaultValue="transcribe">
<TabsList className="w-full grid grid-cols-2 mb-4">
<TabsTrigger value="transcribe">{t("transcribe")}</TabsTrigger>
<TabsTrigger value="download">{t("downloadTranscript")}</TabsTrigger>
<TabsTrigger value="download">
{t("downloadTranscript")}
</TabsTrigger>
</TabsList>
<TabsContent value="transcribe">
<TranscriptionCreateForm
@@ -85,7 +91,7 @@ export const MediaLoadingModal = () => {
{decodeError ? (
<div className="mb-4 flex items-center space-x-4">
<div className="w-4 h-4">
<XCircleIcon className="w-4 h-4 text-destructive" />
<CircleAlertIcon className="text-destructive w-4 h-4" />
</div>
<div className="select-text">
<div className="mb-2">{decodeError}</div>
@@ -97,8 +103,17 @@ export const MediaLoadingModal = () => {
</div>
) : (
<div className="mb-4 flex items-center space-x-4">
<LoaderIcon className="w-4 h-4 animate-spin" />
<span>{t("decodingWaveform")}</span>
{media?.src ? (
<>
<LoaderIcon className="w-4 h-4 animate-spin" />
<span>{t("decodingWaveform")}</span>
</>
) : (
<>
<CircleAlertIcon className="text-destructive w-4 h-4" />
<span>{t("cannotFindSourceFile")}</span>
</>
)}
</div>
)}
<AlertDialogFooter>

View File

@@ -1,6 +1,8 @@
import { Link } from "react-router-dom";
import { cn } from "@renderer/lib/utils";
import { VideoIcon } from "lucide-react";
import { CircleAlertIcon, VideoIcon } from "lucide-react";
import { Badge } from "@renderer/components/ui";
import { t } from "i18next";
export const VideoCard = (props: {
video: Partial<VideoType>;
@@ -12,7 +14,7 @@ export const VideoCard = (props: {
<div className={cn("w-full", className)}>
<Link to={`/videos/${video.id}`}>
<div
className="aspect-[4/3] border rounded-lg overflow-hidden"
className="aspect-[4/3] border rounded-lg overflow-hidden relative"
style={{
borderBottomColor: `#${video.md5.substr(0, 6)}`,
borderBottomWidth: 3,
@@ -26,6 +28,18 @@ export const VideoCard = (props: {
className="absolute top-0 left-0 hover:scale-105 object-cover w-full h-full bg-cover bg-center"
/>
</div>
{video.language && (
<Badge className="absolute left-2 top-2">{video.language}</Badge>
)}
{!video.src && (
<div
data-tooltip-content={t("cannotFindSourceFile")}
data-tooltip-id="global-tooltip"
className="absolute right-2 top-2"
>
<CircleAlertIcon className="text-destructive w-4 h-4" />
</div>
)}
</div>
</Link>
<div className="text-sm font-semibold mt-2 max-w-full truncate">

View File

@@ -32,6 +32,8 @@ import {
SelectItem,
toast,
Input,
DialogDescription,
AlertDialogTrigger,
} from "@renderer/components/ui";
import {
DbProviderContext,
@@ -56,9 +58,10 @@ export const VideosComponent = () => {
const [editing, setEditing] = useState<Partial<VideoType> | null>(null);
const [deleting, setDeleting] = useState<Partial<VideoType> | null>(null);
const [loading, setLoading] = useState(false);
const [tab, setTab] = useState("grid");
useEffect(() => {
addDblistener(onVideosUpdate);
@@ -67,6 +70,18 @@ export const VideosComponent = () => {
};
}, []);
useEffect(() => {
EnjoyApp.cacheObjects.get("videos-page-tab").then((value) => {
if (value) {
setTab(value);
}
});
}, []);
useEffect(() => {
EnjoyApp.cacheObjects.set("videos-page-tab", tab);
}, [tab]);
const fetchVideos = async (options?: { offset: number }) => {
if (loading) return;
const { offset = videos.length } = options || {};
@@ -154,7 +169,7 @@ export const VideosComponent = () => {
return (
<>
<div className="">
<Tabs defaultValue="grid">
<Tabs value={tab} onValueChange={setTab}>
<div className="flex flex-wrap items-center gap-4 mb-4">
<TabsList>
<TabsTrigger value="grid">
@@ -211,6 +226,29 @@ export const VideosComponent = () => {
onChange={(e) => setQuery(e.target.value)}
/>
<AddMediaButton type="Video" />
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="secondary">{t("cleanUp")}</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogTitle>{t("cleanUp")}</AlertDialogTitle>
<AlertDialogDescription>
{t("cleanUpConfirmation")}
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() =>
EnjoyApp.videos
.cleanUp()
.then(() => toast.success(t("cleanedUpSuccessfully")))
}
>
{t("confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{videos.length === 0 ? (
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
@@ -255,6 +293,9 @@ export const VideosComponent = () => {
<DialogContent aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>{t("editResource")}</DialogTitle>
<DialogDescription className="sr-only">
edit video
</DialogDescription>
</DialogHeader>
<VideoEditForm
@@ -276,21 +317,23 @@ export const VideosComponent = () => {
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteResource")}</AlertDialogTitle>
<AlertDialogDescription>
<p className="break-all">
<span className="break-all">
{t("deleteResourceConfirmation", {
name: deleting?.name || "",
})}
</p>
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive"
onClick={async () => {
onClick={() => {
if (!deleting) return;
await EnjoyApp.videos.destroy(deleting.id);
setDeleting(null);
EnjoyApp.videos
.destroy(deleting.id)
.catch((err) => toast.error(err.message))
.finally(() => setDeleting(null));
}}
>
{t("delete")}

View File

@@ -17,7 +17,7 @@ import {
EditIcon,
TrashIcon,
CheckCircleIcon,
AudioWaveformIcon,
CircleAlertIcon,
} from "lucide-react";
import dayjs from "@renderer/lib/dayjs";
import { secondsToTimestamp } from "@renderer/lib/utils";
@@ -62,8 +62,17 @@ export const VideosTable = (props: {
<Tooltip>
<TooltipTrigger>
<Link to={`/videos/${video.id}`}>
<div className="cursor-pointer truncate max-w-[12rem]">
{video.name}
<div className="flex items-center space-x-2">
{!video.src && (
<CircleAlertIcon
data-tooltip-content={t("cannotFindSourceFile")}
data-tooltip-id="global-tooltip"
className="text-destructive w-4 h-4"
/>
)}
<div className="cursor-pointer truncate max-w-[12rem]">
{video.name}
</div>
</div>
</Link>
</TooltipTrigger>

View File

@@ -159,6 +159,7 @@ type EnjoyAppType = {
id: string,
params: { startTime: number; endTime: number }
) => Promise<string>;
cleanUp: () => Promise<void>;
};
videos: {
findAll: (params: any) => Promise<VideoType[]>;
@@ -171,6 +172,7 @@ type EnjoyAppType = {
id: string,
params: { startTime: number; endTime: number }
) => Promise<string>;
cleanUp: () => Promise<void>;
};
recordings: {
findAll: (where: any) => Promise<RecordingType[]>;