Improve audio/video adding & display (#1181)

* improve audio/video adding

* show window when ready

* add actions for audio/video card
This commit is contained in:
an-lee
2024-11-15 09:24:01 +08:00
committed by GitHub
parent 12faf34239
commit 1f531b0cbc
14 changed files with 218 additions and 45 deletions

View File

@@ -15,6 +15,7 @@
"size": "size",
"source": "source",
"createdAt": "created at",
"updatedAt": "updated at",
"recordingsCount": "recordings count",
"recordingsDuration": "recordings duration",
"isTranscribed": "transcribed",
@@ -40,6 +41,7 @@
"size": "size",
"source": "source",
"createdAt": "created at",
"updatedAt": "updated at",
"recordingsCount": "recordings count",
"recordingsDuration": "recordings duration",
"isTranscribed": "transcribed",
@@ -923,5 +925,7 @@
"whisperCppEngineDescription": "C++ port of the Whisper architecture.",
"ttsService": "Text to Speech Service",
"openaiTtsServiceDescription": "Use OpenAI TTS service from your own key.",
"enjoyTtsServiceDescription": "Use TTS service provided by Enjoy. OpenAI or Azure is supported."
"enjoyTtsServiceDescription": "Use TTS service provided by Enjoy. OpenAI or Azure is supported.",
"compressMediaBeforeAdding": "Compress media before adding",
"keepOriginalMedia": "Keep original media"
}

View File

@@ -15,6 +15,7 @@
"size": "大小",
"source": "来源",
"createdAt": "创建时间",
"updatedAt": "更新时间",
"recordingsCount": "练习次数",
"recordingsDuration": "练习时长",
"isTranscribed": "语音文本",
@@ -40,6 +41,7 @@
"size": "大小",
"source": "来源",
"createdAt": "创建时间",
"updatedAt": "更新时间",
"recordingsCount": "练习次数",
"recordingsDuration": "练习时长",
"isTranscribed": "语音文本",
@@ -923,5 +925,7 @@
"whisperCppEngineDescription": "Whisper 的 C++ 实现。",
"ttsService": "文字转语音服务",
"openaiTtsServiceDescription": "使用您自己的 API key 来使用 OpenAI TTS 服务。",
"enjoyTtsServiceDescription": "使用 Enjoy 提供的 TTS 服务,支持 OpenAI 或 Azure。"
"enjoyTtsServiceDescription": "使用 Enjoy 提供的 TTS 服务,支持 OpenAI 或 Azure。",
"compressMediaBeforeAdding": "添加前压缩媒体",
"keepOriginalMedia": "保存原始媒体"
}

View File

@@ -69,6 +69,7 @@ class AudiosHandler {
name?: string;
coverUrl?: string;
originalText?: string;
compressing?: boolean;
} = {}
) {
let file = uri;
@@ -90,6 +91,7 @@ class AudiosHandler {
source,
name: params.name,
coverUrl: params.coverUrl,
compressing: params.compressing,
});
// create transcription if originalText is provided

View File

