5 Commits

Author SHA1 Message Date
an-lee
050c577620 bump v0.1.0-alpha.3 2024-01-15 22:13:11 +08:00
an-lee
8d7a3e37ce build darwin-arm64 in ci 2024-01-15 21:55:15 +08:00
an-lee
23feb06d20 Fix: UI (#119)
* fix ui
2024-01-15 18:00:35 +08:00
an-lee
b545ea2362 Feat: save waveform as file (#118)
* package rpm

* cache waveform data as file in library

* clear waveform data in db

* fix some css
2024-01-15 16:57:44 +08:00
an-lee
187038c42e Feat: scan ffmpeg command (#116)
* package rpm

* fix model url

* scan/check ffmpeg command

* handle undefined

* add reset settings button

* add ffmpeg install instrunction for mac

* improve landing steps
2024-01-15 14:12:22 +08:00
43 changed files with 606 additions and 185 deletions

View File

@@ -18,3 +18,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
run: yarn publish:enjoy
- if: matrix.os == 'macos-latest'
env:
GITHUB_TOKEN: ${{ secrets.PUBLISH_TOKEN }}
run: yarn run publish --arch=arm64

View File

@@ -7,7 +7,7 @@
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true
"cssVariables": false
},
"aliases": {
"components": "src/renderer/components",

View File

@@ -2,6 +2,7 @@ import type { ForgeConfig } from "@electron-forge/shared-types";
import { MakerSquirrel } from "@electron-forge/maker-squirrel";
import { MakerZIP } from "@electron-forge/maker-zip";
import { MakerDeb } from "@electron-forge/maker-deb";
import { MakerRpm } from "@electron-forge/maker-rpm";
import { VitePlugin } from "@electron-forge/plugin-vite";
import { dirname } from "node:path";
import { Walker, DepType, type Module } from "flora-colossus";
@@ -44,6 +45,14 @@ const config: ForgeConfig = {
mimeType: ["x-scheme-handler/enjoy"],
},
}),
new MakerRpm({
options: {
name: "enjoy",
productName: "Enjoy",
icon: "./assets/icon.png",
mimeType: ["x-scheme-handler/enjoy"],
},
}),
],
publishers: [
{

View File

@@ -2,7 +2,7 @@
"private": true,
"name": "enjoy",
"productName": "Enjoy",
"version": "0.1.0-alpha.2",
"version": "0.1.0-alpha.3",
"description": "Enjoy desktop app",
"main": ".vite/build/main.js",
"types": "./src/types.d.ts",
@@ -125,7 +125,7 @@
"react-activity-calendar": "^2.2.1",
"react-dom": "^18.2.0",
"react-hook-form": "^7.49.2",
"react-hotkeys-hook": "^4.4.4",
"react-hotkeys-hook": "^4.4.3",
"react-i18next": "^14.0.0",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.21.1",

View File

@@ -32,9 +32,9 @@ export const WHISPER_MODELS_OPTIONS = [
},
{
type: "large",
name: "ggml-large.bin",
name: "ggml-large-v3.bin",
size: "3.09 GB",
url: "https://huggingface.co/ggerganov/whisper.cpp/blob/main/ggml-large-v3.bin",
url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3.bin",
},
];
@@ -46,35 +46,3 @@ export const PROCESS_TIMEOUT = 1000 * 60 * 15;
export const AI_GATEWAY_ENDPOINT =
"https://gateway.ai.cloudflare.com/v1/11d43ab275eb7e1b271ba4089ecc3864/enjoy";
export const CONVERSATION_PRESET_SCENARIOS: {
scenario: string;
autoSpeech: boolean;
prompt: string;
}[] = [
{
scenario: "translation",
autoSpeech: false,
prompt: `Act as a translation machine that converts any language input I provide into fluent, idiomatic American English. If the input is already in English, refine it to sound like native American English.
Suggestions:
Ensure that the translation maintains the original meaning and tone of the input as much as possible.
In case of English inputs, focus on enhancing clarity, grammar, and style to match American English standards.
Return the translation only, no other words needed.
`,
},
{
scenario: "vocal_coach",
autoSpeech: true,
prompt: `As an AI English vocal coach with an American accent, engage in a conversation with me to help improve my spoken English skills. Use the appropriate tone and expressions that a native American English speaker would use, keeping in mind that your responses will be converted to audio.
Suggestions:
Use common American idioms and phrases to give a more authentic experience of American English.
Provide corrections and suggestions for improvement in a supportive and encouraging manner.
Use a variety of sentence structures and vocabulary to expose me to different aspects of the language.`,
},
];

View File

@@ -190,9 +190,20 @@
"AIModel": "AI Model",
"chooseAIModelToDownload": "Choose AI Model to download",
"ffmpegCheck": "FFmpeg Check",
"check": "Check",
"ffmpegCommandIsWorking": "FFmpeg command is working",
"ffmpegCommandIsNotWorking": "FFmpeg command is not working",
"scan": "Scan",
"checkIfFfmpegIsInstalled": "Check if FFmpeg is installed",
"ffmpegInstalled": "FFmpeg is installed",
"ffmpegNotInstalled": "FFmpeg is not installed.",
"ffmpegFoundAt": "FFmpeg found at {{path}}",
"ffmpegNotFound": "FFmpeg not found",
"ffmpegInstallSteps": "FFmpeg Install Steps",
"Install": "Install",
"runTheFollowingCommandInTerminal": "Run the following command in terminal",
"click": "Click",
"willAutomaticallyFindFFmpeg": "Enjoy will automatically find FFmpeg",
"tryingToFindValidFFmepgInTheseDirectories": "Trying to find valid FFmpeg in these directories: {{dirs}}",
"invalidFfmpegPath": "Invalid FFmpeg path",
"usingInstalledFFmpeg": "Using installed FFmpeg",
"usingDownloadedFFmpeg": "Using downloaded FFmpeg",
"downloadFfmpeg": "Download FFmpeg",
@@ -208,7 +219,10 @@
"reset": "Reset",
"resetAll": "Reset All",
"resetAllConfirmation": "It will remove all of your personal data, are you sure?",
"resetSettings": "Reset Settings",
"resetSettingsConfirmation": "It will reset all of your settings, are you sure? The library will not be affected.",
"logoutAndRemoveAllPersonalData": "Logout and remove all personal data",
"logoutAndRemoveAllPersonalSettings": "Logout and remove all personal settings",
"hotkeys": "Hotkeys",
"quitApp": "Quit APP",
"openPreferences": "Open preferences",
@@ -267,8 +281,11 @@
"recordingActivity": "recording activity",
"recordingDetail": "Recording detail",
"noRecordingActivities": "no recording activities",
"basicSettingsShort": "Basic",
"basicSettings": "Basic settings",
"advancedSettingsShort": "Advanced",
"advancedSettings": "Advanced settings",
"advanced": "Advanced",
"language": "Language",
"sttAiModel": "STT AI model",
"relaunchIsNeededAfterChanged": "Relaunch is needed after changed",

View File

@@ -190,9 +190,20 @@
"AIModel": "AI 模型",
"chooseAIModelToDownload": "选择 AI 模型下载",
"ffmpegCheck": "FFmpeg 检查",
"check": "检查",
"ffmpegCommandIsWorking": "FFmpeg 命令正常工作",
"ffmpegCommandIsNotWorking": "FFmpeg 命令无法正常工作",
"scan": "查找",
"checkIfFfmpegIsInstalled": "检查 FFmpeg 是否已正确安装",
"ffmpegInstalled": "FFmpeg 已经安装",
"ffmpegNotInstalled": "FFmpeg 未安装,软件部分功能依赖于 FFmpeg",
"ffmpegFoundAt": "检测到 FFmpeg 命令: {{path}}",
"ffmpegNotFound": "未检测到可用的 FFmpeg 命令",
"ffmpegInstallSteps": "FFmpeg 安装步骤",
"Install": "安装",
"runTheFollowingCommandInTerminal": "在终端中运行以下命令",
"click": "点击",
"willAutomaticallyFindFFmpeg": "Enjoy 将自动检测 FFmpeg 命令",
"tryingToFindValidFFmepgInTheseDirectories": "正在尝试在以下目录中查找有效的 FFmpeg 命令: {{dirs}}",
"invalidFfmpegPath": "无效的 FFmpeg 路径",
"usingInstalledFFmpeg": "使用已安装的 FFmpeg",
"usingDownloadedFFmpeg": "使用下载的 FFmpeg",
"downloadFfmpeg": "下载 FFmpeg",
@@ -208,7 +219,10 @@
"reset": "重置",
"resetAll": "重置所有",
"resetAllConfirmation": "这将删除您的所有个人数据, 您确定要重置吗?",
"resetSettings": "重置设置选项",
"resetSettingsConfirmation": "您确定要重置个人设置选项吗?资料库不会受影响。",
"logoutAndRemoveAllPersonalData": "退出登录并删除所有个人数据",
"logoutAndRemoveAllPersonalSettings": "退出登录并删除所有个人设置选项",
"hotkeys": "快捷键",
"quitApp": "退出应用",
"openPreferences": "打开设置",
@@ -267,7 +281,9 @@
"recordingActivity": "练习活动",
"recordingDetail": "录音详情",
"noRecordingActivities": "没有练习活动",
"basicSettingsShort": "基本设置",
"basicSettings": "基本设置",
"advancedSettingsShort": "高级设置",
"advancedSettings": "高级设置",
"language": "语言",
"sttAiModel": "语音转文本 AI 模型",

View File

@@ -51,9 +51,6 @@ db.connect = async () => {
db.connection = sequelize;
// vacuum the database
await sequelize.query("VACUUM");
const umzug = new Umzug({
migrations: { glob: __dirname + "/migrations/*.js" },
context: sequelize.getQueryInterface(),
@@ -68,6 +65,23 @@ db.connect = async () => {
await sequelize.sync();
await sequelize.authenticate();
// TODO:
// clear the large waveform data in DB.
// Remove this in next release
const caches = await CacheObject.findAll({
attributes: ["id", "key"],
});
const cacheIds: string[] = [];
caches.forEach((cache) => {
if (cache.key.startsWith("waveform")) {
cacheIds.push(cache.id);
}
});
await CacheObject.destroy({ where: { id: cacheIds } });
// vacuum the database
await sequelize.query("VACUUM");
// register handlers
audiosHandler.register();
cacheObjectsHandler.register();

View File

@@ -7,14 +7,20 @@ import fs from "fs-extra";
import AdmZip from "adm-zip";
import downloader from "@main/downloader";
import storage from "@main/storage";
import readdirp from "readdirp";
import { t } from "i18next";
const logger = log.scope("ffmepg");
const logger = log.scope("ffmpeg");
export default class FfmpegWrapper {
public ffmpeg: Ffmpeg.FfmpegCommand;
public config: any;
constructor() {
this.config = settings.ffmpegConfig();
constructor(config?: {
ffmpegPath: string;
ffprobePath: string;
commandExists?: boolean;
}) {
this.config = config || settings.ffmpegConfig();
if (this.config.commandExists) {
logger.info("Using system ffmpeg");
@@ -28,7 +34,7 @@ export default class FfmpegWrapper {
}
}
checkCommand() {
checkCommand(): Promise<boolean> {
return new Promise((resolve, _reject) => {
this.ffmpeg.getAvailableFormats((err, formats) => {
if (err) {
@@ -319,17 +325,106 @@ export class FfmpegDownloader {
if (valid) {
event.sender.send("on-notification", {
type: "success",
message: `FFmpeg command valid, you're ready to go.`,
message: t("ffmpegCommandIsWorking"),
});
} else {
logger.error("FFmpeg command not valid", ffmpeg.config);
event.sender.send("on-notification", {
type: "warning",
message: `FFmpeg command not valid, please check the log for detail.`,
message: t("ffmpegCommandIsNotWorking"),
});
}
return valid;
});
ipcMain.handle("ffmpeg-discover-command", async (event) => {
try {
return await discoverFfmpeg();
} catch (err) {
logger.error(err);
event.sender.send("on-notification", {
type: "error",
message: `FFmpeg discover failed: ${err.message}`,
});
}
});
}
}
export const discoverFfmpeg = async () => {
const platform = process.platform;
let ffmpegPath: string;
let ffprobePath: string;
const libraryFfmpegPath = path.join(settings.libraryPath(), "ffmpeg");
const scanDirs = [...COMMAND_SCAN_DIR[platform], libraryFfmpegPath];
await Promise.all(
scanDirs.map(async (dir: string) => {
if (!fs.existsSync(dir)) return;
dir = path.resolve(dir);
log.info("FFmpeg scanning: " + dir);
const fileStream = readdirp(dir, {
depth: 3,
});
for await (const entry of fileStream) {
const appName = entry.basename
.replace(".app", "")
.replace(".exe", "")
.toLowerCase();
if (appName === "ffmpeg") {
logger.info("Found ffmpeg: ", entry.fullPath);
ffmpegPath = entry.fullPath;
}
if (appName === "ffprobe") {
logger.info("Found ffprobe: ", entry.fullPath);
ffprobePath = entry.fullPath;
}
if (ffmpegPath && ffprobePath) break;
}
})
);
let valid = false;
if (ffmpegPath && ffprobePath) {
const ffmepg = new FfmpegWrapper({ ffmpegPath, ffprobePath });
valid = await ffmepg.checkCommand();
}
if (valid) {
settings.setSync("ffmpeg", {
ffmpegPath,
ffprobePath,
});
} else {
ffmpegPath = undefined;
ffprobePath = undefined;
settings.setSync("ffmpeg", null);
}
return {
ffmpegPath,
ffprobePath,
scanDirs,
};
};
export const COMMAND_SCAN_DIR: { [key: string]: string[] } = {
darwin: [
"/Applications",
process.env.HOME + "/Applications",
"/opt/homebrew/bin",
],
linux: ["/usr/bin", "/usr/local/bin", "/snap/bin"],
win32: [
process.env.SystemDrive + "\\Program Files\\",
process.env.SystemDrive + "\\Program Files (x86)\\",
process.env.LOCALAPPDATA + "\\Apps\\2.0\\",
],
};

View File

@@ -96,32 +96,19 @@ const userDataPath = () => {
};
const ffmpegConfig = () => {
const _ffmpegPath = path.join(
libraryPath(),
"ffmpeg",
os.platform() === "win32" ? "ffmpeg.exe" : "ffmpeg"
);
const _ffprobePath = path.join(
libraryPath(),
"ffmpeg",
os.platform() === "win32" ? "ffprobe.exe" : "ffprobe"
);
const ffmpegPath = fs.existsSync(_ffmpegPath) ? _ffmpegPath : "";
const ffprobePath = fs.existsSync(_ffprobePath) ? _ffprobePath : "";
const ffmpegPath = settings.getSync("ffmpeg.ffmpegPath");
const ffprobePath = settings.getSync("ffmpeg.ffprobePath");
const _commandExists =
commandExists.sync("ffmpeg") && commandExists.sync("ffprobe");
const ready = Boolean(_commandExists || (ffmpegPath && ffprobePath));
const config = {
os: os.platform(),
arch: os.arch(),
commandExists: _commandExists,
ffmpegPath,
ffprobePath,
ready,
ready: Boolean(_commandExists || (ffmpegPath && ffprobePath)),
};
logger.info("ffmpeg config", config);

View File

@@ -0,0 +1,38 @@
import { ipcMain } from "electron";
import settings from "@main/settings";
import path from "path";
import fs from "fs-extra";
export class Waveform {
public dir = path.join(settings.libraryPath(), "waveforms");
constructor() {
fs.ensureDirSync(this.dir);
}
find(id: string) {
const file = path.join(this.dir, id + ".waveform.json");
if (fs.existsSync(file)) {
return fs.readJsonSync(file);
} else {
return null;
}
}
save(id: string, data: WaveFormDataType) {
const file = path.join(this.dir, id + ".waveform.json");
fs.writeJsonSync(file, data);
}
registerIpcHandlers() {
ipcMain.handle("waveforms-find", async (_event, id) => {
return this.find(id);
});
ipcMain.handle("waveforms-save", (_event, id, data) => {
return this.save(id, data);
});
}
}

View File

@@ -18,6 +18,7 @@ import log from "electron-log/main";
import { WEB_API_URL } from "@/constants";
import { AudibleProvider, TedProvider } from "@main/providers";
import { FfmpegDownloader } from "@main/ffmpeg";
import { Waveform } from "./waveform";
log.initialize({ preload: true });
const logger = log.scope("window");
@@ -25,6 +26,7 @@ const logger = log.scope("window");
const audibleProvider = new AudibleProvider();
const tedProvider = new TedProvider();
const ffmpegDownloader = new FfmpegDownloader();
const waveform = new Waveform();
const main = {
win: null as BrowserWindow | null,
@@ -46,6 +48,9 @@ main.init = () => {
// Whisper
whisper.registerIpcHandlers();
// Waveform
waveform.registerIpcHandlers();
// Downloader
downloader.registerIpcHandlers();
@@ -216,6 +221,14 @@ main.init = () => {
// App options
ipcMain.handle("app-reset", () => {
fs.removeSync(settings.userDataPath());
fs.removeSync(settings.file());
app.relaunch();
app.exit();
});
ipcMain.handle("app-reset-settings", () => {
fs.removeSync(settings.file());
app.relaunch();
app.exit();

View File

@@ -8,6 +8,9 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
reset: () => {
ipcRenderer.invoke("app-reset");
},
resetSettings: () => {
ipcRenderer.invoke("app-reset-settings");
},
relaunch: () => {
ipcRenderer.invoke("app-relaunch");
},
@@ -143,12 +146,15 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
getFfmpegConfig: () => {
return ipcRenderer.invoke("settings-get-ffmpeg-config");
},
setFfmpegConfig: (config: FfmpegConfigType) => {
return ipcRenderer.invoke("settings-set-ffmpeg-config", config);
},
getLanguage: (language: string) => {
return ipcRenderer.invoke("settings-get-language", language);
},
switchLanguage: (language: string) => {
return ipcRenderer.invoke("settings-switch-language", language);
}
},
},
path: {
join: (...paths: string[]) => {
@@ -344,6 +350,9 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
download: () => {
return ipcRenderer.invoke("ffmpeg-download");
},
discover: () => {
return ipcRenderer.invoke("ffmpeg-discover-command");
},
check: () => {
return ipcRenderer.invoke("ffmpeg-check-command");
},
@@ -390,4 +399,12 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
return ipcRenderer.invoke("transcriptions-update", id, params);
},
},
waveforms: {
find: (id: string) => {
return ipcRenderer.invoke("waveforms-find", id);
},
save: (id: string, data: WaveFormDataType) => {
return ipcRenderer.invoke("waveforms-save", id, data);
},
}
});

View File

@@ -53,7 +53,7 @@ function App() {
<AISettingsProvider>
<DbProvider>
<RouterProvider router={router} />
<Toaster richColors />
<Toaster richColors closeButton position="top-center" />
<Tooltip id="global-tooltip" />
</DbProvider>
</AISettingsProvider>

View File

@@ -132,7 +132,7 @@ export const AudioDetail = (props: { id?: string; md5?: string }) => {
mediaId={audio.id}
mediaType="Audio"
mediaUrl={audio.src}
waveformCacheKey={`waveform-audio-${audio.md5}`}
mediaMd5={audio.md5}
transcription={transcription}
currentTime={currentTime}
setCurrentTime={setCurrentTime}
@@ -207,7 +207,7 @@ export const AudioDetail = (props: { id?: string; md5?: string }) => {
</AlertDialog>
{!initialized && (
<div className="top-0 w-full h-full absolute z-30 bg-white/10 flex items-center justify-center">
<div className="top-0 w-full h-full absolute z-30 bg-background/10 flex items-center justify-center">
<LoaderIcon className="text-muted-foreground animate-spin w-8 h-8" />
</div>
)}

View File

@@ -48,7 +48,7 @@ export const ConversationsShortcut = (props: {
<div
key={conversation.id}
onClick={() => ask(conversation)}
className="bg-white text-primary rounded-full w-full mb-2 py-2 px-4 hover:bg-primary hover:text-white cursor-pointer flex items-center border"
className="bg-background text-primary rounded-full w-full mb-2 py-2 px-4 hover:bg-primary hover:text-white cursor-pointer flex items-center border"
style={{
borderLeftColor: `#${conversation.id
.replaceAll("-", "")

View File

@@ -89,7 +89,7 @@ export const SpeechPlayer = (props: {
</div>
<div
ref={ref}
className="bg-white rounded-lg grid grid-cols-9 items-center relative pl-2 h-[100px]"
className="bg-background rounded-lg grid grid-cols-9 items-center relative pl-2 h-[100px]"
>
{!initialized && (
<div className="col-span-9 flex flex-col justify-around h-[80px]">

View File

@@ -1,13 +1,19 @@
import { t } from "i18next";
import { useContext, useEffect, useState } from "react";
import { Button, Progress } from "@renderer/components/ui";
import { Button, Progress, toast } from "@renderer/components/ui";
import { AppSettingsProviderContext } from "@renderer/context";
import { CheckCircle2Icon, XCircleIcon, LoaderIcon } from "lucide-react";
import Markdown from "react-markdown";
export const FfmpegCheck = () => {
const { ffmpegConfig, setFfmegConfig, EnjoyApp } = useContext(
AppSettingsProviderContext
);
const [scanResult, setScanResult] = useState<{
ffmpegPath: string;
ffprobePath: string;
scanDirs: string[];
}>();
const [downloading, setDownloading] = useState(false);
const [progress, setProgress] = useState(0);
@@ -17,15 +23,25 @@ export const FfmpegCheck = () => {
});
};
const discoverFfmpeg = () => {
EnjoyApp.ffmpeg.discover().then((config) => {
setScanResult(config);
if (config.ffmpegPath && config.ffprobePath) {
toast.success(t("ffmpegFound"));
refreshFfmpegConfig();
} else {
toast.error(t("ffmpegNotFound"));
}
});
};
const downloadFfmpeg = () => {
listenToDownloadState();
setDownloading(true);
EnjoyApp.ffmpeg
.download()
.then((config) => {
if (config) {
setFfmegConfig(config);
}
.then(() => {
refreshFfmpegConfig();
})
.finally(() => {
setDownloading(false);
@@ -44,11 +60,11 @@ export const FfmpegCheck = () => {
}, [ffmpegConfig?.ready]);
useEffect(() => {
refreshFfmpegConfig();
discoverFfmpeg();
}, []);
return (
<div className="w-full max-w-sm px-6">
<div className="w-full max-w-screen-md mx-auto px-6">
{ffmpegConfig?.ready ? (
<>
<div className="flex justify-center items-center mb-8">
@@ -58,7 +74,7 @@ export const FfmpegCheck = () => {
<CheckCircle2Icon className="text-green-500 w-10 h-10 mb-4" />
</div>
<div className="text-center text-sm opacity-70">
{t("ffmpegInstalled")}
{t("ffmpegFoundAt", { path: ffmpegConfig.ffmpegPath })}
</div>
</>
) : (
@@ -69,24 +85,87 @@ export const FfmpegCheck = () => {
<div className="flex justify-center mb-4">
<XCircleIcon className="text-red-500 w-10 h-10" />
</div>
<div className="text-center text-sm opacity-70 mb-4">
{t("ffmpegNotInstalled")}
<div className="mb-4">
<div className="text-center text-sm mb-2">
{t("ffmpegNotFound")}
</div>
{scanResult && (
<div className="text-center text-xs text-muted-foreground mb-2">
{t("tryingToFindValidFFmepgInTheseDirectories", {
dirs: scanResult.scanDirs.join(", "),
})}
</div>
)}
</div>
<div className="flex items-center justify-center mb-4">
<Button
disabled={downloading}
className=""
onClick={downloadFfmpeg}
>
{downloading && <LoaderIcon className="animate-spin mr-2" />}
{t("downloadFfmpeg")}
<div className="flex items-center justify-center space-x-4 mb-4">
<Button onClick={discoverFfmpeg} variant="default">
{t("scan")}
</Button>
{ffmpegConfig.os === "win32" && (
<Button
variant="secondary"
disabled={downloading}
onClick={downloadFfmpeg}
>
{downloading && <LoaderIcon className="animate-spin mr-2" />}
{t("download")}
</Button>
)}
</div>
{downloading && (
<div className="w-full">
<Progress value={progress} />
</div>
)}
{ffmpegConfig.os === "darwin" && (
<div className="my-6 select-text prose mx-auto border rounded-lg p-4">
<h3 className="text-center">{t("ffmpegInstallSteps")}</h3>
<h4>
1. {t("install")}{" "}
<a
className="cursor-pointer text-blue-500 hover:underline"
onClick={() => {
EnjoyApp.shell.openExternal("https://brew.sh/");
}}
>
Homebrew
</a>
</h4>
<p>{t("runTheFollowingCommandInTerminal")} </p>
<pre>
<code>
/bin/bash -c "$(curl -fsSL
https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
</code>
</pre>
<h4>2. {t("install")} FFmpeg</h4>
<p>{t("runTheFollowingCommandInTerminal")} </p>
<pre>
<code>brew install ffmpeg</code>
</pre>
<h4>3. {t("scan")} FFmpeg</h4>
<p>
{t("click")}
<Button
onClick={discoverFfmpeg}
variant="default"
size="sm"
className="mx-2"
>
{t("scan")}
</Button>
, {t("willAutomaticallyFindFFmpeg")}
</p>
</div>
)}
</>
)}
</div>

View File

@@ -1,10 +1,13 @@
import { Button, toast } from "@renderer/components/ui";
import { Button, toast, Separator } from "@renderer/components/ui";
import { useContext, useEffect } from "react";
import { AppSettingsProviderContext } from "@renderer/context";
import { t } from "i18next";
import { UserSettings, LanguageSettings } from "@renderer/components";
export const LoginForm = () => {
const { EnjoyApp, login, webApi } = useContext(AppSettingsProviderContext);
const { EnjoyApp, login, webApi, user } = useContext(
AppSettingsProviderContext
);
const handleMixinLogin = () => {
const url = `${webApi.baseUrl}/sessions/new?provider=mixin`;
@@ -59,6 +62,16 @@ export const LoginForm = () => {
};
}, [webApi]);
if (user) {
return (
<div className="px-4 py-2 border rounded-lg w-full max-w-sm">
<UserSettings />
<Separator />
<LanguageSettings />
</div>
);
}
return (
<div className="w-full max-w-sm px-6 flex flex-col space-y-4">
<Button

View File

@@ -34,7 +34,7 @@ export const MediaPlayer = (props: {
mediaId: string;
mediaType: "Audio" | "Video";
mediaUrl: string;
waveformCacheKey: string;
mediaMd5?: string;
transcription: TranscriptionType;
// player controls
currentTime: number;
@@ -67,7 +67,7 @@ export const MediaPlayer = (props: {
mediaId,
mediaType,
mediaUrl,
waveformCacheKey,
mediaMd5,
transcription,
height = 200,
currentTime,
@@ -94,12 +94,7 @@ export const MediaPlayer = (props: {
if (!mediaUrl) return;
const [wavesurfer, setWavesurfer] = useState(null);
const [waveform, setWaveForm] = useState<{
peaks: number[];
duration: number;
frequencies: number[];
sampleRate: number;
}>(null);
const [waveform, setWaveForm] = useState<WaveFormDataType>(null);
const containerRef = useRef<HTMLDivElement>();
const [mediaProvider, setMediaProvider] = useState<
HTMLAudioElement | HTMLVideoElement
@@ -181,7 +176,7 @@ export const MediaPlayer = (props: {
const renderPitchContour = (region: RegionType) => {
if (!region) return;
if (!waveform.frequencies.length) return;
if (!waveform?.frequencies?.length) return;
if (!wavesurfer) return;
const duration = wavesurfer.getDuration();
@@ -280,7 +275,6 @@ export const MediaPlayer = (props: {
const ws = WaveSurfer.create({
container: containerRef.current,
height,
url: mediaUrl,
waveColor: "#ddd",
progressColor: "rgba(0, 0, 0, 0.25)",
cursorColor: "#dc143c",
@@ -324,6 +318,7 @@ export const MediaPlayer = (props: {
const subscriptions = [
wavesurfer.on("play", () => setIsPlaying(true)),
wavesurfer.on("pause", () => setIsPlaying(false)),
wavesurfer.on("loading", (percent: number) => console.log(percent)),
wavesurfer.on("timeupdate", (time: number) => setCurrentTime(time)),
wavesurfer.on("decode", () => {
if (waveform?.frequencies) return;
@@ -340,7 +335,7 @@ export const MediaPlayer = (props: {
sampleRate,
frequencies: _frequencies,
};
EnjoyApp.cacheObjects.set(waveformCacheKey, _waveform);
EnjoyApp.waveforms.save(mediaMd5, _waveform);
setWaveForm(_waveform);
}),
wavesurfer.on("ready", () => {
@@ -479,10 +474,8 @@ export const MediaPlayer = (props: {
}, [wavesurfer, isPlaying]);
useEffect(() => {
EnjoyApp.cacheObjects.get(waveformCacheKey).then((cached) => {
if (!cached) return;
setWaveForm(cached);
EnjoyApp.waveforms.find(mediaMd5).then((waveform) => {
setWaveForm(waveform);
});
}, []);

View File

@@ -90,11 +90,11 @@ export const AssistantMessageComponent = (props: {
id={`message-${message.id}`}
className="flex items-end space-x-2 pr-10"
>
<Avatar className="w-8 h-8 bg-white avatar">
<Avatar className="w-8 h-8 bg-background avatar">
<AvatarImage></AvatarImage>
<AvatarFallback className="bg-white">AI</AvatarFallback>
<AvatarFallback className="bg-background">AI</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2 px-4 py-2 bg-white border rounded-lg shadow-sm w-full">
<div className="flex flex-col gap-2 px-4 py-2 bg-background border rounded-lg shadow-sm w-full">
{configuration?.autoSpeech && speeching ? (
<div className="p-4">
<LoaderIcon className="w-8 h-8 animate-spin" />

View File

@@ -32,6 +32,7 @@ import {
} from "lucide-react";
import { useCopyToClipboard } from "@uidotdev/usehooks";
import { t } from "i18next";
import { useNavigate } from "react-router-dom";
import Markdown from "react-markdown";
export const UserMessageComponent = (props: {
@@ -45,6 +46,7 @@ export const UserMessageComponent = (props: {
const { user, webApi } = useContext(AppSettingsProviderContext);
const [_, copyToClipboard] = useCopyToClipboard();
const [copied, setCopied] = useState<boolean>(false);
const navigate = useNavigate();
const handleShare = async () => {
if (message.role === "user") {
@@ -57,7 +59,18 @@ export const UserMessageComponent = (props: {
},
})
.then(() => {
toast(t("sharedSuccessfully"), { description: t("sharedPrompt") });
toast.success(t("sharedSuccessfully"), {
description: t("sharedPrompt"),
action: {
label: t("view"),
onClick: () => {
navigate("/community");
},
},
actionButtonStyle: {
backgroundColor: "var(--primary)",
},
});
})
.catch((err) => {
toast.error(t("shareFailed"), { description: err.message });
@@ -155,7 +168,7 @@ export const UserMessageComponent = (props: {
</DropdownMenuContent>
</DropdownMenu>
<Avatar className="w-8 h-8 bg-white">
<Avatar className="w-8 h-8 bg-background">
<AvatarImage src={user.avatarUrl} />
<AvatarFallback className="bg-primary text-white capitalize">
{user.name[0]}

View File

@@ -165,7 +165,7 @@ const WavesurferPlayer = (props: {
<div
ref={ref}
className="bg-white rounded-lg grid grid-cols-9 items-center relative h-[80px]"
className="bg-background rounded-lg grid grid-cols-9 items-center relative h-[80px]"
>
{!initialized && (
<div className="col-span-9 flex flex-col justify-around h-[80px]">

View File

@@ -20,7 +20,7 @@ export const PostCard = (props: {
const { user } = useContext(AppSettingsProviderContext);
return (
<div className="rounded p-4 bg-white space-y-3">
<div className="rounded p-4 bg-background space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Avatar>

View File

@@ -10,6 +10,35 @@ export const AdvancedSettings = () => {
{t("advancedSettings")}
</div>
<div className="flex items-start justify-between py-4">
<div className="">
<div className="mb-2">{t("resetSettings")}</div>
<div className="text-sm text-muted-foreground mb-2">
{t("logoutAndRemoveAllPersonalSettings")}
</div>
</div>
<div className="">
<div className="mb-2 flex justify-end">
<ResetAllButton>
<Button
variant="secondary"
className="text-destructive"
size="sm"
>
{t("resetSettings")}
</Button>
</ResetAllButton>
</div>
<div className="text-xs text-muted-foreground">
<InfoIcon className="mr-1 w-3 h-3 inline" />
<span>{t("relaunchIsNeededAfterChanged")}</span>
</div>
</div>
</div>
<Separator />
<div className="flex items-start justify-between py-4">
<div className="">
<div className="mb-2">{t("resetAll")}</div>
@@ -26,7 +55,7 @@ export const AdvancedSettings = () => {
className="text-destructive"
size="sm"
>
{t("reset")}
{t("resetAll")}
</Button>
</ResetAllButton>
</div>

View File

@@ -36,7 +36,7 @@ import {
} from "@renderer/context";
import { useContext, useState, useRef, useEffect } from "react";
import { redirect } from "react-router-dom";
import { InfoIcon } from "lucide-react";
import { InfoIcon, EditIcon } from "lucide-react";
export const BasicSettings = () => {
return (
@@ -60,7 +60,7 @@ export const BasicSettings = () => {
);
};
const UserSettings = () => {
export const UserSettings = () => {
const { user, logout } = useContext(AppSettingsProviderContext);
if (!user) return null;
@@ -113,7 +113,7 @@ const UserSettings = () => {
);
};
const LanguageSettings = () => {
export const LanguageSettings = () => {
const { language, switchLanguage } = useContext(AppSettingsProviderContext);
return (
@@ -188,7 +188,11 @@ const LibraryPathSettings = () => {
<Button variant="secondary" size="sm" onClick={openLibraryPath}>
{t("open")}
</Button>
<Button variant="default" size="sm" onClick={handleChooseLibraryPath}>
<Button
variant="secondary"
size="sm"
onClick={handleChooseLibraryPath}
>
{t("edit")}
</Button>
</div>
@@ -202,49 +206,103 @@ const LibraryPathSettings = () => {
};
const FfmpegSettings = () => {
const { libraryPath, EnjoyApp } = useContext(AppSettingsProviderContext);
const [config, setConfig] = useState<FfmpegConfigType>();
const { EnjoyApp, setFfmegConfig, ffmpegConfig } = useContext(
AppSettingsProviderContext
);
const [editing, setEditing] = useState(false);
useEffect(() => {
EnjoyApp.settings.getFfmpegConfig().then((_config) => {
setConfig(_config);
const refreshFfmpegConfig = async () => {
EnjoyApp.settings.getFfmpegConfig().then((config) => {
setFfmegConfig(config);
});
}, []);
};
const handleChooseFfmpeg = async () => {
const filePaths = await EnjoyApp.dialog.showOpenDialog({
properties: ["openFile"],
});
const path = filePaths?.[0];
if (!path) return;
if (path.includes("ffmpeg")) {
EnjoyApp.settings.setFfmpegConfig({
...ffmpegConfig,
ffmpegPath: path,
});
refreshFfmpegConfig();
} else if (path.includes("ffprobe")) {
EnjoyApp.settings.setFfmpegConfig({
...ffmpegConfig,
ffprobePath: path,
});
refreshFfmpegConfig();
} else {
toast.error(t("invalidFfmpegPath"));
}
};
return (
<div className="flex items-start justify-between py-4">
<div className="">
<div className="mb-2">FFmpeg</div>
<div className="text-sm text-muted-foreground mb-2">
{config?.commandExists && t("usingInstalledFFmpeg")}
{!config?.commandExists &&
`${t("usingDownloadedFFmpeg")}: ${config?.ffmpegPath}`}
<>
<div className="flex items-start justify-between py-4">
<div className="">
<div className="mb-2">FFmpeg</div>
<div className="flex items-center space-x-4">
<span className=" text-sm text-muted-foreground">
<b>ffmpeg</b>: {ffmpegConfig?.ffmpegPath || ""}
</span>
{editing && (
<Button onClick={handleChooseFfmpeg} variant="ghost" size="icon">
<EditIcon className="w-4 h-4 text-muted-foreground" />
</Button>
)}
</div>
<div className="flex items-center space-x-4">
<span className=" text-sm text-muted-foreground">
<b>ffprobe</b>: {ffmpegConfig?.ffprobePath || ""}
</span>
{editing && (
<Button onClick={handleChooseFfmpeg} variant="ghost" size="icon">
<EditIcon className="w-4 h-4 text-muted-foreground" />
</Button>
)}
</div>
</div>
</div>
<div className="">
<div className="flex items-center justify-end space-x-2 mb-2">
{config?.ffmpegPath && (
<div className="">
<div className="flex items-center justify-end space-x-2 mb-2">
<Button
variant="secondary"
size="sm"
onClick={() => {
EnjoyApp.shell.openPath(libraryPath);
EnjoyApp.ffmpeg
.discover()
.then(({ ffmpegPath, ffprobePath }) => {
if (ffmpegPath && ffprobePath) {
toast.success(
t("ffmpegFoundAt", {
path: ffmpegPath + ", " + ffprobePath,
})
);
} else {
toast.warning(t("ffmpegNotFound"));
}
refreshFfmpegConfig();
});
}}
>
{t("open")}
{t("scan")}
</Button>
)}
<Button
size="sm"
onClick={() => {
EnjoyApp.ffmpeg.check();
}}
>
{t("check")}
</Button>
<Button
variant={editing ? "outline" : "secondary"}
size="sm"
onClick={() => setEditing(!editing)}
>
{editing ? t("cancel") : t("edit")}
</Button>
</div>
</div>
</div>
</div>
</>
);
};
@@ -262,7 +320,9 @@ const WhisperSettings = () => {
<Dialog>
<DialogTrigger asChild>
<Button size="sm">{t("edit")}</Button>
<Button variant="secondary" size="sm">
{t("edit")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>{t("sttAiModel")}</DialogHeader>
@@ -339,7 +399,7 @@ const OpenaiSettings = () => {
</div>
<div className="">
<Button
variant={editing ? "secondary" : "default"}
variant={editing ? "outline" : "secondary"}
size="sm"
onClick={() => setEditing(!editing)}
>
@@ -403,7 +463,7 @@ const GoogleGenerativeAiSettings = () => {
</div>
<div className="">
<Button
variant={editing ? "secondary" : "default"}
variant={editing ? "outline" : "secondary"}
size="sm"
onClick={() => setEditing(!editing)}
>

View File

@@ -1,18 +1,23 @@
import { t } from "i18next";
import { Button, ScrollArea } from "@renderer/components/ui";
import { BasicSettings, AdvancedSettings, About, Hotkeys } from "@renderer/components";
import {
BasicSettings,
AdvancedSettings,
About,
Hotkeys,
} from "@renderer/components";
import { useState } from "react";
export const Preferences = () => {
const TABS = [
{
value: "basic",
label: t("basicSettings"),
label: t("basicSettingsShort"),
component: () => <BasicSettings />,
},
{
value: "advanced",
label: t("advancedSettings"),
label: t("advancedSettingsShort"),
component: () => <AdvancedSettings />,
},
{
@@ -30,8 +35,8 @@ export const Preferences = () => {
const [activeTab, setActiveTab] = useState<string>("basic");
return (
<div className="grid grid-cols-5">
<ScrollArea className="col-span-1 h-full bg-muted/50 p-4">
<div className="grid grid-cols-5 overflow-hidden h-full">
<ScrollArea className="h-full col-span-1 bg-muted/50 p-4">
<div className="py-2 text-muted-foreground mb-4">
{t("sidebar.preferences")}
</div>
@@ -50,7 +55,7 @@ export const Preferences = () => {
</Button>
))}
</ScrollArea>
<ScrollArea className="col-span-4 p-6">
<ScrollArea className="h-full col-span-4 py-6 px-10">
{TABS.find((tab) => tab.value === activeTab)?.component()}
</ScrollArea>
</div>

View File

@@ -96,7 +96,7 @@ export const PronunciationAssessmentScoreResult = (props: {
</div>
{!pronunciationScore && (
<div className="w-full h-full absolute z-30 bg-white/10 flex items-center justify-center">
<div className="w-full h-full absolute z-30 bg-background/10 flex items-center justify-center">
<Button size="lg" disabled={assessing} onClick={onAssess}>
{assessing && (
<LoaderIcon className="w-4 h-4 animate-spin inline mr-2" />

View File

@@ -70,7 +70,7 @@ export const RecordingCard = (props: {
return (
<div id={id} className="flex items-center justify-end px-4 transition-all">
<div className="w-full">
<div className="bg-white rounded-lg py-2 px-4 relative mb-1">
<div className="bg-background rounded-lg py-2 px-4 relative mb-1">
<div className="flex items-center justify-end space-x-2">
<span className="text-xs text-muted-foreground">
{secondsToTimestamp(recording.duration / 1000)}

View File

@@ -44,3 +44,35 @@ export const ResetAllButton = (props: { children: React.ReactNode }) => {
</AlertDialog>
);
};
export const ResetSettingsButton = (props: { children: React.ReactNode }) => {
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const reset = () => {
EnjoyApp.app.resetSettings();
};
return (
<AlertDialog>
<AlertDialogTrigger asChild>{props.children}</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("resetSettings")}</AlertDialogTitle>
</AlertDialogHeader>
<AlertDialogDescription>
{t("resetSettingsConfirmation")}
</AlertDialogDescription>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive hover:bg-destructive-hover"
onClick={reset}
>
{t("resetSettings")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};

View File

@@ -13,7 +13,7 @@ import { debounce , uniq } from "lodash";
import Mark from "mark.js";
export const StoryViewer = (props: {
story: StoryType & Partial<CreateStoryParamsType>;
story: Partial<StoryType> & Partial<CreateStoryParamsType>;
marked?: boolean;
meanings?: MeaningType[];
setMeanings: (meanings: MeaningType[]) => void;
@@ -96,7 +96,7 @@ export const StoryViewer = (props: {
return (
<>
<div className="w-full max-w-2xl xl:max-w-3xl mx-auto sticky bg-white top-0 z-30 px-4 py-2 border-b">
<div className="w-full max-w-2xl xl:max-w-3xl mx-auto sticky bg-background top-0 z-30 px-4 py-2 border-b">
<div className="w-full flex items-center space-x-4">
<Button
variant="ghost"
@@ -130,10 +130,10 @@ export const StoryViewer = (props: {
</div>
</div>
</div>
<div className="bg-white py-6 px-8 max-w-2xl xl:max-w-3xl mx-auto relative shadow-lg">
<div className="bg-background py-6 px-8 max-w-2xl xl:max-w-3xl mx-auto relative shadow-lg">
<article
ref={ref}
className="relative select-text prose prose-lg xl:prose-xl font-serif text-lg"
className="relative select-text prose dark:prose-invert prose-lg xl:prose-xl font-serif text-lg"
>
<h2>
{story.title.split(" ").map((word, i) => (

View File

@@ -41,8 +41,8 @@ export const ToolbarButton = (props: {
className={cn(
`rounded-full p-3 h-12 w-12 ${
toggled
? "bg-primary text-white"
: "bg-white text-muted-foreground hover:text-white "
? "bg-primary dark:bg-background text-background dark:text-foreground"
: "bg-background dark:bg-muted text-muted-foreground hover:text-background "
}`,
className
)}

View File

@@ -6,7 +6,7 @@ import { Toaster as Sonner, toast } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
const { theme = "light" } = useTheme();
return (
<Sonner

View File

@@ -81,7 +81,9 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
targetId: video.id,
})
.then(() => {
toast.success(t("sharedSuccessfully"), { description: t("sharedVideo") });
toast.success(t("sharedSuccessfully"), {
description: t("sharedVideo"),
});
})
.catch((err) => {
toast.error(t("shareFailed"), { description: err.message });
@@ -136,7 +138,7 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
mediaId={video.id}
mediaType="Video"
mediaUrl={video.src}
waveformCacheKey={`waveform-video-${video.md5}`}
mediaMd5={video.md5}
transcription={transcription}
currentTime={currentTime}
setCurrentTime={setCurrentTime}
@@ -216,7 +218,7 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
</AlertDialog>
{!initialized && (
<div className="top-0 w-full h-full absolute z-30 bg-white/10 flex items-center justify-center">
<div className="top-0 w-full h-full absolute z-30 bg-background/10 flex items-center justify-center">
<LoaderIcon className="text-muted-foreground animate-spin w-8 h-8" />
</div>
)}

View File

@@ -78,7 +78,6 @@ export const AppSettingsProvider = ({
const fetchLanguage = async () => {
const language = await EnjoyApp.settings.getLanguage();
console.log(language);
setLanguage(language as "en" | "zh-CN");
i18n.changeLanguage(language);
};

View File

@@ -271,7 +271,7 @@ export default () => {
</ScrollArea>
<div className="px-4 absolute w-full bottom-0 left-0 h-14 bg-muted z-50">
<div className="focus-within:bg-white px-4 py-2 flex items-center space-x-4 rounded-lg border">
<div className="focus-within:bg-background px-4 py-2 flex items-center space-x-4 rounded-lg border">
<Textarea
rows={1}
ref={inputRef}
@@ -279,7 +279,7 @@ export default () => {
value={content}
onChange={(e) => setConent(e.target.value)}
placeholder={t("pressEnterToSend")}
className="px-0 py-0 shadow-none border-none focus-visible:outline-0 focus-visible:ring-0 border-none bg-muted focus:bg-white min-h-[1.25rem] max-h-[3.5rem] !overflow-x-hidden"
className="px-0 py-0 shadow-none border-none focus-visible:outline-0 focus-visible:ring-0 border-none bg-muted focus:bg-background min-h-[1.25rem] max-h-[3.5rem] !overflow-x-hidden"
/>
<Button
type="submit"

View File

@@ -86,7 +86,7 @@ export default () => {
{conversations.map((conversation) => (
<Link key={conversation.id} to={`/conversations/${conversation.id}`}>
<div
className="bg-white text-primary rounded-full w-full mb-2 p-4 hover:bg-primary hover:text-white cursor-pointer flex items-center"
className="bg-background text-muted-foreground rounded-full w-full mb-2 p-4 hover:bg-primary hover:text-muted cursor-pointer flex items-center"
style={{
borderLeftColor: `#${conversation.id
.replaceAll("-", "")

View File

@@ -6,7 +6,6 @@ import {
LoginForm,
ChooseLibraryPathInput,
WhisperModelOptionsPanel,
UserCard,
FfmpegCheck,
} from "@renderer/components";
import { AppSettingsProviderContext } from "@renderer/context";
@@ -93,7 +92,7 @@ export default () => {
</div>
</div>
<div className="flex-1 flex justify-center items-center">
{currentStep == 1 && (user ? <UserCard user={user} /> : <LoginForm />)}
{currentStep == 1 && <LoginForm />}
{currentStep == 2 && <ChooseLibraryPathInput />}
{currentStep == 3 && <WhisperModelOptionsPanel />}
{currentStep == 4 && <FfmpegCheck />}

View File

@@ -38,7 +38,8 @@ export default () => {
}
return (
<div className="h-[100vh] max-w-screen-md mx-auto px-4 py-6">
<div className="h-[100vh] bg-muted">
<div className="max-w-screen-md mx-auto px-4 py-6">
<div className="flex space-x-1 items-center mb-4">
<Button variant="ghost" size="icon" onClick={() => navigate(-1)}>
<ChevronLeftIcon className="w-5 h-5" />
@@ -62,7 +63,7 @@ export default () => {
>
<ChevronLeftIcon className="w-5 h-5" />
</Button>
<div className="flex-1 h-5/6 border p-6 rounded-xl shadow-lg">
<div className="bg-background flex-1 h-5/6 border p-6 rounded-xl shadow-lg">
<MeaningMemorizingCard meaning={meanings[currentIndex]} />
</div>
<Button
@@ -83,5 +84,6 @@ export default () => {
</div>
)}
</div>
</div>
);
};

View File

@@ -75,11 +75,13 @@ type TransactionStateType = {
record?: AudioType | UserType | RecordingType;
};
type FfmpegConfigType = {
os: string;
arch: string;
commandExists: boolean;
ffmpegPath?: string;
ffprobePath?: string;
scanDirs: string[];
ready: boolean;
};
@@ -110,7 +112,6 @@ type PagyResponseType = {
next: number | null;
};
type AudibleBookType = {
title: string;
subtitle: string;

View File

@@ -1,6 +1,7 @@
type EnjoyAppType = {
app: {
reset: () => Promise<void>;
resetSettings: () => Promise<void>;
relaunch: () => Promise<void>;
reload: () => Promise<void>;
isPackaged: () => Promise<boolean>;
@@ -75,7 +76,7 @@ type EnjoyAppType = {
LlmProviderType
) => Promise<void>;
getFfmpegConfig: () => Promise<FfmpegConfigType>;
setFfmpegConfig: () => Promise<void>;
setFfmpegConfig: (config: FfmpegConfigType) => Promise<void>;
getLanguage: () => Promise<string>;
switchLanguage: (language: string) => Promise<void>;
};
@@ -180,6 +181,11 @@ type EnjoyAppType = {
ffmpeg: {
download: () => Promise<FfmpegConfigType>;
check: () => Promise<boolean>;
discover: () => Promise<{
ffmpegPath: string;
ffprobePath: string;
scanDirs: string[];
}>;
};
download: {
onState: (callback: (event, state) => void) => void;
@@ -199,4 +205,8 @@ type EnjoyAppType = {
process: (params: any) => Promise<void>;
update: (id: string, params: any) => Promise<void>;
};
waveforms: {
find: (id: string) => Promise<WaveFormDataType>;
save: (id: string, data: WaveFormDataType) => Promise<void>;
};
};

6
enjoy/src/types/waveform.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
type WaveFormDataType = {
peaks: number[];
sampleRate: number;
duration: number;
frequencies: number[];
};

View File

@@ -5827,7 +5827,7 @@ __metadata:
react-activity-calendar: "npm:^2.2.1"
react-dom: "npm:^18.2.0"
react-hook-form: "npm:^7.49.2"
react-hotkeys-hook: "npm:^4.4.4"
react-hotkeys-hook: "npm:^4.4.3"
react-i18next: "npm:^14.0.0"
react-markdown: "npm:^9.0.1"
react-router-dom: "npm:^6.21.1"
@@ -10684,13 +10684,13 @@ __metadata:
languageName: node
linkType: hard
"react-hotkeys-hook@npm:^4.4.4":
version: 4.4.4
resolution: "react-hotkeys-hook@npm:4.4.4"
"react-hotkeys-hook@npm:^4.4.3":
version: 4.4.3
resolution: "react-hotkeys-hook@npm:4.4.3"
peerDependencies:
react: ">=16.8.1"
react-dom: ">=16.8.1"
checksum: afe7418c8bd0ecd3a3f315648b84d9978d06a02e55f93df23aaa00e56613fca839f9ff4d10387e8336454702b4aa03cef97f3e67bfe3e121e42f16d84685f55a
checksum: ef79e279129f6e55d81c8762b1da214d9c6ee4617b9597dbc3f93057cba8d166831508967b8e3f763a0c4ce0af3b59e6888c6fc94d152deac9335020b2ba80df
languageName: node
linkType: hard