Compare commits
5 Commits
dependabot
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
050c577620 | ||
|
|
8d7a3e37ce | ||
|
|
23feb06d20 | ||
|
|
b545ea2362 | ||
|
|
187038c42e |
4
.github/workflows/release-enjoy-app.yml
vendored
4
.github/workflows/release-enjoy-app.yml
vendored
@@ -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
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true
|
||||
"cssVariables": false
|
||||
},
|
||||
"aliases": {
|
||||
"components": "src/renderer/components",
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 模型",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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\\",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
38
enjoy/src/main/waveform.ts
Normal file
38
enjoy/src/main/waveform.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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("-", "")
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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("-", "")
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
5
enjoy/src/types.d.ts
vendored
5
enjoy/src/types.d.ts
vendored
@@ -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;
|
||||
|
||||
12
enjoy/src/types/enjoy-app.d.ts
vendored
12
enjoy/src/types/enjoy-app.d.ts
vendored
@@ -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
6
enjoy/src/types/waveform.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
type WaveFormDataType = {
|
||||
peaks: number[];
|
||||
sampleRate: number;
|
||||
duration: number;
|
||||
frequencies: number[];
|
||||
};
|
||||
10
yarn.lock
10
yarn.lock
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user