@@ -68,6 +68,7 @@ class VideosHandler {
name?: string;
coverUrl?: string;
md5?: string;
compressing?: boolean;
} = {}
) {
let file = uri;

View File

@@ -161,8 +161,9 @@ export class Audio extends Model<Audio> {
get extname(): string {
return (
this.getDataValue("metadata").extname ||
path.extname(this.getDataValue("source")) ||
this.getDataValue("metadata")?.extname ||
(this.getDataValue("source") &&
path.extname(this.getDataValue("source"))) ||
""
);
}
@@ -308,8 +309,10 @@ export class Audio extends Model<Audio> {
description?: string;
source?: string;
coverUrl?: string;
compressing?: boolean;
}
): Promise<Audio | Video> {
const { compressing = true } = params || {};
// Check if file exists
try {
fs.accessSync(filePath, fs.constants.R_OK);
@@ -334,7 +337,9 @@ export class Audio extends Model<Audio> {
},
});
if (!!existing) {
logger.warn("Audio already exists:", existing.id);
logger.warn("Audio already exists:", existing.id, existing.name);
existing.changed("updatedAt", true);
existing.update({ updatedAt: new Date() });
return existing;
}
@@ -344,7 +349,10 @@ export class Audio extends Model<Audio> {
logger.debug("Generated ID:", id);
const destDir = path.join(settings.userDataPath(), "audios");
const destFile = path.join(destDir, `${md5}.compressed.mp3`);
const destFile = path.join(
destDir,
compressing ? `${md5}.compressed.mp3` : `${md5}${extname}`
);
let metadata = {
extname,
@@ -363,8 +371,13 @@ export class Audio extends Model<Audio> {
duration: fileMetadata.format.duration,
});
// Compress file
await ffmpeg.compressAudio(filePath, destFile);
if (compressing) {
// Compress file
await ffmpeg.compressAudio(filePath, destFile);
} else {
// Copy file
fs.copyFileSync(filePath, destFile);
}
// Check if file copied
fs.accessSync(destFile, fs.constants.R_OK);

View File

@@ -324,8 +324,11 @@ export class Video extends Model<Video> {
description?: string;
source?: string;
coverUrl?: string;
compressing?: boolean;
}
): Promise<Audio | Video> {
const { compressing = true } = params || {};
// Check if file exists
try {
fs.accessSync(filePath, fs.constants.R_OK);
@@ -350,7 +353,9 @@ export class Video extends Model<Video> {
},
});
if (!!existing) {
logger.warn("Video already exists:", existing.id);
logger.warn("Video already exists:", existing.id, existing.name);
existing.changed("updatedAt", true);
existing.update({ updatedAt: new Date() });
return existing;
}
@@ -360,7 +365,10 @@ export class Video extends Model<Video> {
logger.debug("Generated ID:", id);
const destDir = path.join(settings.userDataPath(), "videos");
const destFile = path.join(destDir, `${md5}.compressed.mp4`);
const destFile = path.join(
destDir,
compressing ? `${md5}.compressed.mp4` : `${md5}${extname}`
);
let metadata = {
extname,
@@ -379,8 +387,13 @@ export class Video extends Model<Video> {
duration: fileMetadata.format.duration,
});
// Compress file to destFile
await ffmpeg.compressVideo(filePath, destFile);
if (compressing) {
// Compress file to destFile
await ffmpeg.compressVideo(filePath, destFile);
} else {
// Copy file
fs.copyFileSync(filePath, destFile);
}
// Check if file copied
fs.accessSync(destFile, fs.constants.R_OK);

View File

@@ -509,6 +509,7 @@ ${log}
// Create the browser window.
const mainWindow = new BrowserWindow({
show: false,
icon:
process.platform === "win32" ? "./assets/icon.ico" : "./assets/icon.png",
width: 1280,
@@ -521,6 +522,10 @@ ${log}
},
});
mainWindow.on("ready-to-show", () => {
mainWindow.show();
});
mainWindow.on("resize", () => {
mainWindow.webContents.send("window-on-resize", mainWindow.getBounds());
});

View File

@@ -1,17 +1,32 @@
import { Link } from "react-router-dom";
import { cn } from "@renderer/lib/utils";
import { AudioLinesIcon, CircleAlertIcon } from "lucide-react";
import { Badge } from "@renderer/components/ui";
import {
AudioLinesIcon,
CircleAlertIcon,
EditIcon,
MoreVerticalIcon,
TrashIcon,
} from "lucide-react";
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@renderer/components/ui";
import { t } from "i18next";
export const AudioCard = (props: {
audio: Partial<AudioType>;
className?: string;
onDelete?: () => void;
onEdit?: () => void;
}) => {
const { audio, className } = props;
const { audio, className, onDelete, onEdit } = props;
return (
<div className={cn("w-full", className)}>
<div className={cn("w-full relative", className)}>
<Link to={`/audios/${audio.id}`}>
<div
className="aspect-square border rounded-lg overflow-hidden flex relative"
@@ -47,6 +62,38 @@ export const AudioCard = (props: {
<div className="text-sm font-semibold mt-2 max-w-full line-clamp-2 h-10">
{audio.name}
</div>
{(onDelete || onEdit) && (
<div className="absolute right-1 top-1 z-10">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hover:bg-transparent w-6 h-6"
>
<MoreVerticalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{onEdit && (
<DropdownMenuItem onClick={onEdit}>
<EditIcon className="size-4" />
<span className="ml-2 text-sm">{t("edit")}</span>
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem onClick={onDelete}>
<TrashIcon className="size-4 text-destructive" />
<span className="ml-2 text-destructive text-sm">
{t("delete")}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
);
};

View File

@@ -4,6 +4,7 @@ import {
MediaAddButton,
AudiosTable,
AudioEditForm,
LoaderSpin,
} from "@renderer/components";
import { t } from "i18next";
import {
@@ -255,15 +256,24 @@ export const AudiosComponent = () => {
</div>
{audios.length === 0 ? (
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
{t("noData")}
</div>
loading ? (
<LoaderSpin />
) : (
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
{t("noData")}
</div>
)
) : (
<>
<TabsContent value="grid">
<div className="grid gap-4 grid-cols-5">
{audios.map((audio) => (
<AudioCard audio={audio} key={audio.id} />
<AudioCard
audio={audio}
key={audio.id}
onEdit={() => setEditing(audio)}
onDelete={() => setDeleting(audio)}
/>
))}
</div>
</TabsContent>
@@ -280,7 +290,7 @@ export const AudiosComponent = () => {
</Tabs>
</div>
{hasMore && (
{!loading && hasMore && (
<div className="flex items-center justify-center my-4">
<Button variant="link" onClick={() => fetchAudios()}>
{t("loadMore")}

View File

@@ -19,8 +19,7 @@ import {
CheckCircleIcon,
CircleAlertIcon,
} from "lucide-react";
import dayjs from "@renderer/lib/dayjs";
import { secondsToTimestamp } from "@renderer/lib/utils";
import { formatDateTime, secondsToTimestamp } from "@renderer/lib/utils";
import { Link } from "react-router-dom";
export const AudiosTable = (props: {
@@ -46,7 +45,7 @@ export const AudiosTable = (props: {
{t("models.audio.recordingsDuration")}
</TableHead>
<TableHead className="capitalize">
{t("models.audio.createdAt")}
{t("models.audio.updatedAt")}
</TableHead>
<TableHead className="capitalize">
{t("models.audio.isTranscribed")}
@@ -92,9 +91,7 @@ export const AudiosTable = (props: {
<TableCell>
{secondsToTimestamp(audio.recordingsDuration / 1000)}
</TableCell>
<TableCell>
{dayjs(audio.createdAt).format("YYYY-MM-DD HH:mm")}
</TableCell>
<TableCell>{formatDateTime(audio.updatedAt)}</TableCell>
<TableCell>
{audio.transcribed ? (
<CheckCircleIcon className="text-green-500 w-4 h-4" />

View File

@@ -10,6 +10,8 @@ import {
Button,
Progress,
toast,
Switch,
Label,
} from "@renderer/components/ui";
import { PlusCircleIcon, LoaderIcon } from "lucide-react";
import { t } from "i18next";
@@ -19,6 +21,7 @@ import {
AppSettingsProviderContext,
DbProviderContext,
} from "@renderer/context";
import { useNavigate } from "react-router-dom";
export const MediaAddButton = (props: { type?: "Audio" | "Video" }) => {
const { type = "Audio" } = props;
@@ -26,10 +29,13 @@ export const MediaAddButton = (props: { type?: "Audio" | "Video" }) => {
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
const [uri, setUri] = useState("");
const [files, setFiles] = useState<string[]>([]);
const [compressing, setCompressing] = useState(true);
const [open, setOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [createdCount, setCreatedCount] = useState(0);
const navigate = useNavigate();
const handleOpen = (value: boolean) => {
if (submitting) {
setOpen(true);
@@ -48,7 +54,13 @@ export const MediaAddButton = (props: { type?: "Audio" | "Video" }) => {
setSubmitting(true);
if (files.length > 1) {
Promise.allSettled(files.map((f) => EnjoyApp.audios.create(f)))
Promise.allSettled(
files.map((f) =>
EnjoyApp[type.toLowerCase() as "audios" | "videos"].create(f, {
compressing,
})
)
)
.then((results) => {
const fulfilled = results.filter((r) => r.status === "fulfilled");
const rejected = results.filter((r) => r.status === "rejected");
@@ -87,8 +99,9 @@ export const MediaAddButton = (props: { type?: "Audio" | "Video" }) => {
} else {
EnjoyApp.audios
.create(uri)
.then(() => {
.then((media) => {
toast.success(t("resourceAdded"));
navigate(`/${type.toLowerCase()}s/${media.id}`);
})
.catch((err) => {
toast.error(err.message);
@@ -135,7 +148,7 @@ export const MediaAddButton = (props: { type?: "Audio" | "Video" }) => {
</DialogDescription>
</DialogHeader>
<div className="flex space-x-2">
<div className="flex space-x-2 mb-2">
<Input
placeholder="https://"
value={uri}
@@ -168,6 +181,17 @@ export const MediaAddButton = (props: { type?: "Audio" | "Video" }) => {
{t("localFile")}
</Button>
</div>
<div className="flex items-center space-x-2 mb-2">
<Switch
checked={compressing}
onCheckedChange={(value) => setCompressing(value)}
/>
<span className="text-sm text-muted-foreground">
{compressing
? t("compressMediaBeforeAdding")
: t("keepOriginalMedia")}
</span>
</div>
{files.length > 0 && (
<div className="">

View File

@@ -1,17 +1,32 @@
import { Link } from "react-router-dom";
import { cn } from "@renderer/lib/utils";
import { CircleAlertIcon, VideoIcon } from "lucide-react";
import { Badge } from "@renderer/components/ui";
import {
CircleAlertIcon,
VideoIcon,
MoreVerticalIcon,
TrashIcon,
EditIcon,
} from "lucide-react";
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@renderer/components/ui";
import { t } from "i18next";
export const VideoCard = (props: {
video: Partial<VideoType>;
className?: string;
onDelete?: () => void;
onEdit?: () => void;
}) => {
const { video, className } = props;
const { video, className, onDelete, onEdit } = props;
return (
<div className={cn("w-full", className)}>
<div className={cn("w-full relative", className)}>
<Link to={`/videos/${video.id}`}>
<div
className="aspect-[4/3] border rounded-lg overflow-hidden relative"
@@ -45,6 +60,38 @@ export const VideoCard = (props: {
<div className="text-sm font-semibold mt-2 max-w-full truncate">
{video.name}
</div>
{(onDelete || onEdit) && (
<div className="absolute right-1 top-1 z-10">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="hover:bg-transparent w-6 h-6"
>
<MoreVerticalIcon className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{onEdit && (
<DropdownMenuItem onClick={onEdit}>
<EditIcon className="size-4" />
<span className="ml-2 text-sm">{t("edit")}</span>
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem onClick={onDelete}>
<TrashIcon className="size-4 text-destructive" />
<span className="ml-2 text-destructive text-sm">
{t("delete")}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
</div>
);
};

View File

@@ -4,6 +4,7 @@ import {
VideosTable,
VideoEditForm,
MediaAddButton,
LoaderSpin,
} from "@renderer/components";
import { t } from "i18next";
import {
@@ -251,15 +252,23 @@ export const VideosComponent = () => {
</AlertDialog>
</div>
{videos.length === 0 ? (
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
{t("noData")}
</div>
loading ? (
<LoaderSpin />
) : (
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
{t("noData")}
</div>
)
) : (
<>
<TabsContent value="grid">
<div className="grid gap-4 grid-cols-4">
{videos.map((video) => (
<VideoCard video={video} key={video.id} />
<VideoCard
video={video}
key={video.id}
onDelete={() => setDeleting(video)}
/>
))}
</div>
</TabsContent>
@@ -275,7 +284,7 @@ export const VideosComponent = () => {
</Tabs>
</div>
{hasMore && (
{!loading && hasMore && (
<div className="flex items-center justify-center my-4">
<Button variant="link" onClick={() => fetchVideos()}>
{t("loadMore")}

View File

@@ -19,8 +19,7 @@ import {
CheckCircleIcon,
CircleAlertIcon,
} from "lucide-react";
import dayjs from "@renderer/lib/dayjs";
import { secondsToTimestamp } from "@renderer/lib/utils";
import { formatDateTime, secondsToTimestamp } from "@renderer/lib/utils";
import { Link } from "react-router-dom";
export const VideosTable = (props: {
@@ -46,7 +45,7 @@ export const VideosTable = (props: {
{t("models.video.recordingsDuration")}
</TableHead>
<TableHead className="capitalize">
{t("models.video.createdAt")}
{t("models.video.updatedAt")}
</TableHead>
<TableHead className="capitalize">
{t("models.video.isTranscribed")}
@@ -92,9 +91,7 @@ export const VideosTable = (props: {
<TableCell>
{secondsToTimestamp(video.recordingsDuration / 1000)}
</TableCell>
<TableCell>
{dayjs(video.createdAt).format("YYYY-MM-DD HH:mm")}
</TableCell>
<TableCell>{formatDateTime(video.updatedAt)}</TableCell>
<TableCell>
{video.transcribed ? (
<CheckCircleIcon className="text-green-500 w-4 h-4" />