Compare commits
52 Commits
v0.1.0-alp
...
v0.1.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
050c577620 | ||
|
|
8d7a3e37ce | ||
|
|
23feb06d20 | ||
|
|
b545ea2362 | ||
|
|
187038c42e | ||
|
|
6cc9cb9da2 | ||
|
|
3cf168f098 | ||
|
|
2ceb122acf | ||
|
|
3edcfc0017 | ||
|
|
d2510d00cb | ||
|
|
afb818f215 | ||
|
|
fe0542e8c6 | ||
|
|
d6a4b24a1e | ||
|
|
befdc6744a | ||
|
|
7f671bb709 | ||
|
|
29e12106a2 | ||
|
|
09b7ca40f4 | ||
|
|
e77eeb6a9c | ||
|
|
3d3fc17c79 | ||
|
|
24236a48ff | ||
|
|
f40df6ecd6 | ||
|
|
f67a59e756 | ||
|
|
80fe9caa90 | ||
|
|
8d42c4c626 | ||
|
|
aa2334aa12 | ||
|
|
0ecaf4bdff | ||
|
|
d655da9aea | ||
|
|
1243076bbb | ||
|
|
5b50096467 | ||
|
|
1e290e9e88 | ||
|
|
3fe209a100 | ||
|
|
3dff4330a1 | ||
|
|
2db0d6c43b | ||
|
|
91e573adef | ||
|
|
d84973fac0 | ||
|
|
cc47d64083 | ||
|
|
e05f2c57eb | ||
|
|
f9b1c14b4c | ||
|
|
e510ed9337 | ||
|
|
9635c192d5 | ||
|
|
5414af1a06 | ||
|
|
017b5b59e9 | ||
|
|
eda547aca1 | ||
|
|
267eee37b9 | ||
|
|
66cf3dd828 | ||
|
|
94d4a0a338 | ||
|
|
551b848ade | ||
|
|
3a87a41ce9 | ||
|
|
cee7f5238a | ||
|
|
e4c0736784 | ||
|
|
a27d145990 | ||
|
|
e3d64dcf24 |
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
|
||||
|
||||
87
README.md
87
README.md
@@ -17,6 +17,8 @@
|
||||
|
||||
- [Enjoy App](./enjoy/README.md)
|
||||
|
||||
## * 开发者
|
||||
|
||||
### 本地启动
|
||||
|
||||
```bash
|
||||
@@ -29,3 +31,88 @@ yarn start:enjoy
|
||||
```bash
|
||||
yarn make:enjoy
|
||||
```
|
||||
|
||||
## * 普通小白用户
|
||||
|
||||
方法一:这是**最直接简单的方法**是去 [releases 页面](https://github.com/xiaolai/everyone-can-use-english/tags)下载相应的安装文件。
|
||||
|
||||
方法二:如果想要随时**试用更新版本**的话,请按一下步骤操作。
|
||||
|
||||
### MacOS 用户
|
||||
|
||||
1. 打开命令行工具 Terminal
|
||||
|
||||
2. 安装 Homebrew(请参阅这篇文章:《[从 Terminal 开始…](https://github.com/xiaolai/apple-computer-literacy/blob/main/start-from-terminal.md)》)
|
||||
|
||||
3. 安装 yarn:
|
||||
```bash
|
||||
brew install yarn
|
||||
```
|
||||
|
||||
4. 克隆此仓库至本地,而后安装、启动:
|
||||
|
||||
```bash
|
||||
cd ~
|
||||
mkdir github
|
||||
cd github
|
||||
git clone https://github.com/xiaolai/everyone-can-use-english
|
||||
cd everyone-can-use-english
|
||||
yarn install
|
||||
yarn start:enjoy
|
||||
```
|
||||
|
||||
### Windows 用户
|
||||
|
||||
系统要求:Windows 10 22H2 以上版本、 [Windows PowerShell 5.1](https://aka.ms/wmf5download) 以上版本、互联网网络连接正常。
|
||||
|
||||
1. 将鼠标移至任务栏的 “Windows 徽标” 上单击右键,选择 “PowerShell”
|
||||
|
||||
> tips 1 :在最新的 Windows 11 上,你看不到 “PowerShell” 选项,只有 “终端”
|
||||
>
|
||||
> tips 2 :不能用管理员权限运行 PowerShell ,否则会导致 Scoop 安装失败
|
||||
|
||||
2. 在弹出的 PowerShell 窗口中依次执行运行以下命令,安装Scoop:
|
||||
|
||||
```powershell
|
||||
# 设置 PowerShell 执行策略
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
# 下载安装脚本
|
||||
irm get.scoop.sh -outfile 'install.ps1'
|
||||
# 执行安装, --ScoopDir 参数指定 Scoop 安装路径
|
||||
.\install.ps1 -ScoopDir 'C:\Scoop'
|
||||
```
|
||||
|
||||
如果出现下面的错误:
|
||||
|
||||
> <span style="color:red">irm : 未能解析此远程名称: 'raw.githubusercontent.com'</span>
|
||||
|
||||
说明你的**网络连接**有问题,请自行研究解决:
|
||||
|
||||
3. 安装 Nodejs 和 yarn 以及其他依赖环境 :
|
||||
|
||||
```powershell
|
||||
scoop install nodejs
|
||||
scoop install git
|
||||
npm install yarn -D
|
||||
```
|
||||
|
||||
4. 克隆此仓库至本地,而后安装 Enjoy APP:
|
||||
|
||||
```powershell
|
||||
cd ~
|
||||
mkdir github
|
||||
cd github
|
||||
git clone https://github.com/xiaolai/everyone-can-use-english
|
||||
cd everyone-can-use-english
|
||||
cd enjoy
|
||||
yarn install
|
||||
yarn start:enjoy
|
||||
```
|
||||
|
||||
出现 `Completed in XXXXXXXXXX` 类似字样说明安装成功!
|
||||
|
||||
5. 运行 Enjoy APP ,在终端执行下列命令:
|
||||
|
||||
```powershell
|
||||
yarn start:enjoy
|
||||
```
|
||||
|
||||
@@ -395,7 +395,7 @@ One of the reasons why many parents want to send their children to separate scho
|
||||
* 名词单复数形式错误:错误地使用了名词的数,包括:使用了不可数名词的 “复数” 形式,使用了集合名词的 “复数” 形式,在应该使用复数的地方使用了单数名词(或反之)等。
|
||||
* 单数可数名词未受限定:句子中出现的单数可数名词之前没有使用限定词,包括冠词、不定代词、指示代词、名词或代词所有格、数词与某些形容词性的物主代词。
|
||||
* 词性错误:在选择词汇的过程中忽略了英文词性的特性,仅按照含义来使用词汇,从而发生了词性使用错误的现象。
|
||||
* 修饰关系错误:违反了词汇修饰的规则,采用了不恰当的修饰关系。包括用* 形容词修饰动词、形容词修饰形容词,副词修饰名词等。
|
||||
* 修饰关系错误:违反了词汇修饰的规则,采用了不恰当的修饰关系。包括用形容词修饰动词、形容词修饰形容词,副词修饰名词等。
|
||||
* 搭配错误:句子中出现了不合适的词汇修饰、限制、说明现象,或者错误地使用了固有的词汇搭配形式。
|
||||
* 词序错误:未使用正确的、符合习惯的表述语序来对内容进行陈述。其中包括修饰词顺序错误,该倒装时没有倒装等。
|
||||
* 非谓语动词使用错误:错误地使用了现在分词、过去分词、或动词的不定式。其中包括:
|
||||
@@ -445,9 +445,9 @@ Style: Toward Clarity and Grace by Joseph M. Williams
|
||||
|
||||
几乎所有真正有效的学习手段都是简单、廉价、往往并不直接但却真正有效的。复述,就是这样的有效手段。
|
||||
|
||||
每个文化中的每个人在这方面都一样 —— 终其一生绝大多数情况下都在复述别人说过的话。首先语言文字很难纯粹 “原创”,其次绝大多数情况下确实也没有 必要 “独一无二”。更为重要的是,第二语言学习者的目标绝大多数情况下不是为了从事诗人、小说家之类的职业,而是希望多掌握一门用来承载信息沟通交流的工 具 —— 这种情况下 “复述” 几乎占据了第二语言应用的全部。
|
||||
每个文化中的每个人在这方面都一样 —— 终其一生绝大多数情况下都在复述别人说过的话。首先语言文字很难纯粹 “原创”,其次绝大多数情况下确实也没有必要 “独一无二”。更为重要的是,第二语言学习者的目标绝大多数情况下不是为了从事诗人、小说家之类的职业,而是希望多掌握一门用来承载信息沟通交流的工具 —— 这种情况下 “复述” 几乎占据了第二语言应用的全部。
|
||||
|
||||
这还真的并不是那么 “显而易见” 的事实。ETS 在设计并举办 TOEFL 考试几十年之后才 “恍然大悟” 地在新托福考试中大面积添加了 “复述能力” 的考 量:TOEFL 作文部分中有综合测试,要求考生先读一篇文章,然后再听一篇与刚刚读过的文章相关的讲座,而后复述讲座内容以及讲座内容是如何与阅读文章内 容相联系的;口语部分中有先听再说,先读再说,听与读之后再说 —— 无一不是在考量考生的 “复述能力”。
|
||||
这还真的并不是那么 “显而易见” 的事实。ETS 在设计并举办 TOEFL 考试几十年之后才 “恍然大悟” 地在新托福考试中大面积添加了 “复述能力” 的考量:TOEFL 作文部分中有综合测试,要求考生先读一篇文章,然后再听一篇与刚刚读过的文章相关的讲座,而后复述讲座内容以及讲座内容是如何与阅读文章内容相联系的;口语部分中有先听再说,先读再说,听与读之后再说 —— 无一不是在考量考生的 “复述能力”。
|
||||
|
||||
## 9. 貌似多余:其实连哑巴英语都并不那么坏
|
||||
|
||||
|
||||
@@ -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,14 +45,22 @@ 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: [
|
||||
{
|
||||
name: "@electron-forge/publisher-github",
|
||||
config: {
|
||||
repository: {
|
||||
owner: "an-lee",
|
||||
name: "enjoy",
|
||||
owner: "xiaolai",
|
||||
name: "everyone-can-use-english",
|
||||
},
|
||||
draft: true,
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"private": true,
|
||||
"name": "enjoy",
|
||||
"productName": "Enjoy",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.0-alpha.3",
|
||||
"description": "Enjoy desktop app",
|
||||
"main": ".vite/build/main.js",
|
||||
"types": "./src/types.d.ts",
|
||||
@@ -117,6 +117,7 @@
|
||||
"lucide-react": "^0.308.0",
|
||||
"mark.js": "^8.11.1",
|
||||
"microsoft-cognitiveservices-speech-sdk": "^1.34.0",
|
||||
"next-themes": "^0.2.1",
|
||||
"openai": "^4.24.1",
|
||||
"pitchfinder": "^2.3.2",
|
||||
"postcss": "^8.4.33",
|
||||
@@ -133,6 +134,7 @@
|
||||
"rimraf": "^5.0.5",
|
||||
"sequelize": "^6.35.2",
|
||||
"sequelize-typescript": "^2.1.6",
|
||||
"sonner": "^1.3.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-scrollbar-hide": "^1.1.7",
|
||||
"umzug": "^3.5.0",
|
||||
|
||||
259
enjoy/src/api/client.ts
Normal file
259
enjoy/src/api/client.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import decamelizeKeys from "decamelize-keys";
|
||||
import camelcaseKeys from "camelcase-keys";
|
||||
|
||||
const ONE_MINUTE = 1000 * 60; // 1 minute
|
||||
|
||||
export class Client {
|
||||
public api: AxiosInstance;
|
||||
public baseUrl: string;
|
||||
public logger: any;
|
||||
|
||||
constructor(options: {
|
||||
baseUrl: string;
|
||||
accessToken?: string;
|
||||
logger?: any;
|
||||
}) {
|
||||
const { baseUrl, accessToken, logger } = options;
|
||||
this.baseUrl = baseUrl;
|
||||
this.logger = logger || console;
|
||||
|
||||
this.api = axios.create({
|
||||
baseURL: baseUrl,
|
||||
timeout: ONE_MINUTE,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
this.api.interceptors.request.use((config) => {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
|
||||
this.logger.debug(
|
||||
config.method.toUpperCase(),
|
||||
config.baseURL + config.url,
|
||||
config.data,
|
||||
config.params
|
||||
);
|
||||
return config;
|
||||
});
|
||||
this.api.interceptors.response.use(
|
||||
(response) => {
|
||||
this.logger.debug(
|
||||
response.status,
|
||||
response.config.method.toUpperCase(),
|
||||
response.config.baseURL + response.config.url
|
||||
);
|
||||
return camelcaseKeys(response.data, { deep: true });
|
||||
},
|
||||
(err) => {
|
||||
if (err.response) {
|
||||
this.logger.error(
|
||||
err.response.status,
|
||||
err.response.config.method.toUpperCase(),
|
||||
err.response.config.baseURL + err.response.config.url
|
||||
);
|
||||
this.logger.error(err.response.data);
|
||||
return Promise.reject(err.response.data);
|
||||
}
|
||||
|
||||
if (err.request) {
|
||||
this.logger.error(err.request);
|
||||
} else {
|
||||
this.logger.error(err.message);
|
||||
}
|
||||
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
auth(params: { provider: string; code: string }): Promise<UserType> {
|
||||
return this.api.post("/api/sessions", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
me(): Promise<UserType> {
|
||||
return this.api.get("/api/me");
|
||||
}
|
||||
|
||||
rankings(range: "day" | "week" | "month" | "year" | "all" = "day"): Promise<{
|
||||
rankings: UserType[];
|
||||
range: string;
|
||||
}> {
|
||||
return this.api.get("/api/users/rankings", { params: { range } });
|
||||
}
|
||||
|
||||
posts(params?: { page?: number; items?: number }): Promise<
|
||||
{
|
||||
posts: PostType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/posts", { params: decamelizeKeys(params) });
|
||||
}
|
||||
|
||||
post(id: string): Promise<PostType> {
|
||||
return this.api.get(`/api/posts/${id}`);
|
||||
}
|
||||
|
||||
createPost(params: {
|
||||
metadata?: PostType["metadata"];
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
}): Promise<PostType> {
|
||||
return this.api.post("/api/posts", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
updatePost(id: string, params: { content: string }): Promise<PostType> {
|
||||
return this.api.put(`/api/posts/${id}`, decamelizeKeys(params));
|
||||
}
|
||||
|
||||
deletePost(id: string): Promise<void> {
|
||||
return this.api.delete(`/api/posts/${id}`);
|
||||
}
|
||||
|
||||
transcriptions(params?: {
|
||||
page?: number;
|
||||
items?: number;
|
||||
targetId?: string;
|
||||
targetType?: string;
|
||||
targetMd5?: string;
|
||||
}): Promise<
|
||||
{
|
||||
transcriptions: TranscriptionType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/transcriptions", {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
syncAudio(audio: Partial<AudioType>) {
|
||||
return this.api.post("/api/mine/audios", decamelizeKeys(audio));
|
||||
}
|
||||
|
||||
syncVideo(video: Partial<VideoType>) {
|
||||
return this.api.post("/api/mine/videos", decamelizeKeys(video));
|
||||
}
|
||||
|
||||
syncTranscription(transcription: Partial<TranscriptionType>) {
|
||||
return this.api.post("/api/transcriptions", decamelizeKeys(transcription));
|
||||
}
|
||||
|
||||
syncRecording(recording: Partial<RecordingType>) {
|
||||
if (!recording) return;
|
||||
|
||||
return this.api.post("/api/mine/recordings", decamelizeKeys(recording));
|
||||
}
|
||||
|
||||
generateSpeechToken(): Promise<{ token: string; region: string }> {
|
||||
return this.api.post("/api/speech/tokens");
|
||||
}
|
||||
|
||||
syncPronunciationAssessment(
|
||||
pronunciationAssessment: Partial<PronunciationAssessmentType>
|
||||
) {
|
||||
if (!pronunciationAssessment) return;
|
||||
|
||||
return this.api.post(
|
||||
"/api/mine/pronunciation_assessments",
|
||||
decamelizeKeys(pronunciationAssessment)
|
||||
);
|
||||
}
|
||||
|
||||
recordingAssessment(id: string) {
|
||||
return this.api.get(`/api/mine/recordings/${id}/assessment`);
|
||||
}
|
||||
|
||||
lookup(params: {
|
||||
word: string;
|
||||
context: string;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
}): Promise<LookupType> {
|
||||
return this.api.post("/api/lookups", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
lookupInBatch(
|
||||
lookups: {
|
||||
word: string;
|
||||
context: string;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
}[]
|
||||
): Promise<{ successCount: number; errors: string[]; total: number }> {
|
||||
return this.api.post("/api/lookups/batch", {
|
||||
lookups: decamelizeKeys(lookups, { deep: true }),
|
||||
});
|
||||
}
|
||||
|
||||
extractVocabularyFromStory(storyId: string): Promise<string[]> {
|
||||
return this.api.post(`/api/stories/${storyId}/extract_vocabulary`);
|
||||
}
|
||||
|
||||
storyMeanings(
|
||||
storyId: string,
|
||||
params?: {
|
||||
page?: number;
|
||||
items?: number;
|
||||
storyId?: string;
|
||||
}
|
||||
): Promise<
|
||||
{
|
||||
meanings: MeaningType[];
|
||||
pendingLookups?: LookupType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get(`/api/stories/${storyId}/meanings`, {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
mineMeanings(params?: {
|
||||
page?: number;
|
||||
items?: number;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
status?: string;
|
||||
}): Promise<
|
||||
{
|
||||
meanings: MeaningType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/mine/meanings", {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
createStory(params: CreateStoryParamsType): Promise<StoryType> {
|
||||
return this.api.post("/api/stories", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
story(id: string): Promise<StoryType> {
|
||||
return this.api.get(`/api/stories/${id}`);
|
||||
}
|
||||
|
||||
stories(params?: { page: number }): Promise<
|
||||
{
|
||||
stories: StoryType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/stories", { params: decamelizeKeys(params) });
|
||||
}
|
||||
|
||||
mineStories(params?: { page: number }): Promise<
|
||||
{
|
||||
stories: StoryType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/mine/stories", {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
starStory(storyId: string): Promise<{ starred: boolean }> {
|
||||
return this.api.post(`/api/mine/stories`, decamelizeKeys({ storyId }));
|
||||
}
|
||||
|
||||
unstarStory(storyId: string): Promise<{ starred: boolean }> {
|
||||
return this.api.delete(`/api/mine/stories/${storyId}`);
|
||||
}
|
||||
}
|
||||
1
enjoy/src/api/index.ts
Normal file
1
enjoy/src/api/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./client";
|
||||
@@ -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/resolve/main/ggml-large.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.`,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -86,7 +86,8 @@
|
||||
"ttsVoice": "TTS voice",
|
||||
"ttsBaseUrl": "TTS base URL",
|
||||
"notFound": "Conversation not found",
|
||||
"contentRequired": "Content required"
|
||||
"contentRequired": "Content required",
|
||||
"failedToGenerateResponse": "Failed to generate response"
|
||||
},
|
||||
"pronunciationAssessment": {
|
||||
"pronunciationScore": "Pronunciation Score",
|
||||
@@ -122,6 +123,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"home": "Home",
|
||||
"community": "Community",
|
||||
"audios": "Audios",
|
||||
"videos": "Videos",
|
||||
"stories": "Stories",
|
||||
@@ -188,13 +190,27 @@
|
||||
"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",
|
||||
"youAreReadyToGo": "You are ready to go",
|
||||
"welcomeBack": "Welcome back! {{name}}",
|
||||
"download": "Download",
|
||||
"downloading": "Downloading {{file}}",
|
||||
"chooseAIModelDependingOnYourHardware": "Choose AI Model depending on your hardware",
|
||||
"areYouSureToDownload": "Are you sure to download {{name}}?",
|
||||
"yourModelsWillBeDownloadedTo": "Your models will be downloaded to {{path}}",
|
||||
@@ -203,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",
|
||||
@@ -237,7 +256,7 @@
|
||||
"recentlyAdded": "recently added",
|
||||
"recommended": "recommended",
|
||||
"resourcesRecommendedByEnjoy": "resources recommended by Enjoy Bot",
|
||||
"fromCommunity": "from commnuity",
|
||||
"fromCommunity": "from community",
|
||||
"videoResources": "video resources",
|
||||
"audioResources": "audio resources",
|
||||
"seeMore": "see more",
|
||||
@@ -262,8 +281,12 @@
|
||||
"recordingActivity": "recording activity",
|
||||
"recordingDetail": "Recording detail",
|
||||
"noRecordingActivities": "no recording activities",
|
||||
"basicSettings": "basic",
|
||||
"advancedSettings": "advanced",
|
||||
"basicSettingsShort": "Basic",
|
||||
"basicSettings": "Basic settings",
|
||||
"advancedSettingsShort": "Advanced",
|
||||
"advancedSettings": "Advanced settings",
|
||||
"advanced": "Advanced",
|
||||
"language": "Language",
|
||||
"sttAiModel": "STT AI model",
|
||||
"relaunchIsNeededAfterChanged": "Relaunch is needed after changed",
|
||||
"openaiKeySaved": "OpenAI key saved",
|
||||
@@ -298,7 +321,7 @@
|
||||
"score": "score",
|
||||
"inputUrlToStartReading": "Input url to start reading",
|
||||
"read": "read",
|
||||
"add_story": "add story",
|
||||
"addStory": "add story",
|
||||
"context": "context",
|
||||
"keyVocabulary": "key vocabulary",
|
||||
"addedStories": "added stories",
|
||||
@@ -320,5 +343,44 @@
|
||||
"presenter": "presenter",
|
||||
"downloadAudio": "Download audio",
|
||||
"downloadVideo": "Download video",
|
||||
"recordTooShort": "Record too short"
|
||||
"recordTooShort": "Record too short",
|
||||
"rankings": "Rankings",
|
||||
"dayRankings": "Day rankings",
|
||||
"weekRankings": "Week rankings",
|
||||
"monthRankings": "Month rankings",
|
||||
"allRankings": "All time rankings",
|
||||
"noOneHasRecordedYet": "No one has recorded yet",
|
||||
"activities": "Activities",
|
||||
"square": "Square",
|
||||
"noOneSharedYet": "No one shared yet",
|
||||
"sharedSuccessfully": "Shared successfully",
|
||||
"shareFailed": "Share failed",
|
||||
"shareAudio": "Share audio",
|
||||
"sharedAudio": "Shared an audio resource",
|
||||
"areYouSureToShareThisAudioToCommunity": "Are you sure to share this audio to community?",
|
||||
"shareVideo": "Share video",
|
||||
"sharedVideo": "Shared a video resource",
|
||||
"cannotShareLocalVideo": "Cannot share local video",
|
||||
"areYouSureToShareThisVideoToCommunity": "Are you sure to share this video to community?",
|
||||
"sharePrompt": "Share prompt",
|
||||
"sharedPrompt": "Shared a prompt",
|
||||
"areYouSureToShareThisPromptToCommunity": "Are you sure to share this prompt to community?",
|
||||
"shareRecording": "Share recording",
|
||||
"sharedRecording": "Shared a recording",
|
||||
"areYouSureToShareThisRecordingToCommunity": "Are you sure to share this recording to community?",
|
||||
"shareStory": "Share story",
|
||||
"sharedStory": "Shared a story",
|
||||
"areYouSureToShareThisStoryToCommunity": "Are you sure to share this story to community?",
|
||||
"addToLibary": "Add to library",
|
||||
"areYouSureToAddThisVideoToYourLibrary": "Are you sure to add this video to library?",
|
||||
"areYouSureToAddThisAudioToYourLibrary": "Are you sure to add this audio to library?",
|
||||
"audioAlreadyAddedToLibrary": "Audio already added to library",
|
||||
"videoAlreadyAddedToLibrary": "Video already added to library",
|
||||
"audioSuccessfullyAddedToLibrary": "Audio successfully added to library",
|
||||
"videoSuccessfullyAddedToLibrary": "Video successfully added to library",
|
||||
"sendToAIAssistant": "Send to AI assistant",
|
||||
"removeSharing": "Remove sharing",
|
||||
"areYouSureToRemoveThisSharing": "Are you sure to remove this sharing?",
|
||||
"removeSharingSuccessfully": "Remove sharing successfully",
|
||||
"removeSharingFailed": "Remove sharing failed"
|
||||
}
|
||||
|
||||
@@ -86,7 +86,8 @@
|
||||
"ttsVoice": "TTS 声音",
|
||||
"ttsBaseUrl": "TTS 请求地址",
|
||||
"notFound": "未找到对话",
|
||||
"contentRequired": "对话内容不能为空"
|
||||
"contentRequired": "对话内容不能为空",
|
||||
"failedToGenerateResponse": "生成失败"
|
||||
},
|
||||
"pronunciationAssessment": {
|
||||
"pronunciationScore": "发音得分",
|
||||
@@ -122,6 +123,7 @@
|
||||
},
|
||||
"sidebar": {
|
||||
"home": "主页",
|
||||
"community": "社区",
|
||||
"audios": "音频",
|
||||
"videos": "视频",
|
||||
"stories": "文章",
|
||||
@@ -188,13 +190,27 @@
|
||||
"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",
|
||||
"youAreReadyToGo": "您已准备就绪",
|
||||
"welcomeBack": "欢迎回来, {{name}}",
|
||||
"download": "下载",
|
||||
"downloading": "正在下载 {{file}}",
|
||||
"chooseAIModelDependingOnYourHardware": "根据您的硬件选择合适的 AI 模型",
|
||||
"areYouSureToDownload": "您确定要下载 {{name}} 吗?",
|
||||
"yourModelsWillBeDownloadedTo": "您的模型将下载到目录 {{path}}",
|
||||
@@ -203,7 +219,10 @@
|
||||
"reset": "重置",
|
||||
"resetAll": "重置所有",
|
||||
"resetAllConfirmation": "这将删除您的所有个人数据, 您确定要重置吗?",
|
||||
"resetSettings": "重置设置选项",
|
||||
"resetSettingsConfirmation": "您确定要重置个人设置选项吗?资料库不会受影响。",
|
||||
"logoutAndRemoveAllPersonalData": "退出登录并删除所有个人数据",
|
||||
"logoutAndRemoveAllPersonalSettings": "退出登录并删除所有个人设置选项",
|
||||
"hotkeys": "快捷键",
|
||||
"quitApp": "退出应用",
|
||||
"openPreferences": "打开设置",
|
||||
@@ -262,8 +281,11 @@
|
||||
"recordingActivity": "练习活动",
|
||||
"recordingDetail": "录音详情",
|
||||
"noRecordingActivities": "没有练习活动",
|
||||
"basicSettingsShort": "基本设置",
|
||||
"basicSettings": "基本设置",
|
||||
"advancedSettingsShort": "高级设置",
|
||||
"advancedSettings": "高级设置",
|
||||
"language": "语言",
|
||||
"sttAiModel": "语音转文本 AI 模型",
|
||||
"relaunchIsNeededAfterChanged": "更改后需要重新启动",
|
||||
"openaiKeySaved": "OpenAI 密钥已保存",
|
||||
@@ -298,7 +320,7 @@
|
||||
"score": "得分",
|
||||
"inputUrlToStartReading": "输入 URL 开始阅读",
|
||||
"read": "阅读",
|
||||
"add_story": "添加文章",
|
||||
"addStory": "添加文章",
|
||||
"context": "原文",
|
||||
"keyVocabulary": "关键词汇",
|
||||
"addedStories": "添加的文章",
|
||||
@@ -320,5 +342,44 @@
|
||||
"presenter": "讲者",
|
||||
"downloadAudio": "下载音频",
|
||||
"downloadVideo": "下载视频",
|
||||
"recordTooShort": "录音时长太短"
|
||||
"recordTooShort": "录音时长太短",
|
||||
"rankings": "排行榜",
|
||||
"dayRankings": "日排行榜",
|
||||
"weekRankings": "周排行榜",
|
||||
"monthRankings": "月排行榜",
|
||||
"allRankings": "总排行榜",
|
||||
"noOneHasRecordedYet": "还没有人练习",
|
||||
"activities": "动态",
|
||||
"square": "广场",
|
||||
"noOneSharedYet": "还没有人分享",
|
||||
"sharedSuccessfully": "分享成功",
|
||||
"sharedFailed": "分享失败",
|
||||
"shareAudio": "分享音频",
|
||||
"sharedAudio": "分享了一个音频材料",
|
||||
"areYouSureToShareThisAudioToCommunity": "您确定要分享此音频到社区吗?",
|
||||
"shareVideo": "分享视频",
|
||||
"sharedVideo": "分享了一个视频材料",
|
||||
"cannotShareLocalVideo": "无法分享本地视频",
|
||||
"areYouSureToShareThisVideoToCommunity": "您确定要分享此视频到社区吗?",
|
||||
"sharePrompt": "分享提示语",
|
||||
"sharedPrompt": "分享了一条提示语",
|
||||
"areYouSureToShareThisPromptToCommunity": "您确定要分享此提示语到社区吗?",
|
||||
"shareRecording": "分享录音",
|
||||
"sharedRecording": "分享了一条录音",
|
||||
"areYouSureToShareThisRecordingToCommunity": "您确定要分享此录音到社区吗?",
|
||||
"shareStory": "分享文章",
|
||||
"sharedStory": "分享了一篇文章",
|
||||
"areYouSureToShareThisStoryToCommunity": "您确定要分享此文章到社区吗?",
|
||||
"addToLibary": "添加到资源库",
|
||||
"areYouSureToAddThisVideoToYourLibrary": "您确定要添加此视频到资料库吗?",
|
||||
"areYouSureToAddThisAudioToYourLibrary": "您确定要添加此音频到资料库吗?",
|
||||
"audioAlreadyAddedToLibrary": "资料库已经存在此音频",
|
||||
"videoAlreadyAddedToLibrary": "资料库已经存在此视频",
|
||||
"audioSuccessfullyAddedToLibrary": "音频成功添加到资料库",
|
||||
"videoSuccessfullyAddedToLibrary": "视频成功添加到资料库",
|
||||
"sendToAIAssistant": "发送到智能助手",
|
||||
"removeSharing": "取消分享",
|
||||
"areYouSureToRemoveThisSharing": "您确定要取消分享吗?",
|
||||
"removeSharingSuccessfully": "取消分享成功",
|
||||
"removeSharingFailed": "取消分享失败"
|
||||
}
|
||||
|
||||
@@ -90,27 +90,29 @@ class AudiosHandler {
|
||||
|
||||
private async create(
|
||||
event: IpcMainEvent,
|
||||
source: string,
|
||||
uri: string,
|
||||
params: {
|
||||
name?: string;
|
||||
coverUrl?: string;
|
||||
} = {}
|
||||
) {
|
||||
let file = source;
|
||||
if (source.startsWith("http")) {
|
||||
let file = uri;
|
||||
let source;
|
||||
if (uri.startsWith("http")) {
|
||||
try {
|
||||
if (youtubedr.validateYtURL(source)) {
|
||||
file = await youtubedr.autoDownload(source);
|
||||
if (youtubedr.validateYtURL(uri)) {
|
||||
file = await youtubedr.autoDownload(uri);
|
||||
} else {
|
||||
file = await downloader.download(source, {
|
||||
file = await downloader.download(uri, {
|
||||
webContents: event.sender,
|
||||
});
|
||||
}
|
||||
if (!file) throw new Error("Failed to download file");
|
||||
source = uri;
|
||||
} catch (err) {
|
||||
return event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: t("models.audio.failedToDownloadFile", { file: source }),
|
||||
message: t("models.audio.failedToDownloadFile", { file: uri }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ipcMain, IpcMainEvent } from "electron";
|
||||
import { CacheObject } from "@main/db/models";
|
||||
import db from "@main/db";
|
||||
|
||||
class CacheObjectsHandler {
|
||||
private async get(event: IpcMainEvent, key: string) {
|
||||
@@ -49,6 +50,7 @@ class CacheObjectsHandler {
|
||||
private async clear(event: IpcMainEvent) {
|
||||
return CacheObject.destroy({ where: {} })
|
||||
.then(() => {
|
||||
db.connection.query("VACUUM");
|
||||
return;
|
||||
})
|
||||
.catch((err) => {
|
||||
|
||||
@@ -90,27 +90,30 @@ class VideosHandler {
|
||||
|
||||
private async create(
|
||||
event: IpcMainEvent,
|
||||
source: string,
|
||||
uri: string,
|
||||
params: {
|
||||
name?: string;
|
||||
coverUrl?: string;
|
||||
md5?: string;
|
||||
} = {}
|
||||
) {
|
||||
let file = source;
|
||||
if (source.startsWith("http")) {
|
||||
let file = uri;
|
||||
let source;
|
||||
if (uri.startsWith("http")) {
|
||||
try {
|
||||
if (youtubedr.validateYtURL(source)) {
|
||||
file = await youtubedr.autoDownload(source);
|
||||
if (youtubedr.validateYtURL(uri)) {
|
||||
file = await youtubedr.autoDownload(uri);
|
||||
} else {
|
||||
file = await downloader.download(source, {
|
||||
file = await downloader.download(uri, {
|
||||
webContents: event.sender,
|
||||
});
|
||||
}
|
||||
if (!file) throw new Error("Failed to download file");
|
||||
source = uri;
|
||||
} catch (err) {
|
||||
return event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: t("models.video.failedToDownloadFile", { file: source }),
|
||||
message: t("models.video.failedToDownloadFile", { file: uri }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -25,13 +25,21 @@ import mainWindow from "@main/window";
|
||||
import log from "electron-log/main";
|
||||
import storage from "@main/storage";
|
||||
import Ffmpeg from "@main/ffmpeg";
|
||||
import webApi from "@main/web-api";
|
||||
import { Client } from "@/api";
|
||||
import { WEB_API_URL } from "@/constants";
|
||||
import { startCase } from "lodash";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
|
||||
const SIZE_LIMIT = 1024 * 1024 * 50; // 50MB
|
||||
|
||||
const logger = log.scope("db/models/audio");
|
||||
|
||||
const webApi = new Client({
|
||||
baseUrl: process.env.WEB_API_URL || WEB_API_URL,
|
||||
accessToken: settings.getSync("user.accessToken") as string,
|
||||
logger: log.scope("api/client"),
|
||||
});
|
||||
|
||||
@Table({
|
||||
modelName: "Audio",
|
||||
tableName: "audios",
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
AllowNull,
|
||||
} from "sequelize-typescript";
|
||||
import { Message, Speech } from "@main/db/models";
|
||||
import { ChatMessageHistory , BufferMemory } from "langchain/memory";
|
||||
import { ChatMessageHistory, BufferMemory } from "langchain/memory";
|
||||
import { ConversationChain } from "langchain/chains";
|
||||
import { ChatOpenAI } from "langchain/chat_models/openai";
|
||||
import { ChatOllama } from "langchain/chat_models/ollama";
|
||||
@@ -267,13 +267,14 @@ export class Conversation extends Model<Conversation> {
|
||||
|
||||
const chain = await this.chain();
|
||||
let response: Generation[] = [];
|
||||
await chain.call({ input: content }, [
|
||||
const result = await chain.call({ input: content }, [
|
||||
{
|
||||
handleLLMEnd: async (output) => {
|
||||
response = output.generations[0];
|
||||
},
|
||||
},
|
||||
]);
|
||||
logger.debug("LLM result:", result);
|
||||
|
||||
if (!response) {
|
||||
throw new Error(t("models.conversation.failedToGenerateResponse"));
|
||||
@@ -294,9 +295,9 @@ export class Conversation extends Model<Conversation> {
|
||||
}
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
const replies = await Promise.all(
|
||||
response.map(async (generation) => {
|
||||
await Message.create(
|
||||
return await Message.create(
|
||||
{
|
||||
conversationId: this.id,
|
||||
role: "assistant",
|
||||
@@ -330,5 +331,7 @@ export class Conversation extends Model<Conversation> {
|
||||
}
|
||||
|
||||
await transaction.commit();
|
||||
|
||||
return replies.map((reply) => reply.toJSON());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,16 @@ import {
|
||||
} from "sequelize-typescript";
|
||||
import mainWindow from "@main/window";
|
||||
import { Recording } from "@main/db/models";
|
||||
import webApi from "@main/web-api";
|
||||
import { Client } from "@/api";
|
||||
import { WEB_API_URL } from "@/constants";
|
||||
import settings from "@main/settings";
|
||||
import log from "electron-log/main";
|
||||
|
||||
const webApi = new Client({
|
||||
baseUrl: process.env.WEB_API_URL || WEB_API_URL,
|
||||
accessToken: settings.getSync("user.accessToken") as string,
|
||||
logger: log.scope("api/client"),
|
||||
});
|
||||
|
||||
@Table({
|
||||
modelName: "PronunciationAssessment",
|
||||
|
||||
@@ -23,12 +23,19 @@ import { hashFile } from "@/utils";
|
||||
import log from "electron-log/main";
|
||||
import storage from "@main/storage";
|
||||
import Ffmpeg from "@main/ffmpeg";
|
||||
import webApi from "@main/web-api";
|
||||
import { Client } from "@/api";
|
||||
import { WEB_API_URL } from "@/constants";
|
||||
import { AzureSpeechSdk } from "@main/azure-speech-sdk";
|
||||
import camelcaseKeys from "camelcase-keys";
|
||||
|
||||
const logger = log.scope("db/models/recording");
|
||||
|
||||
const webApi = new Client({
|
||||
baseUrl: process.env.WEB_API_URL || WEB_API_URL,
|
||||
accessToken: settings.getSync("user.accessToken") as string,
|
||||
logger: log.scope("api/client"),
|
||||
});
|
||||
|
||||
@Table({
|
||||
modelName: "Recording",
|
||||
tableName: "recordings",
|
||||
@@ -36,7 +43,7 @@ const logger = log.scope("db/models/recording");
|
||||
timestamps: true,
|
||||
})
|
||||
export class Recording extends Model<Recording> {
|
||||
@IsUUID('all')
|
||||
@IsUUID("all")
|
||||
@Default(DataType.UUIDV4)
|
||||
@Column({ primaryKey: true, type: DataType.UUID })
|
||||
id: string;
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AfterCreate,
|
||||
AfterUpdate,
|
||||
AfterDestroy,
|
||||
AfterFind,
|
||||
BelongsTo,
|
||||
Table,
|
||||
Column,
|
||||
@@ -15,9 +16,17 @@ import { Audio, Video } from "@main/db/models";
|
||||
import whisper from "@main/whisper";
|
||||
import mainWindow from "@main/window";
|
||||
import log from "electron-log/main";
|
||||
import webApi from "@main/web-api";
|
||||
import { Client } from "@/api";
|
||||
import { WEB_API_URL, PROCESS_TIMEOUT } from "@/constants";
|
||||
import settings from "@main/settings";
|
||||
|
||||
const logger = log.scope("db/models/transcription");
|
||||
const webApi = new Client({
|
||||
baseUrl: process.env.WEB_API_URL || WEB_API_URL,
|
||||
accessToken: settings.getSync("user.accessToken") as string,
|
||||
logger: log.scope("api/client"),
|
||||
});
|
||||
|
||||
@Table({
|
||||
modelName: "Transcription",
|
||||
tableName: "transcriptions",
|
||||
@@ -25,7 +34,7 @@ const logger = log.scope("db/models/transcription");
|
||||
timestamps: true,
|
||||
})
|
||||
export class Transcription extends Model<Transcription> {
|
||||
@IsUUID('all')
|
||||
@IsUUID("all")
|
||||
@Default(DataType.UUIDV4)
|
||||
@Column({ primaryKey: true, type: DataType.UUID })
|
||||
id: string;
|
||||
@@ -146,6 +155,23 @@ export class Transcription extends Model<Transcription> {
|
||||
this.notify(transcription, "destroy");
|
||||
}
|
||||
|
||||
@AfterFind
|
||||
static expireProcessingState(transcription: Transcription) {
|
||||
if (transcription?.state !== "processing") return;
|
||||
|
||||
if (transcription.updatedAt.getTime() + PROCESS_TIMEOUT < Date.now()) {
|
||||
if (transcription.result) {
|
||||
transcription.update({
|
||||
state: "finished",
|
||||
});
|
||||
} else {
|
||||
transcription.update({
|
||||
state: "pending",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static notify(
|
||||
transcription: Transcription,
|
||||
action: "create" | "update" | "destroy"
|
||||
|
||||
@@ -25,13 +25,21 @@ import mainWindow from "@main/window";
|
||||
import log from "electron-log/main";
|
||||
import storage from "@main/storage";
|
||||
import Ffmpeg from "@main/ffmpeg";
|
||||
import webApi from "@main/web-api";
|
||||
import { Client } from "@/api";
|
||||
import { WEB_API_URL } from "@/constants";
|
||||
import { startCase } from "lodash";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
|
||||
const SIZE_LIMIT = 1024 * 1024 * 100; // 100MB
|
||||
|
||||
const logger = log.scope("db/models/video");
|
||||
|
||||
const webApi = new Client({
|
||||
baseUrl: process.env.WEB_API_URL || WEB_API_URL,
|
||||
accessToken: settings.getSync("user.accessToken") as string,
|
||||
logger: log.scope("api/client"),
|
||||
});
|
||||
|
||||
@Table({
|
||||
modelName: "Video",
|
||||
tableName: "videos",
|
||||
|
||||
@@ -7,26 +7,47 @@ 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() {
|
||||
const config = settings.ffmpegConfig();
|
||||
constructor(config?: {
|
||||
ffmpegPath: string;
|
||||
ffprobePath: string;
|
||||
commandExists?: boolean;
|
||||
}) {
|
||||
this.config = config || settings.ffmpegConfig();
|
||||
|
||||
if (config.commandExists) {
|
||||
if (this.config.commandExists) {
|
||||
logger.info("Using system ffmpeg");
|
||||
this.ffmpeg = Ffmpeg();
|
||||
} else {
|
||||
logger.info("Using downloaded ffmpeg");
|
||||
const ff = Ffmpeg();
|
||||
ff.setFfmpegPath(config.ffmpegPath);
|
||||
ff.setFfprobePath(config.ffprobePath);
|
||||
ff.setFfmpegPath(this.config.ffmpegPath);
|
||||
ff.setFfprobePath(this.config.ffprobePath);
|
||||
this.ffmpeg = ff;
|
||||
}
|
||||
}
|
||||
|
||||
checkCommand(): Promise<boolean> {
|
||||
return new Promise((resolve, _reject) => {
|
||||
this.ffmpeg.getAvailableFormats((err, formats) => {
|
||||
if (err) {
|
||||
logger.error("Command not valid:", err);
|
||||
resolve(false);
|
||||
} else {
|
||||
logger.info("Command valid, available formats:", formats);
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
generateMetadata(input: string): Promise<Ffmpeg.FfprobeData> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.ffmpeg
|
||||
@@ -292,9 +313,118 @@ export class FfmpegDownloader {
|
||||
logger.error(err);
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
title: `FFmpeg download failed: ${err.message}`,
|
||||
message: `FFmpeg download failed: ${err.message}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("ffmpeg-check-command", async (event) => {
|
||||
const ffmpeg = new FfmpegWrapper();
|
||||
const valid = await ffmpeg.checkCommand();
|
||||
|
||||
if (valid) {
|
||||
event.sender.send("on-notification", {
|
||||
type: "success",
|
||||
message: t("ffmpegCommandIsWorking"),
|
||||
});
|
||||
} else {
|
||||
logger.error("FFmpeg command not valid", ffmpeg.config);
|
||||
event.sender.send("on-notification", {
|
||||
type: "warning",
|
||||
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\\",
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as i18n from "i18next";
|
||||
import en from "@/i18n/en.json";
|
||||
import zh_CN from "@/i18n/zh-CN.json";
|
||||
import settings from "@main/settings";
|
||||
|
||||
const resources = {
|
||||
en: {
|
||||
@@ -13,7 +14,9 @@ const resources = {
|
||||
|
||||
i18n.init({
|
||||
resources,
|
||||
lng: "zh-CN",
|
||||
lng: settings.language(),
|
||||
supportedLngs: ["en", "zh-CN"],
|
||||
fallbackLng: "en",
|
||||
interpolation: {
|
||||
escapeValue: false, // react already safes from xss
|
||||
},
|
||||
|
||||
@@ -6,9 +6,25 @@ import fs from "fs-extra";
|
||||
import os from "os";
|
||||
import commandExists from "command-exists";
|
||||
import log from "electron-log";
|
||||
import * as i18n from "i18next";
|
||||
|
||||
const logger = log.scope("settings");
|
||||
|
||||
const language = () => {
|
||||
const _language = settings.getSync("language");
|
||||
|
||||
if (!_language || typeof _language !== "string") {
|
||||
settings.setSync("language", "en");
|
||||
}
|
||||
|
||||
return settings.getSync("language") as string;
|
||||
};
|
||||
|
||||
const switchLanguage = (language: string) => {
|
||||
settings.setSync("language", language);
|
||||
i18n.changeLanguage(language);
|
||||
};
|
||||
|
||||
const libraryPath = () => {
|
||||
const _library = settings.getSync("library");
|
||||
|
||||
@@ -80,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);
|
||||
@@ -178,6 +181,14 @@ export default {
|
||||
settings.setSync("ffmpeg.ffmpegPath", config.ffmpegPath);
|
||||
settings.setSync("ffmpeg.ffprobePath", config.ffrobePath);
|
||||
});
|
||||
|
||||
ipcMain.handle("settings-get-language", (_event) => {
|
||||
return language();
|
||||
});
|
||||
|
||||
ipcMain.handle("settings-switch-language", (_event, language) => {
|
||||
switchLanguage(language);
|
||||
});
|
||||
},
|
||||
cachePath,
|
||||
libraryPath,
|
||||
@@ -188,5 +199,7 @@ export default {
|
||||
userDataPath,
|
||||
dbPath,
|
||||
ffmpegConfig,
|
||||
language,
|
||||
switchLanguage,
|
||||
...settings,
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
import { ipcMain } from "electron";
|
||||
import axios, { AxiosInstance } from "axios";
|
||||
import { WEB_API_URL } from "@/constants";
|
||||
import settings from "@main/settings";
|
||||
import log from "electron-log/main";
|
||||
import decamelizeKeys from "decamelize-keys";
|
||||
import camelcaseKeys from "camelcase-keys";
|
||||
|
||||
const logger = log.scope("web-api");
|
||||
const ONE_MINUTE = 1000 * 60; // 1 minute
|
||||
class WebApi {
|
||||
public api: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.api = axios.create({
|
||||
baseURL: process.env.WEB_API_URL || WEB_API_URL,
|
||||
timeout: ONE_MINUTE,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
this.api.interceptors.request.use((config) => {
|
||||
config.headers.Authorization = `Bearer ${settings.getSync(
|
||||
"user.accessToken"
|
||||
)}`;
|
||||
|
||||
logger.info(
|
||||
config.method.toUpperCase(),
|
||||
config.baseURL + config.url,
|
||||
config.data,
|
||||
config.params
|
||||
);
|
||||
return config;
|
||||
});
|
||||
this.api.interceptors.response.use(
|
||||
(response) => {
|
||||
logger.info(
|
||||
response.status,
|
||||
response.config.method.toUpperCase(),
|
||||
response.config.baseURL + response.config.url
|
||||
);
|
||||
return camelcaseKeys(response.data, { deep: true });
|
||||
},
|
||||
(err) => {
|
||||
if (err.response) {
|
||||
logger.error(
|
||||
err.response.status,
|
||||
err.response.config.method.toUpperCase(),
|
||||
err.response.config.baseURL + err.response.config.url
|
||||
);
|
||||
logger.error(err.response.data);
|
||||
return Promise.reject(err.response.data);
|
||||
}
|
||||
|
||||
if (err.request) {
|
||||
logger.error(err.request);
|
||||
} else {
|
||||
logger.error(err.message);
|
||||
}
|
||||
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
me() {
|
||||
return this.api.get("/api/me");
|
||||
}
|
||||
|
||||
auth(params: { provider: string; code: string }): Promise<UserType> {
|
||||
return this.api.post("/api/sessions", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
syncAudio(audio: Partial<AudioType>) {
|
||||
return this.api.post("/api/mine/audios", decamelizeKeys(audio));
|
||||
}
|
||||
|
||||
syncVideo(video: Partial<VideoType>) {
|
||||
return this.api.post("/api/mine/videos", decamelizeKeys(video));
|
||||
}
|
||||
|
||||
syncTranscription(transcription: Partial<TranscriptionType>) {
|
||||
return this.api.post("/api/transcriptions", decamelizeKeys(transcription));
|
||||
}
|
||||
|
||||
syncRecording(recording: Partial<RecordingType>) {
|
||||
if (!recording) return;
|
||||
|
||||
return this.api.post("/api/mine/recordings", decamelizeKeys(recording));
|
||||
}
|
||||
|
||||
generateSpeechToken(): Promise<{ token: string; region: string }> {
|
||||
return this.api.post("/api/speech/tokens");
|
||||
}
|
||||
|
||||
syncPronunciationAssessment(
|
||||
pronunciationAssessment: Partial<PronunciationAssessmentType>
|
||||
) {
|
||||
if (!pronunciationAssessment) return;
|
||||
|
||||
return this.api.post(
|
||||
"/api/mine/pronunciation_assessments",
|
||||
decamelizeKeys(pronunciationAssessment)
|
||||
);
|
||||
}
|
||||
|
||||
recordingAssessment(id: string) {
|
||||
return this.api.get(`/api/mine/recordings/${id}/assessment`);
|
||||
}
|
||||
|
||||
lookup(params: {
|
||||
word: string;
|
||||
context: string;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
}): Promise<LookupType> {
|
||||
return this.api.post("/api/lookups", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
lookupInBatch(
|
||||
lookups: {
|
||||
word: string;
|
||||
context: string;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
}[]
|
||||
): Promise<{ successCount: number; total: number }> {
|
||||
return this.api.post("/api/lookups/batch", {
|
||||
lookups: decamelizeKeys(lookups, { deep: true }),
|
||||
});
|
||||
}
|
||||
|
||||
extractVocabularyFromStory(storyId: string): Promise<string[]> {
|
||||
return this.api.post(`/api/stories/${storyId}/extract_vocabulary`);
|
||||
}
|
||||
|
||||
storyMeanings(
|
||||
storyId: string,
|
||||
params?: {
|
||||
page?: number;
|
||||
items?: number;
|
||||
storyId?: string;
|
||||
}
|
||||
): Promise<
|
||||
{
|
||||
meanings: MeaningType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get(`/api/stories/${storyId}/meanings`, {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
mineMeanings(params?: {
|
||||
page?: number;
|
||||
items?: number;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
status?: string;
|
||||
}): Promise<
|
||||
{
|
||||
meanings: MeaningType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/mine/meanings", {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
createStory(params: CreateStoryParamsType): Promise<StoryType> {
|
||||
return this.api.post("/api/stories", decamelizeKeys(params));
|
||||
}
|
||||
|
||||
story(id: string): Promise<StoryType> {
|
||||
return this.api.get(`/api/stories/${id}`);
|
||||
}
|
||||
|
||||
stories(params?: { page: number }): Promise<
|
||||
{
|
||||
stories: StoryType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/stories", { params: decamelizeKeys(params) });
|
||||
}
|
||||
|
||||
mineStories(params?: { page: number }): Promise<
|
||||
{
|
||||
stories: StoryType[];
|
||||
} & PagyResponseType
|
||||
> {
|
||||
return this.api.get("/api/mine/stories", {
|
||||
params: decamelizeKeys(params),
|
||||
});
|
||||
}
|
||||
|
||||
starStory(storyId: string) {
|
||||
return this.api.post(`/api/mine/stories`, decamelizeKeys({ storyId }));
|
||||
}
|
||||
|
||||
unstarStory(storyId: string) {
|
||||
return this.api.delete(`/api/mine/stories/${storyId}`);
|
||||
}
|
||||
|
||||
registerIpcHandlers() {
|
||||
ipcMain.handle("web-api-auth", async (event, params) => {
|
||||
return this.auth(params)
|
||||
.then((user) => {
|
||||
return user;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-me", async (event) => {
|
||||
return this.me()
|
||||
.then((user) => {
|
||||
return user;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-lookup", async (event, params) => {
|
||||
return this.lookup(params)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-lookup-in-batch", async (event, params) => {
|
||||
return this.lookupInBatch(params)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-mine-meanings", async (event, params) => {
|
||||
return this.mineMeanings(params)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-create-story", async (event, params) => {
|
||||
return this.createStory(params)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle(
|
||||
"web-api-extract-vocabulary-from-story",
|
||||
async (event, storyId) => {
|
||||
return this.extractVocabularyFromStory(storyId)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
"web-api-story-meanings",
|
||||
async (event, storyId, params) => {
|
||||
return this.storyMeanings(storyId, params)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle("web-api-stories", async (event, params) => {
|
||||
return this.stories(params)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-story", async (event, id) => {
|
||||
return this.story(id)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-mine-stories", async (event, params) => {
|
||||
return this.mineStories(params)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-star-story", async (event, id) => {
|
||||
return this.starStory(id)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ipcMain.handle("web-api-unstar-story", async (event, id) => {
|
||||
return this.unstarStory(id)
|
||||
.then((response) => {
|
||||
return response;
|
||||
})
|
||||
.catch((error) => {
|
||||
event.sender.send("on-notification", {
|
||||
type: "error",
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new WebApi();
|
||||
@@ -14,11 +14,11 @@ import downloader from "@main/downloader";
|
||||
import whisper from "@main/whisper";
|
||||
import fs from "fs-extra";
|
||||
import "@main/i18n";
|
||||
import webApi from "@main/web-api";
|
||||
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");
|
||||
@@ -26,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,
|
||||
@@ -38,8 +39,6 @@ main.init = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
webApi.registerIpcHandlers();
|
||||
|
||||
// Prepare local database
|
||||
db.registerIpcHandlers();
|
||||
|
||||
@@ -49,6 +48,9 @@ main.init = () => {
|
||||
// Whisper
|
||||
whisper.registerIpcHandlers();
|
||||
|
||||
// Waveform
|
||||
waveform.registerIpcHandlers();
|
||||
|
||||
// Downloader
|
||||
downloader.registerIpcHandlers();
|
||||
|
||||
@@ -219,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();
|
||||
|
||||
@@ -204,7 +204,7 @@ class Youtubedr {
|
||||
this.getYtVideoId(url);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
logger.warn(error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,6 +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[]) => {
|
||||
@@ -175,8 +187,8 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
findOne: (params: object) => {
|
||||
return ipcRenderer.invoke("audios-find-one", params);
|
||||
},
|
||||
create: (source: string, params?: object) => {
|
||||
return ipcRenderer.invoke("audios-create", source, params);
|
||||
create: (uri: string, params?: object) => {
|
||||
return ipcRenderer.invoke("audios-create", uri, params);
|
||||
},
|
||||
update: (id: string, params: object) => {
|
||||
return ipcRenderer.invoke("audios-update", id, params);
|
||||
@@ -201,8 +213,8 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
findOne: (params: object) => {
|
||||
return ipcRenderer.invoke("videos-find-one", params);
|
||||
},
|
||||
create: (source: string, params?: object) => {
|
||||
return ipcRenderer.invoke("videos-create", source, params);
|
||||
create: (uri: string, params?: object) => {
|
||||
return ipcRenderer.invoke("videos-create", uri, params);
|
||||
},
|
||||
update: (id: string, params: object) => {
|
||||
return ipcRenderer.invoke("videos-update", id, params);
|
||||
@@ -338,6 +350,12 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
download: () => {
|
||||
return ipcRenderer.invoke("ffmpeg-download");
|
||||
},
|
||||
discover: () => {
|
||||
return ipcRenderer.invoke("ffmpeg-discover-command");
|
||||
},
|
||||
check: () => {
|
||||
return ipcRenderer.invoke("ffmpeg-check-command");
|
||||
},
|
||||
},
|
||||
download: {
|
||||
onState: (
|
||||
@@ -356,50 +374,6 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", {
|
||||
ipcRenderer.removeAllListeners("download-on-error");
|
||||
},
|
||||
},
|
||||
webApi: {
|
||||
auth: (params: object) => {
|
||||
return ipcRenderer.invoke("web-api-auth", params);
|
||||
},
|
||||
me: () => {
|
||||
return ipcRenderer.invoke("web-api-me");
|
||||
},
|
||||
lookup: (params: object) => {
|
||||
return ipcRenderer.invoke("web-api-lookup", params);
|
||||
},
|
||||
lookupInBatch: (params: object[]) => {
|
||||
return ipcRenderer.invoke("web-api-lookup-in-batch", params);
|
||||
},
|
||||
createStory: (params: object) => {
|
||||
return ipcRenderer.invoke("web-api-create-story", params);
|
||||
},
|
||||
starStory: (storyId: string) => {
|
||||
return ipcRenderer.invoke("web-api-star-story", storyId);
|
||||
},
|
||||
unstarStory: (storyId: string) => {
|
||||
return ipcRenderer.invoke("web-api-unstar-story", storyId);
|
||||
},
|
||||
extractVocabularyFromStory: (storyId: string) => {
|
||||
return ipcRenderer.invoke(
|
||||
"web-api-extract-vocabulary-from-story",
|
||||
storyId
|
||||
);
|
||||
},
|
||||
storyMeanings: (storyId: string, params: object) => {
|
||||
return ipcRenderer.invoke("web-api-story-meanings", storyId, params);
|
||||
},
|
||||
story: (id: string) => {
|
||||
return ipcRenderer.invoke("web-api-story", id);
|
||||
},
|
||||
stories: (params: object) => {
|
||||
return ipcRenderer.invoke("web-api-stories", params);
|
||||
},
|
||||
mineStories: (params: object) => {
|
||||
return ipcRenderer.invoke("web-api-mine-stories", params);
|
||||
},
|
||||
mineMeanings: (params: object) => {
|
||||
return ipcRenderer.invoke("web-api-mine-meanings", params);
|
||||
},
|
||||
},
|
||||
cacheObjects: {
|
||||
get: (key: string) => {
|
||||
return ipcRenderer.invoke("cache-objects-get", key);
|
||||
@@ -425,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);
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,19 +6,29 @@ import {
|
||||
} from "@renderer/context";
|
||||
import router from "./router";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { Toaster, useToast } from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import { Toaster, toast } from "@renderer/components/ui";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
|
||||
function App() {
|
||||
const { toast } = useToast();
|
||||
window.__ENJOY_APP__.onNotification((_event, notification) => {
|
||||
toast({
|
||||
title: t(notification.type),
|
||||
description: notification.message,
|
||||
variant: notification.type === "error" ? "destructive" : "default",
|
||||
});
|
||||
switch (notification.type) {
|
||||
case "success":
|
||||
toast.success(notification.message);
|
||||
break;
|
||||
case "error":
|
||||
toast.error(notification.message);
|
||||
break;
|
||||
case "info":
|
||||
toast.info(notification.message);
|
||||
break;
|
||||
case "warning":
|
||||
toast.warning(notification.message);
|
||||
break;
|
||||
default:
|
||||
toast.message(notification.message);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const ControlOrCommand = navigator.platform.includes("Mac")
|
||||
@@ -43,7 +53,7 @@ function App() {
|
||||
<AISettingsProvider>
|
||||
<DbProvider>
|
||||
<RouterProvider router={router} />
|
||||
<Toaster />
|
||||
<Toaster richColors closeButton position="top-center" />
|
||||
<Tooltip id="global-tooltip" />
|
||||
</DbProvider>
|
||||
</AISettingsProvider>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Link } from "react-router-dom";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
|
||||
export const AudioCard = (props: {
|
||||
audio: AudioType;
|
||||
audio: Partial<AudioType>;
|
||||
className?: string;
|
||||
}) => {
|
||||
const { audio, className } = props;
|
||||
|
||||
@@ -11,16 +11,29 @@ import {
|
||||
MediaTranscription,
|
||||
} from "@renderer/components";
|
||||
import { LoaderIcon } from "lucide-react";
|
||||
import { ScrollArea } from "@renderer/components/ui";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogHeader,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
Button,
|
||||
ScrollArea,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const AudioDetail = (props: { id?: string; md5?: string }) => {
|
||||
const { id, md5 } = props;
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const [audio, setAudio] = useState<AudioType | null>(null);
|
||||
const [transcription, setTranscription] = useState<TranscriptionType>(null);
|
||||
const [initialized, setInitialized] = useState<boolean>(false);
|
||||
const [sharing, setSharing] = useState<boolean>(false);
|
||||
|
||||
// Player controls
|
||||
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||
@@ -43,6 +56,35 @@ export const AudioDetail = (props: { id?: string; md5?: string }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!audio.source && !audio.isUploaded) {
|
||||
try {
|
||||
await EnjoyApp.audios.upload(audio.id);
|
||||
} catch (err) {
|
||||
toast.error(t("shareFailed"), {
|
||||
description: err.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
webApi
|
||||
.createPost({
|
||||
targetType: "Audio",
|
||||
targetId: audio.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("sharedSuccessfully"), {
|
||||
description: t("sharedAudio"),
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t("shareFailed"), {
|
||||
description: err.message,
|
||||
});
|
||||
});
|
||||
setSharing(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const where = id ? { id } : { md5 };
|
||||
EnjoyApp.audios.findOne(where).then((audio) => {
|
||||
@@ -90,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}
|
||||
@@ -110,6 +152,7 @@ export const AudioDetail = (props: { id?: string; md5?: string }) => {
|
||||
setPlaybackRate={setPlaybackRate}
|
||||
displayInlineCaption={displayInlineCaption}
|
||||
setDisplayInlineCaption={setDisplayInlineCaption}
|
||||
onShare={() => setSharing(true)}
|
||||
/>
|
||||
|
||||
<ScrollArea className={`flex-1 relative bg-muted`}>
|
||||
@@ -146,8 +189,25 @@ export const AudioDetail = (props: { id?: string; md5?: string }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={sharing} onOpenChange={(value) => setSharing(value)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("shareAudio")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("areYouSureToShareThisAudioToCommunity")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<Button variant="default" onClick={handleShare}>
|
||||
{t("share")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -4,9 +4,11 @@ import {
|
||||
AddMediaButton,
|
||||
AudiosTable,
|
||||
AudioEditForm,
|
||||
LoaderSpin,
|
||||
} from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
Button,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
DbProviderContext,
|
||||
@@ -43,28 +46,51 @@ export const AudiosComponent = () => {
|
||||
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [offset, setOffest] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
fetchResources();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
addDblistener(onAudiosUpdate);
|
||||
fetchResources();
|
||||
fetchAudios();
|
||||
|
||||
return () => {
|
||||
removeDbListener(onAudiosUpdate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const fetchResources = async () => {
|
||||
const audios = await EnjoyApp.audios.findAll({
|
||||
limit: 10,
|
||||
});
|
||||
if (!audios) return;
|
||||
const fetchAudios = async () => {
|
||||
if (loading) return;
|
||||
if (offset === -1) return;
|
||||
|
||||
dispatchAudios({ type: "set", records: audios });
|
||||
setLoading(true);
|
||||
const limit = 10;
|
||||
EnjoyApp.audios
|
||||
.findAll({
|
||||
offset,
|
||||
limit,
|
||||
})
|
||||
.then((_audios) => {
|
||||
if (_audios.length === 0) {
|
||||
setOffest(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_audios.length < limit) {
|
||||
setOffest(-1);
|
||||
} else {
|
||||
setOffest(offset + _audios.length);
|
||||
}
|
||||
|
||||
dispatchAudios({ type: "append", records: _audios });
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const onAudiosUpdate = (event: CustomEvent) => {
|
||||
@@ -79,7 +105,7 @@ export const AudiosComponent = () => {
|
||||
dispatchAudios({ type: "destroy", record });
|
||||
}
|
||||
} else if (model === "Video" && action === "create") {
|
||||
navigate(`/videos/${record.id}`);
|
||||
navigate(`/videos/${record.id}`);
|
||||
} else if (model === "Transcription" && action === "update") {
|
||||
dispatchAudios({
|
||||
type: "update",
|
||||
@@ -93,6 +119,8 @@ export const AudiosComponent = () => {
|
||||
};
|
||||
|
||||
if (audios.length === 0) {
|
||||
if (loading) return <LoaderSpin />;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
|
||||
<AddMediaButton />
|
||||
@@ -135,6 +163,14 @@ export const AudiosComponent = () => {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{offset > -1 && (
|
||||
<div className="flex items-center justify-center my-4">
|
||||
<Button variant="link" onClick={fetchAudios}>
|
||||
{t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={!!editing}
|
||||
onOpenChange={(value) => {
|
||||
|
||||
@@ -627,8 +627,6 @@ export const LLM_PROVIDERS: { [key: string]: any } = {
|
||||
openai: {
|
||||
name: "OpenAI",
|
||||
description: t("youNeedToSetupApiKeyBeforeUsingOpenAI"),
|
||||
baseUrl:
|
||||
"https://gateway.ai.cloudflare.com/v1/11d43ab275eb7e1b271ba4089ecc3864/enjoy/openai",
|
||||
models: [
|
||||
"gpt-3.5-turbo-1106",
|
||||
"gpt-3.5-turbo",
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { ScrollArea, toast } from "@renderer/components/ui";
|
||||
import { LoaderSpin } from "@renderer/components";
|
||||
import { MessageCircleIcon } from "lucide-react";
|
||||
|
||||
export const ConversationsShortcut = (props: {
|
||||
prompt: string;
|
||||
onReply?: (reply: MessageType[]) => void;
|
||||
}) => {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { prompt, onReply } = props;
|
||||
const [conversations, setConversations] = useState<ConversationType[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const ask = (conversation: ConversationType) => {
|
||||
setLoading(true);
|
||||
EnjoyApp.conversations
|
||||
.ask(conversation.id, {
|
||||
content: prompt,
|
||||
})
|
||||
.then((replies) => {
|
||||
onReply(replies);
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
EnjoyApp.conversations.findAll({ limit: 10 }).then((conversations) => {
|
||||
setConversations(conversations);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <LoaderSpin />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea>
|
||||
{conversations.map((conversation) => {
|
||||
return (
|
||||
<div
|
||||
key={conversation.id}
|
||||
onClick={() => ask(conversation)}
|
||||
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("-", "")
|
||||
.substr(0, 6)}`,
|
||||
borderLeftWidth: 3,
|
||||
}}
|
||||
>
|
||||
<div className="">
|
||||
<MessageCircleIcon className="mr-2" />
|
||||
</div>
|
||||
<div className="flex-1 truncated">{conversation.name}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ScrollArea>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './conversation-form';
|
||||
export * from "./conversation-form";
|
||||
export * from "./conversations-shortcut";
|
||||
|
||||
export * from './speech-form';
|
||||
export * from "./speech-form";
|
||||
export * from "./speech-player";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { PitchContour } from "@renderer/components";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
import { Button } from "@renderer/components/ui";
|
||||
import { Button, Skeleton } from "@renderer/components/ui";
|
||||
import { PlayIcon, PauseIcon } from "lucide-react";
|
||||
import { useIntersectionObserver } from "@uidotdev/usehooks";
|
||||
import { secondsToTimestamp } from "@renderer/lib/utils";
|
||||
@@ -18,6 +18,7 @@ export const SpeechPlayer = (props: {
|
||||
threshold: 1,
|
||||
});
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
const onPlayClick = useCallback(() => {
|
||||
wavesurfer.isPlaying() ? wavesurfer.pause() : wavesurfer.play();
|
||||
@@ -69,6 +70,7 @@ export const SpeechPlayer = (props: {
|
||||
height,
|
||||
})
|
||||
);
|
||||
setInitialized(true);
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -87,9 +89,17 @@ 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]"
|
||||
>
|
||||
<div className="flex justify-center">
|
||||
{!initialized && (
|
||||
<div className="col-span-9 flex flex-col justify-around h-[80px]">
|
||||
<Skeleton className="h-3 w-full rounded-full" />
|
||||
<Skeleton className="h-3 w-full rounded-full" />
|
||||
<Skeleton className="h-3 w-full rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex justify-center ${initialized ? "" : "hidden"}`}>
|
||||
<Button
|
||||
onClick={onPlayClick}
|
||||
className="aspect-square rounded-full p-2 w-12 h-12 bg-blue-600 hover:bg-blue-500"
|
||||
@@ -102,7 +112,10 @@ export const SpeechPlayer = (props: {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="col-span-8" ref={containerRef}></div>
|
||||
<div
|
||||
className={`col-span-8 ${initialized ? "" : "hidden"}`}
|
||||
ref={containerRef}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,6 +10,9 @@ export * from "./videos";
|
||||
|
||||
export * from "./medias";
|
||||
|
||||
export * from "./posts";
|
||||
export * from "./users";
|
||||
|
||||
export * from "./db-state";
|
||||
|
||||
export * from "./layout";
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { Button, useToast } from "@renderer/components/ui";
|
||||
import { useContext, useState, useEffect } from "react";
|
||||
import { WEB_API_URL } from "@/constants";
|
||||
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 { toast } = useToast();
|
||||
const { EnjoyApp, login } = useContext(AppSettingsProviderContext);
|
||||
const [endpoint, setEndpoint] = useState(WEB_API_URL);
|
||||
const { EnjoyApp, login, webApi, user } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
|
||||
const handleMixinLogin = () => {
|
||||
const url = `${endpoint}/sessions/new?provider=mixin`;
|
||||
const url = `${webApi.baseUrl}/sessions/new?provider=mixin`;
|
||||
EnjoyApp.view.load(url, { x: 0, y: 0 });
|
||||
};
|
||||
|
||||
@@ -23,11 +23,7 @@ export const LoginForm = () => {
|
||||
const { state, url, error } = event;
|
||||
|
||||
if (error) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: error,
|
||||
variant: "destructive",
|
||||
});
|
||||
toast.error(error);
|
||||
EnjoyApp.view.hide();
|
||||
return;
|
||||
}
|
||||
@@ -36,17 +32,13 @@ export const LoginForm = () => {
|
||||
const provider = new URL(url).pathname.split("/")[2];
|
||||
const code = new URL(url).searchParams.get("code");
|
||||
|
||||
if (!url.startsWith(endpoint)) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("invalidRedirectUrl"),
|
||||
variant: "destructive",
|
||||
});
|
||||
if (!url.startsWith(webApi.baseUrl)) {
|
||||
toast.error(t("invalidRedirectUrl"));
|
||||
EnjoyApp.view.hide();
|
||||
}
|
||||
|
||||
if (provider && code) {
|
||||
EnjoyApp.webApi
|
||||
webApi
|
||||
.auth({ provider, code })
|
||||
.then((user) => {
|
||||
login(user);
|
||||
@@ -55,22 +47,12 @@ export const LoginForm = () => {
|
||||
EnjoyApp.view.hide();
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: t("failedToLogin"),
|
||||
variant: "destructive",
|
||||
});
|
||||
toast.error(t("failedToLogin"));
|
||||
EnjoyApp.view.hide();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
EnjoyApp.app.apiUrl().then((url) => {
|
||||
setEndpoint(url);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
EnjoyApp.view.onViewState((_event, state) => onViewState(state));
|
||||
|
||||
@@ -78,7 +60,17 @@ export const LoginForm = () => {
|
||||
EnjoyApp.view.removeViewStateListeners();
|
||||
EnjoyApp.view.remove();
|
||||
};
|
||||
}, [endpoint]);
|
||||
}, [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">
|
||||
|
||||
@@ -18,7 +18,7 @@ export const LookupResult = (props: {
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
if (!word) return null;
|
||||
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const lookup = (retries = 0) => {
|
||||
if (!word) return;
|
||||
@@ -28,7 +28,7 @@ export const LookupResult = (props: {
|
||||
}
|
||||
|
||||
retries += 1;
|
||||
EnjoyApp.webApi
|
||||
webApi
|
||||
.lookup({
|
||||
word,
|
||||
context,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
MinimizeIcon,
|
||||
GalleryHorizontalIcon,
|
||||
SpellCheckIcon,
|
||||
Share2Icon,
|
||||
} from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
import { type WaveSurferOptions } from "wavesurfer.js";
|
||||
@@ -24,7 +25,6 @@ import { Tooltip } from "react-tooltip";
|
||||
const PLAYBACK_RATE_OPTIONS = [0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75];
|
||||
const MIN_ZOOM_RATIO = 0.25;
|
||||
const MAX_ZOOM_RATIO = 5.0;
|
||||
const ZOOM_RATIO_STEP = 0.25;
|
||||
|
||||
export const MediaPlayerControls = (props: {
|
||||
isPlaying: boolean;
|
||||
@@ -47,6 +47,7 @@ export const MediaPlayerControls = (props: {
|
||||
setWavesurferOptions?: (options: Partial<WaveSurferOptions>) => void;
|
||||
displayInlineCaption?: boolean;
|
||||
setDisplayInlineCaption?: (display: boolean) => void;
|
||||
onShare?: () => void;
|
||||
}) => {
|
||||
const {
|
||||
isPlaying,
|
||||
@@ -67,6 +68,7 @@ export const MediaPlayerControls = (props: {
|
||||
setWavesurferOptions,
|
||||
displayInlineCaption,
|
||||
setDisplayInlineCaption,
|
||||
onShare,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
@@ -244,20 +246,32 @@ export const MediaPlayerControls = (props: {
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{transcriptionDirty && (
|
||||
<div className="absolute right-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className=""
|
||||
onClick={resetTranscription}
|
||||
>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
<Button onClick={saveTranscription}>{t("save")}</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
data-tooltip-id="media-player-controls-tooltip"
|
||||
data-tooltip-content={t("share")}
|
||||
className="relative aspect-square p-0 h-10"
|
||||
onClick={onShare}
|
||||
>
|
||||
<Share2Icon className="w-6 h-6" />
|
||||
</Button>
|
||||
|
||||
<div className="absolute right-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
{transcriptionDirty && (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
className=""
|
||||
onClick={resetTranscription}
|
||||
>
|
||||
{t("reset")}
|
||||
</Button>
|
||||
<Button onClick={saveTranscription}>{t("save")}</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tooltip id="media-player-controls-tooltip" />
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
@@ -60,13 +60,14 @@ export const MediaPlayer = (props: {
|
||||
setPlaybackRate: (value: number) => void;
|
||||
displayInlineCaption?: boolean;
|
||||
setDisplayInlineCaption?: (value: boolean) => void;
|
||||
onShare?: () => void;
|
||||
}) => {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const {
|
||||
mediaId,
|
||||
mediaType,
|
||||
mediaUrl,
|
||||
waveformCacheKey,
|
||||
mediaMd5,
|
||||
transcription,
|
||||
height = 200,
|
||||
currentTime,
|
||||
@@ -88,16 +89,12 @@ export const MediaPlayer = (props: {
|
||||
setPlaybackRate,
|
||||
displayInlineCaption,
|
||||
setDisplayInlineCaption,
|
||||
onShare,
|
||||
} = 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
|
||||
@@ -179,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();
|
||||
@@ -278,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",
|
||||
@@ -322,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;
|
||||
@@ -338,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", () => {
|
||||
@@ -477,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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -536,6 +531,7 @@ export const MediaPlayer = (props: {
|
||||
setWavesurferOptions={(options) => wavesurfer?.setOptions(options)}
|
||||
displayInlineCaption={displayInlineCaption}
|
||||
setDisplayInlineCaption={setDisplayInlineCaption}
|
||||
onShare={onShare}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -90,18 +90,18 @@ 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 prose max-w-prose">
|
||||
<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" />
|
||||
</div>
|
||||
) : (
|
||||
<Markdown
|
||||
className="select-text"
|
||||
className="select-text prose"
|
||||
components={{
|
||||
a({ node, children, ...props }) {
|
||||
try {
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogHeader,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { SpeechPlayer } from "@renderer/components";
|
||||
import { useContext, useState } from "react";
|
||||
@@ -17,9 +28,11 @@ import {
|
||||
AlertCircleIcon,
|
||||
CopyIcon,
|
||||
CheckIcon,
|
||||
Share2Icon,
|
||||
} 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: {
|
||||
@@ -30,9 +43,40 @@ export const UserMessageComponent = (props: {
|
||||
}) => {
|
||||
const { message, onResend, onRemove } = props;
|
||||
const speech = message.speeches?.[0];
|
||||
const { user } = useContext(AppSettingsProviderContext);
|
||||
const { user, webApi } = useContext(AppSettingsProviderContext);
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleShare = async () => {
|
||||
if (message.role === "user") {
|
||||
const content = message.content;
|
||||
webApi
|
||||
.createPost({
|
||||
metadata: {
|
||||
type: "prompt",
|
||||
content,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
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 });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -41,7 +85,7 @@ export const UserMessageComponent = (props: {
|
||||
>
|
||||
<DropdownMenu>
|
||||
<div className="flex flex-col gap-2 px-4 py-2 bg-sky-500/30 border-sky-500 rounded-lg shadow-sm w-full">
|
||||
<Markdown className="select-text">{message.content}</Markdown>
|
||||
<Markdown className="select-text prose">{message.content}</Markdown>
|
||||
|
||||
{Boolean(speech) && <SpeechPlayer speech={speech} />}
|
||||
|
||||
@@ -81,6 +125,34 @@ export const UserMessageComponent = (props: {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{message.createdAt && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Share2Icon
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("share")}
|
||||
className="w-3 h-3 cursor-pointer"
|
||||
/>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("sharePrompt")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("areYouSureToShareThisPromptToCommunity")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button variant="default" onClick={handleShare}>
|
||||
{t("share")}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenuContent>
|
||||
@@ -96,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]}
|
||||
|
||||
9
enjoy/src/renderer/components/posts/index.ts
Normal file
9
enjoy/src/renderer/components/posts/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from "./posts";
|
||||
export * from "./post-audio";
|
||||
export * from "./post-card";
|
||||
export * from "./post-medium";
|
||||
export * from "./post-recording";
|
||||
export * from "./post-story";
|
||||
|
||||
export * from "./post-options";
|
||||
export * from "./post-actions";
|
||||
206
enjoy/src/renderer/components/posts/post-actions.tsx
Normal file
206
enjoy/src/renderer/components/posts/post-actions.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { ConversationsShortcut } from "@renderer/components";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogFooter,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
ScrollArea,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
import Markdown from "react-markdown";
|
||||
import {
|
||||
BotIcon,
|
||||
CheckIcon,
|
||||
CopyPlusIcon,
|
||||
PlusCircleIcon,
|
||||
ChevronRightIcon,
|
||||
} from "lucide-react";
|
||||
import { useCopyToClipboard } from "@uidotdev/usehooks";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export const PostActions = (props: { post: PostType }) => {
|
||||
const { post } = props;
|
||||
const [_, copyToClipboard] = useCopyToClipboard();
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [asking, setAsking] = useState<boolean>(false);
|
||||
const [aiReplies, setAiReplies] = useState<MessageType[]>([]);
|
||||
|
||||
const handleAddMedium = async () => {
|
||||
if (post.targetType !== "Medium") return;
|
||||
const medium = post.target as MediumType;
|
||||
if (!medium) return;
|
||||
|
||||
if (medium.mediumType === "Video") {
|
||||
try {
|
||||
const video = await EnjoyApp.videos.findOne({ md5: medium.md5 });
|
||||
if (video) {
|
||||
toast.info(t("videoAlreadyAddedToLibrary"));
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
EnjoyApp.videos
|
||||
.create(medium.sourceUrl, {
|
||||
coverUrl: medium.coverUrl,
|
||||
md5: medium.md5,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("videoSuccessfullyAddedToLibrary"));
|
||||
});
|
||||
} else if (medium.mediumType === "Audio") {
|
||||
try {
|
||||
const audio = await EnjoyApp.audios.findOne({ md5: medium.md5 });
|
||||
if (audio) {
|
||||
toast.info(t("audioAlreadyAddedToLibrary"));
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error.message);
|
||||
}
|
||||
|
||||
EnjoyApp.audios
|
||||
.create(medium.sourceUrl, {
|
||||
coverUrl: medium.coverUrl,
|
||||
md5: medium.md5,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("audioSuccessfullyAddedToLibrary"));
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center space-x-2 justify-end">
|
||||
{post.target && post.targetType === "Medium" && (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("addToLibary")}
|
||||
data-tooltip-place="bottom"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="px-1.5 rounded-full"
|
||||
>
|
||||
<PlusCircleIcon className="w-5 h-5 text-muted-foreground hover:text-primary" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("addRecourse")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{(post.target as MediumType).mediumType === "Video" &&
|
||||
t("areYouSureToAddThisVideoToYourLibrary")}
|
||||
|
||||
{(post.target as MediumType).mediumType === "Audio" &&
|
||||
t("areYouSureToAddThisAudioToYourLibrary")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleAddMedium}>
|
||||
{t("confirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
{typeof post.metadata?.content === "string" && (
|
||||
<Button
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("copy")}
|
||||
data-tooltip-place="bottom"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="px-1.5 rounded-full"
|
||||
>
|
||||
{copied ? (
|
||||
<CheckIcon className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<CopyPlusIcon
|
||||
className="w-5 h-5 text-muted-foreground hover:text-primary"
|
||||
onClick={() => {
|
||||
copyToClipboard(post.metadata.content as string);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 3000);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{post.metadata?.type === "prompt" && (
|
||||
<Dialog open={asking} onOpenChange={setAsking}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("sendToAIAssistant")}
|
||||
data-tooltip-place="bottom"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="px-1.5 rounded-full"
|
||||
>
|
||||
<BotIcon className="w-5 h-5 text-muted-foreground hover:text-primary" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("sendToAIAssistant")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ConversationsShortcut
|
||||
prompt={post.metadata.content as string}
|
||||
onReply={(replies) => {
|
||||
setAiReplies([...aiReplies, ...replies]);
|
||||
setAsking(false);
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<ScrollArea></ScrollArea>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{aiReplies.length > 0 && <AIReplies replies={aiReplies} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AIReplies = (props: { replies: MessageType[] }) => {
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
{props.replies.map((reply) => (
|
||||
<div key={reply.id} className="bg-muted py-2 px-4 rounded">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<BotIcon className="w-5 h-5 text-blue-500" />
|
||||
<Link to={`/conversations/${reply.conversationId}`}>
|
||||
<ChevronRightIcon className="w-5 h-5 text-muted-foreground" />
|
||||
</Link>
|
||||
</div>
|
||||
<Markdown className="prose select-text">{reply.content}</Markdown>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
198
enjoy/src/renderer/components/posts/post-audio.tsx
Normal file
198
enjoy/src/renderer/components/posts/post-audio.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useState, useRef, useCallback, useContext } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { PitchContour } from "@renderer/components";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
import { Button, Skeleton } from "@renderer/components/ui";
|
||||
import { PlayIcon, PauseIcon } from "lucide-react";
|
||||
import { useIntersectionObserver } from "@uidotdev/usehooks";
|
||||
import { secondsToTimestamp } from "@renderer/lib/utils";
|
||||
import { MediaPlayer, MediaProvider } from "@vidstack/react";
|
||||
import {
|
||||
DefaultAudioLayout,
|
||||
defaultLayoutIcons,
|
||||
} from "@vidstack/react/player/layouts/default";
|
||||
export const STORAGE_WORKER_ENDPOINT = "https://enjoy-storage.baizhiheizi.com";
|
||||
|
||||
export const PostAudio = (props: {
|
||||
audio: Partial<MediumType>;
|
||||
height?: number;
|
||||
}) => {
|
||||
const { audio, height = 80 } = props;
|
||||
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [transcription, setTranscription] = useState<TranscriptionType>();
|
||||
|
||||
const currentTranscription = (transcription?.result || []).find(
|
||||
(s) =>
|
||||
currentTime >= s.offsets.from / 1000.0 &&
|
||||
currentTime <= s.offsets.to / 1000.0
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
webApi
|
||||
.transcriptions({
|
||||
targetMd5: audio.md5,
|
||||
})
|
||||
.then((response) => {
|
||||
setTranscription(response?.transcriptions?.[0]);
|
||||
});
|
||||
}, [audio.md5]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{audio.sourceUrl.startsWith(STORAGE_WORKER_ENDPOINT) ? (
|
||||
<WavesurferPlayer
|
||||
currentTime={currentTime}
|
||||
setCurrentTime={setCurrentTime}
|
||||
audio={audio}
|
||||
height={height}
|
||||
/>
|
||||
) : (
|
||||
<MediaPlayer
|
||||
onTimeUpdate={({ currentTime: _currentTime }) => {
|
||||
setCurrentTime(_currentTime);
|
||||
}}
|
||||
src={audio.sourceUrl}
|
||||
>
|
||||
<MediaProvider />
|
||||
<DefaultAudioLayout icons={defaultLayoutIcons} />
|
||||
</MediaPlayer>
|
||||
)}
|
||||
|
||||
{currentTranscription && (
|
||||
<div className="mt-2 bg-muted px-4 py-2 rounded">
|
||||
<div className="text-muted-foreground text-center font-serif">
|
||||
{currentTranscription.text}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{audio.coverUrl && (
|
||||
<div className="mt-2">
|
||||
<img src={audio.coverUrl} className="w-full rounded" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const WavesurferPlayer = (props: {
|
||||
audio: Partial<MediumType>;
|
||||
height?: number;
|
||||
currentTime: number;
|
||||
setCurrentTime: (currentTime: number) => void;
|
||||
}) => {
|
||||
const { audio, height = 80, currentTime, setCurrentTime } = props;
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [wavesurfer, setWavesurfer] = useState(null);
|
||||
const containerRef = useRef();
|
||||
const [ref, entry] = useIntersectionObserver({
|
||||
threshold: 1,
|
||||
});
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
|
||||
const onPlayClick = useCallback(() => {
|
||||
wavesurfer.isPlaying() ? wavesurfer.pause() : wavesurfer.play();
|
||||
}, [wavesurfer]);
|
||||
|
||||
useEffect(() => {
|
||||
// use the intersection observer to only create the wavesurfer instance
|
||||
// when the player is visible
|
||||
if (!entry?.isIntersecting) return;
|
||||
if (!audio.sourceUrl) return;
|
||||
if (wavesurfer) return;
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container: containerRef.current,
|
||||
url: audio.sourceUrl,
|
||||
height,
|
||||
barWidth: 1,
|
||||
cursorWidth: 0,
|
||||
autoCenter: true,
|
||||
autoScroll: true,
|
||||
dragToSeek: true,
|
||||
hideScrollbar: true,
|
||||
minPxPerSec: 100,
|
||||
waveColor: "#ddd",
|
||||
progressColor: "rgba(0, 0, 0, 0.25)",
|
||||
});
|
||||
|
||||
setWavesurfer(ws);
|
||||
}, [audio.sourceUrl, entry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wavesurfer) return;
|
||||
|
||||
const subscriptions = [
|
||||
wavesurfer.on("play", () => {
|
||||
setIsPlaying(true);
|
||||
}),
|
||||
wavesurfer.on("pause", () => {
|
||||
setIsPlaying(false);
|
||||
}),
|
||||
wavesurfer.on("timeupdate", (time: number) => {
|
||||
setCurrentTime(time);
|
||||
}),
|
||||
wavesurfer.on("decode", () => {
|
||||
setDuration(wavesurfer.getDuration());
|
||||
const peaks = wavesurfer.getDecodedData().getChannelData(0);
|
||||
const sampleRate = wavesurfer.options.sampleRate;
|
||||
wavesurfer.renderer.getWrapper().appendChild(
|
||||
PitchContour({
|
||||
peaks,
|
||||
sampleRate,
|
||||
height,
|
||||
})
|
||||
);
|
||||
setInitialized(true);
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
subscriptions.forEach((unsub) => unsub());
|
||||
wavesurfer?.destroy();
|
||||
};
|
||||
}, [wavesurfer]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{secondsToTimestamp(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={ref}
|
||||
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]">
|
||||
<Skeleton className="h-3 w-full rounded-full" />
|
||||
<Skeleton className="h-3 w-full rounded-full" />
|
||||
<Skeleton className="h-3 w-full rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex justify-center ${initialized ? "" : "hidden"}`}>
|
||||
<Button
|
||||
onClick={onPlayClick}
|
||||
className="aspect-square rounded-full p-2 w-12 h-12 bg-blue-600 hover:bg-blue-500"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<PauseIcon className="w-6 h-6 text-white" />
|
||||
) : (
|
||||
<PlayIcon className="w-6 h-6 text-white" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`col-span-8 ${initialized ? "" : "hidden"}`}
|
||||
ref={containerRef}
|
||||
></div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
81
enjoy/src/renderer/components/posts/post-card.tsx
Normal file
81
enjoy/src/renderer/components/posts/post-card.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useContext } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import {
|
||||
PostRecording,
|
||||
PostActions,
|
||||
PostMedium,
|
||||
PostStory,
|
||||
PostOptions,
|
||||
} from "@renderer/components";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@renderer/components/ui";
|
||||
import { formatDateTime } from "@renderer/lib/utils";
|
||||
import { t } from "i18next";
|
||||
import Markdown from "react-markdown";
|
||||
|
||||
export const PostCard = (props: {
|
||||
post: PostType;
|
||||
handleDelete: (id: string) => void;
|
||||
}) => {
|
||||
const { post, handleDelete } = props;
|
||||
const { user } = useContext(AppSettingsProviderContext);
|
||||
|
||||
return (
|
||||
<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>
|
||||
<AvatarImage src={post.user.avatarUrl} />
|
||||
<AvatarFallback className="text-xl">
|
||||
{post.user.name[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex flex-col justify-between">
|
||||
<div className="">{post.user.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatDateTime(post.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{post.user.id == user.id && (
|
||||
<PostOptions handleDelete={() => handleDelete(post.id)} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{post.metadata?.type === "prompt" && (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("sharedPrompt")}
|
||||
</div>
|
||||
<Markdown className="prose prose-slate prose-pre:whitespace-normal select-text">
|
||||
{"```prompt\n" + post.metadata.content + "\n```"}
|
||||
</Markdown>
|
||||
</>
|
||||
)}
|
||||
|
||||
{post.targetType == "Medium" && (
|
||||
<PostMedium medium={post.target as MediumType} />
|
||||
)}
|
||||
|
||||
{post.targetType == "Recording" && (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("sharedRecording")}
|
||||
</div>
|
||||
<PostRecording recording={post.target as RecordingType} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{post.targetType == "Story" && (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("sharedStory")}
|
||||
</div>
|
||||
<PostStory story={post.target as StoryType} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<PostActions post={post} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
45
enjoy/src/renderer/components/posts/post-medium.tsx
Normal file
45
enjoy/src/renderer/components/posts/post-medium.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { PostAudio } from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
import { MediaPlayer, MediaProvider } from "@vidstack/react";
|
||||
import {
|
||||
DefaultVideoLayout,
|
||||
defaultLayoutIcons,
|
||||
} from "@vidstack/react/player/layouts/default";
|
||||
|
||||
export const PostMedium = (props: { medium: MediumType }) => {
|
||||
const { medium } = props;
|
||||
if (!medium.sourceUrl) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{medium.mediumType == "Video" && (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("sharedAudio")}
|
||||
</div>
|
||||
<MediaPlayer
|
||||
poster={medium.coverUrl}
|
||||
src={{
|
||||
type: `${medium.mediumType.toLowerCase()}/${
|
||||
medium.extname.replace(".", "") || "mp4"
|
||||
}`,
|
||||
src: medium.sourceUrl,
|
||||
}}
|
||||
>
|
||||
<MediaProvider />
|
||||
<DefaultVideoLayout icons={defaultLayoutIcons} />
|
||||
</MediaPlayer>
|
||||
</>
|
||||
)}
|
||||
|
||||
{medium.mediumType == "Audio" && (
|
||||
<>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("sharedAudio")}
|
||||
</div>
|
||||
<PostAudio audio={medium} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
63
enjoy/src/renderer/components/posts/post-options.tsx
Normal file
63
enjoy/src/renderer/components/posts/post-options.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@renderer/components/ui";
|
||||
import { MoreHorizontalIcon, Trash2Icon } from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const PostOptions = (props: { handleDelete: () => void }) => {
|
||||
const { handleDelete } = props;
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<MoreHorizontalIcon className="w-4 h-4" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem className="cursor-pointer" onClick={() => setDeleting(true)}>
|
||||
<span className="text-sm mr-auto text-destructive capitalize">
|
||||
{t("delete")}
|
||||
</span>
|
||||
<Trash2Icon className="w-4 h-4 text-destructive" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<AlertDialog open={deleting} onOpenChange={setDeleting}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("removeSharing")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("areYouSureToRemoveThisSharing")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
handleDelete();
|
||||
setDeleting(false);
|
||||
}}
|
||||
>
|
||||
{t("delete")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
133
enjoy/src/renderer/components/posts/post-recording.tsx
Normal file
133
enjoy/src/renderer/components/posts/post-recording.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { PitchContour } from "@renderer/components";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
import { Button, Skeleton } from "@renderer/components/ui";
|
||||
import { PlayIcon, PauseIcon } from "lucide-react";
|
||||
import { useIntersectionObserver } from "@uidotdev/usehooks";
|
||||
import { secondsToTimestamp } from "@renderer/lib/utils";
|
||||
|
||||
export const PostRecording = (props: {
|
||||
recording: RecordingType;
|
||||
height?: number;
|
||||
}) => {
|
||||
const { recording, height = 80 } = props;
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [wavesurfer, setWavesurfer] = useState(null);
|
||||
const containerRef = useRef();
|
||||
const [ref, entry] = useIntersectionObserver({
|
||||
threshold: 1,
|
||||
});
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
|
||||
const onPlayClick = useCallback(() => {
|
||||
wavesurfer.isPlaying() ? wavesurfer.pause() : wavesurfer.play();
|
||||
}, [wavesurfer]);
|
||||
|
||||
useEffect(() => {
|
||||
// use the intersection observer to only create the wavesurfer instance
|
||||
// when the player is visible
|
||||
if (!entry?.isIntersecting) return;
|
||||
if (!recording.src) return;
|
||||
if (wavesurfer) return;
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container: containerRef.current,
|
||||
url: recording.src,
|
||||
height,
|
||||
barWidth: 1,
|
||||
cursorWidth: 0,
|
||||
autoCenter: true,
|
||||
autoScroll: true,
|
||||
dragToSeek: true,
|
||||
hideScrollbar: true,
|
||||
minPxPerSec: 100,
|
||||
waveColor: "rgba(0, 0, 0, 0.25)",
|
||||
progressColor: "rgba(0, 0, 0, 0.5)",
|
||||
});
|
||||
|
||||
setWavesurfer(ws);
|
||||
}, [recording.src, entry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wavesurfer) return;
|
||||
|
||||
const subscriptions = [
|
||||
wavesurfer.on("play", () => {
|
||||
setIsPlaying(true);
|
||||
}),
|
||||
wavesurfer.on("pause", () => {
|
||||
setIsPlaying(false);
|
||||
}),
|
||||
wavesurfer.on("decode", () => {
|
||||
setDuration(wavesurfer.getDuration());
|
||||
const peaks = wavesurfer.getDecodedData().getChannelData(0);
|
||||
const sampleRate = wavesurfer.options.sampleRate;
|
||||
wavesurfer.renderer.getWrapper().appendChild(
|
||||
PitchContour({
|
||||
peaks,
|
||||
sampleRate,
|
||||
height,
|
||||
})
|
||||
);
|
||||
setInitialized(true);
|
||||
}),
|
||||
];
|
||||
|
||||
return () => {
|
||||
subscriptions.forEach((unsub) => unsub());
|
||||
wavesurfer?.destroy();
|
||||
};
|
||||
}, [wavesurfer]);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{secondsToTimestamp(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={ref}
|
||||
className="bg-sky-500/30 rounded-lg grid grid-cols-9 items-center relative h-[80px]"
|
||||
>
|
||||
{!initialized && (
|
||||
<div className="col-span-9 flex flex-col justify-around h-[80px]">
|
||||
<Skeleton className="h-2 w-full rounded-full" />
|
||||
<Skeleton className="h-2 w-full rounded-full" />
|
||||
<Skeleton className="h-2 w-full rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex justify-center ${initialized ? "" : "hidden"}`}>
|
||||
<Button
|
||||
onClick={onPlayClick}
|
||||
className="aspect-square rounded-full p-2 w-12 h-12 bg-blue-600 hover:bg-blue-500"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<PauseIcon className="w-6 h-6 text-white" />
|
||||
) : (
|
||||
<PlayIcon className="w-6 h-6 text-white" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`col-span-8 ${initialized ? "" : "hidden"}`}
|
||||
ref={containerRef}
|
||||
></div>
|
||||
</div>
|
||||
|
||||
{
|
||||
recording.referenceText && (
|
||||
<div className="mt-2 bg-muted px-4 py-2 rounded">
|
||||
<div className="text-muted-foreground text-center font-serif">
|
||||
{recording.referenceText}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
25
enjoy/src/renderer/components/posts/post-story.tsx
Normal file
25
enjoy/src/renderer/components/posts/post-story.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export const PostStory = (props: { story: StoryType }) => {
|
||||
const { story } = props;
|
||||
return (
|
||||
<Link className="block" to={`/stories/${story.id}`}>
|
||||
<div className="rounded-lg flex items-start border">
|
||||
<div className="aspect-square h-36 bg-muted">
|
||||
<img
|
||||
src={story.metadata?.image}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-4 py-2">
|
||||
<div className="line-clamp-2 text-lg font-semibold mb-2">
|
||||
{story.metadata?.title}
|
||||
</div>
|
||||
<div className="line-clamp-3 text-sm text-muted-foreground">
|
||||
{story.metadata?.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
74
enjoy/src/renderer/components/posts/posts.tsx
Normal file
74
enjoy/src/renderer/components/posts/posts.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { PostCard, LoaderSpin } from "@renderer/components";
|
||||
import { toast, Button } from "@renderer/components//ui";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const Posts = () => {
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [posts, setPosts] = useState<PostType[]>([]);
|
||||
const [nextPage, setNextPage] = useState(1);
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
webApi
|
||||
.deletePost(id)
|
||||
.then(() => {
|
||||
toast.success(t("removeSharingSuccessfully"));
|
||||
setPosts(posts.filter((post) => post.id !== id));
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(t("removeSharingFailed"), { description: error.message });
|
||||
});
|
||||
};
|
||||
|
||||
const fetchPosts = async (page: number = nextPage) => {
|
||||
if (!page) return;
|
||||
|
||||
webApi
|
||||
.posts({
|
||||
page,
|
||||
items: 10,
|
||||
})
|
||||
.then((res) => {
|
||||
setPosts([...posts, ...res.posts]);
|
||||
setNextPage(res.next);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPosts();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return <LoaderSpin />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-screen-sm mx-auto">
|
||||
{posts.length === 0 && (
|
||||
<div className="text-center text-gray-500">{t("noOneSharedYet")}</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} handleDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{nextPage && (
|
||||
<div className="py-4 flex justify-center">
|
||||
<Button variant="link" onClick={() => fetchPosts(nextPage)}>
|
||||
{t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from "i18next";
|
||||
import { Button, useToast } from "@renderer/components/ui";
|
||||
import { Button, toast } from "@renderer/components/ui";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { useState, useContext } from "react";
|
||||
import { LoaderIcon } from "lucide-react";
|
||||
@@ -7,14 +7,11 @@ import { LoaderIcon } from "lucide-react";
|
||||
export const About = () => {
|
||||
const { version } = useContext(AppSettingsProviderContext);
|
||||
const [checking, setChecking] = useState<boolean>(false);
|
||||
const { toast } = useToast();
|
||||
const checkUpdate = () => {
|
||||
setChecking(true);
|
||||
setTimeout(() => {
|
||||
setChecking(false);
|
||||
toast({
|
||||
description: t("alreadyLatestVersion"),
|
||||
});
|
||||
toast.info(t("alreadyLatestVersion"));
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -22,7 +22,12 @@ import {
|
||||
Input,
|
||||
Label,
|
||||
Separator,
|
||||
useToast,
|
||||
toast,
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectItem,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
} from "@renderer/components/ui";
|
||||
import { WhisperModelOptions } from "@renderer/components";
|
||||
import {
|
||||
@@ -31,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 (
|
||||
@@ -39,8 +44,12 @@ export const BasicSettings = () => {
|
||||
<div className="font-semibold mb-4 capitilized">{t("basicSettings")}</div>
|
||||
<UserSettings />
|
||||
<Separator />
|
||||
<LanguageSettings />
|
||||
<Separator />
|
||||
<LibraryPathSettings />
|
||||
<Separator />
|
||||
<FfmpegSettings />
|
||||
<Separator />
|
||||
<WhisperSettings />
|
||||
<Separator />
|
||||
<OpenaiSettings />
|
||||
@@ -51,7 +60,7 @@ export const BasicSettings = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const UserSettings = () => {
|
||||
export const UserSettings = () => {
|
||||
const { user, logout } = useContext(AppSettingsProviderContext);
|
||||
|
||||
if (!user) return null;
|
||||
@@ -104,6 +113,46 @@ const UserSettings = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const LanguageSettings = () => {
|
||||
const { language, switchLanguage } = useContext(AppSettingsProviderContext);
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between py-4">
|
||||
<div className="">
|
||||
<div className="mb-2">{t("language")}</div>
|
||||
<div className="text-sm text-muted-foreground mb-2">
|
||||
{language === "en" ? "English" : "简体中文"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<div className="flex items-center justify-end space-x-2 mb-2">
|
||||
<Select
|
||||
value={language}
|
||||
onValueChange={(value: "en" | "zh-CN") => {
|
||||
switchLanguage(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="text-xs">
|
||||
<SelectValue>
|
||||
{language === "en" ? "English" : "简体中文"}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem className="text-xs" value="en">
|
||||
English
|
||||
</SelectItem>
|
||||
<SelectItem className="text-xs" value="zh-CN">
|
||||
简体中文
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const LibraryPathSettings = () => {
|
||||
const { libraryPath, EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
|
||||
@@ -139,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>
|
||||
@@ -152,6 +205,107 @@ const LibraryPathSettings = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const FfmpegSettings = () => {
|
||||
const { EnjoyApp, setFfmegConfig, ffmpegConfig } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
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="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 className="">
|
||||
<div className="flex items-center justify-end space-x-2 mb-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
EnjoyApp.ffmpeg
|
||||
.discover()
|
||||
.then(({ ffmpegPath, ffprobePath }) => {
|
||||
if (ffmpegPath && ffprobePath) {
|
||||
toast.success(
|
||||
t("ffmpegFoundAt", {
|
||||
path: ffmpegPath + ", " + ffprobePath,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.warning(t("ffmpegNotFound"));
|
||||
}
|
||||
refreshFfmpegConfig();
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("scan")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={editing ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setEditing(!editing)}
|
||||
>
|
||||
{editing ? t("cancel") : t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const WhisperSettings = () => {
|
||||
const { whisperModel, whisperModelsPath } = useContext(
|
||||
AppSettingsProviderContext
|
||||
@@ -166,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>
|
||||
@@ -196,7 +352,6 @@ const OpenaiSettings = () => {
|
||||
const { openai, setOpenai } = useContext(AISettingsProviderContext);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const ref = useRef<HTMLInputElement>();
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleSave = () => {
|
||||
if (!ref.current) return;
|
||||
@@ -206,10 +361,7 @@ const OpenaiSettings = () => {
|
||||
});
|
||||
setEditing(false);
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("openaiKeySaved"),
|
||||
});
|
||||
toast.success(t("openaiKeySaved"));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -247,7 +399,7 @@ const OpenaiSettings = () => {
|
||||
</div>
|
||||
<div className="">
|
||||
<Button
|
||||
variant={editing ? "secondary" : "default"}
|
||||
variant={editing ? "outline" : "secondary"}
|
||||
size="sm"
|
||||
onClick={() => setEditing(!editing)}
|
||||
>
|
||||
@@ -264,7 +416,6 @@ const GoogleGenerativeAiSettings = () => {
|
||||
);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const ref = useRef<HTMLInputElement>();
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleSave = () => {
|
||||
if (!ref.current) return;
|
||||
@@ -274,10 +425,7 @@ const GoogleGenerativeAiSettings = () => {
|
||||
});
|
||||
setEditing(false);
|
||||
|
||||
toast({
|
||||
title: t("success"),
|
||||
description: t("googleGenerativeAiKeySaved"),
|
||||
});
|
||||
toast.success(t("googleGenerativeAiKeySaved"));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -315,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" />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef } from "react";
|
||||
import RecordPlugin from "wavesurfer.js/dist/plugins/record";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
import { cn } from "@renderer/lib/utils";
|
||||
import { RadialProgress, useToast } from "@renderer/components/ui";
|
||||
import { RadialProgress, toast } from "@renderer/components/ui";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
|
||||
export const RecordButton = (props: {
|
||||
@@ -16,7 +16,6 @@ export const RecordButton = (props: {
|
||||
const { className, disabled, onRecordBegin, onRecordEnd } = props;
|
||||
const [isRecording, setIsRecording] = useState<boolean>(false);
|
||||
const [duration, setDuration] = useState<number>(0);
|
||||
const { toast } = useToast();
|
||||
|
||||
useHotkeys(["command+alt+r", "control+alt+r"], () => {
|
||||
if (disabled) return;
|
||||
@@ -67,10 +66,7 @@ export const RecordButton = (props: {
|
||||
if (duration > 1000) {
|
||||
onRecordEnd(blob, duration);
|
||||
} else {
|
||||
toast({
|
||||
description: t("recordTooShort"),
|
||||
variant: "warning",
|
||||
});
|
||||
toast.warning(t("recordTooShort"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -4,18 +4,26 @@ import { RecordingPlayer } from "@renderer/components";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { ChevronDownIcon, Trash2Icon, InfoIcon, Share2Icon } from "lucide-react";
|
||||
import {
|
||||
MoreHorizontalIcon,
|
||||
Trash2Icon,
|
||||
Share2Icon,
|
||||
GaugeCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { formatDateTime, secondsToTimestamp } from "@renderer/lib/utils";
|
||||
import { t } from "i18next";
|
||||
|
||||
@@ -26,39 +34,67 @@ export const RecordingCard = (props: {
|
||||
}) => {
|
||||
const { recording, id, onSelect } = props;
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
|
||||
const handleDelete = () => {
|
||||
EnjoyApp.recordings.destroy(recording.id);
|
||||
};
|
||||
const handleShare = async () => {
|
||||
if (!recording.updatedAt) {
|
||||
try {
|
||||
await EnjoyApp.recordings.upload(recording.id);
|
||||
} catch (error) {
|
||||
toast.error(t("shareFailed"), { description: error.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
webApi
|
||||
.createPost({
|
||||
targetId: recording.id,
|
||||
targetType: "Recording",
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("sharedSuccessfully"), {
|
||||
description: t("sharedRecording"),
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(t("shareFailed"), {
|
||||
description: error.message,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div id={id} className="flex items-center justify-end px-4 transition-all">
|
||||
<DropdownMenu>
|
||||
<div className="w-full">
|
||||
<div className="bg-white 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)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<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)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<RecordingPlayer
|
||||
recording={recording}
|
||||
isPlaying={isPlaying}
|
||||
setIsPlaying={setIsPlaying}
|
||||
/>
|
||||
<RecordingPlayer
|
||||
recording={recording}
|
||||
isPlaying={isPlaying}
|
||||
setIsPlaying={setIsPlaying}
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
onClick={onSelect}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-6"
|
||||
>
|
||||
<InfoIcon
|
||||
className={`w-4 h-4
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
<Button
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("pronunciationAssessment")}
|
||||
data-tooltip-place="bottom"
|
||||
onClick={onSelect}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-6"
|
||||
>
|
||||
<GaugeCircleIcon
|
||||
className={`w-4 h-4
|
||||
${
|
||||
recording.pronunciationAssessment
|
||||
? recording.pronunciationAssessment
|
||||
@@ -71,29 +107,60 @@ export const RecordingCard = (props: {
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</Button>
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("share")}
|
||||
data-tooltip-place="bottom"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="p-1 h-6"
|
||||
>
|
||||
<Share2Icon className="w-4 h-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("shareRecording")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("areYouSureToShareThisRecordingToCommunity")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button onClick={handleShare}>{t("share")}</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<ChevronDownIcon className="w-4 h-4 text-muted-foreground" />
|
||||
<MoreHorizontalIcon className="w-4 h-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDateTime(recording.createdAt)}
|
||||
</span>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setIsDeleteDialogOpen(true)}>
|
||||
<span className="mr-auto text-destructive capitalize">
|
||||
{t("delete")}
|
||||
</span>
|
||||
<Trash2Icon className="w-4 h-4 text-destructive" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={() => setIsDeleteDialogOpen(true)}>
|
||||
<span className="mr-auto text-destructive capitalize">
|
||||
{t("delete")}
|
||||
</span>
|
||||
<Trash2Icon className="w-4 h-4 text-destructive" />
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex justify-end">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDateTime(recording.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={isDeleteDialogOpen}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import WaveSurfer from "wavesurfer.js";
|
||||
import { PitchContour } from "@renderer/components";
|
||||
import { Button } from "@renderer/components/ui";
|
||||
import { Button, Skeleton } from "@renderer/components/ui";
|
||||
import { PlayIcon, PauseIcon } from "lucide-react";
|
||||
import { useIntersectionObserver } from "@uidotdev/usehooks";
|
||||
|
||||
@@ -30,6 +30,7 @@ export const RecordingPlayer = (props: {
|
||||
const [ref, entry] = useIntersectionObserver({
|
||||
threshold: 0,
|
||||
});
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
const onPlayClick = useCallback(() => {
|
||||
wavesurfer.isPlaying() ? wavesurfer.pause() : wavesurfer.play();
|
||||
@@ -40,6 +41,7 @@ export const RecordingPlayer = (props: {
|
||||
// when the player is visible
|
||||
if (!entry?.isIntersecting) return;
|
||||
if (!recording?.src) return;
|
||||
if (wavesurfer) return;
|
||||
|
||||
const ws = WaveSurfer.create({
|
||||
container: containerRef.current,
|
||||
@@ -78,6 +80,7 @@ export const RecordingPlayer = (props: {
|
||||
height,
|
||||
})
|
||||
);
|
||||
setInitialized(true);
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -105,7 +108,15 @@ export const RecordingPlayer = (props: {
|
||||
|
||||
return (
|
||||
<div ref={ref} className="grid grid-cols-11 xl:grid-cols-12 items-center">
|
||||
<div className="flex justify-center">
|
||||
{!initialized && (
|
||||
<div className="col-span-9 flex flex-col justify-around h-[80px]">
|
||||
<Skeleton className="h-3 w-full rounded-full" />
|
||||
<Skeleton className="h-3 w-full rounded-full" />
|
||||
<Skeleton className="h-3 w-full rounded-full" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`flex justify-center ${initialized ? "" : "hidden"}`}>
|
||||
<Button
|
||||
onClick={onPlayClick}
|
||||
className="aspect-square rounded-full p-2 w-12 h-12 bg-blue-600 hover:bg-blue-500"
|
||||
@@ -118,7 +129,10 @@ export const RecordingPlayer = (props: {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="col-span-10 xl:col-span-11" ref={containerRef}></div>
|
||||
<div
|
||||
className={`col-span-10 xl:col-span-11 ${initialized ? "" : "hidden"}`}
|
||||
ref={containerRef}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
BookMarkedIcon,
|
||||
UserIcon,
|
||||
BotIcon,
|
||||
UsersRoundIcon,
|
||||
} from "lucide-react";
|
||||
import { useLocation, Link } from "react-router-dom";
|
||||
import { t } from "i18next";
|
||||
@@ -50,6 +51,21 @@ export const Sidebar = () => {
|
||||
<span className="hidden xl:block">{t("sidebar.home")}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/community"
|
||||
data-tooltip-id="sidebar-tooltip"
|
||||
data-tooltip-content={t("sidebar.community")}
|
||||
className="block"
|
||||
>
|
||||
<Button
|
||||
variant={activeTab === "" ? "secondary" : "ghost"}
|
||||
className="w-full xl:justify-start"
|
||||
>
|
||||
<UsersRoundIcon className="xl:mr-2 h-5 w-5" />
|
||||
<span className="hidden xl:block">{t("sidebar.community")}</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ import { AppSettingsProviderContext } from "@renderer/context";
|
||||
|
||||
export const StoriesSegment = () => {
|
||||
const [stories, setStorys] = useState<StoryType[]>([]);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const fetchStorys = async () => {
|
||||
EnjoyApp.webApi.mineStories().then((response) => {
|
||||
webApi.mineStories().then((response) => {
|
||||
if (response?.stories) {
|
||||
setStorys(response.stories);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,16 @@ import {
|
||||
Alert,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
AlertDialog,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
Button,
|
||||
ScrollArea,
|
||||
Separator,
|
||||
Sheet,
|
||||
@@ -17,6 +27,7 @@ import {
|
||||
ScanTextIcon,
|
||||
LoaderIcon,
|
||||
StarIcon,
|
||||
Share2Icon,
|
||||
} from "lucide-react";
|
||||
import { t } from "i18next";
|
||||
|
||||
@@ -36,6 +47,7 @@ export const StoryToolbar = (props: {
|
||||
marked?: boolean;
|
||||
toggleMarked?: () => void;
|
||||
pendingLookups?: LookupType[];
|
||||
handleShare?: () => void;
|
||||
}) => {
|
||||
const {
|
||||
starred,
|
||||
@@ -47,6 +59,7 @@ export const StoryToolbar = (props: {
|
||||
toggleMarked,
|
||||
meanings = [],
|
||||
pendingLookups = [],
|
||||
handleShare,
|
||||
} = props;
|
||||
|
||||
const [vocabularyVisible, setVocabularyVisible] = useState<boolean>(
|
||||
@@ -76,6 +89,27 @@ export const StoryToolbar = (props: {
|
||||
<ToolbarButton toggled={starred} onClick={toggleStarred}>
|
||||
<StarIcon className="w-6 h-6" />
|
||||
</ToolbarButton>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<ToolbarButton toggled={false} onClick={toggleStarred}>
|
||||
<Share2Icon className="w-6 h-6" />
|
||||
</ToolbarButton>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("shareStory")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("areYouSureToShareThisStoryToCommunity")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction>
|
||||
<Button onClick={handleShare}>{t("share")}</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</FloatingToolbar>
|
||||
|
||||
<Sheet
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -13,9 +13,9 @@ export * from "./input";
|
||||
export * from "./avatar";
|
||||
export * from "./alert-dialog";
|
||||
export * from "./card";
|
||||
export * from "./toast";
|
||||
export * from "./use-toast";
|
||||
export * from "./toaster";
|
||||
// export * from "./toast";
|
||||
// export * from "./use-toast";
|
||||
// export * from "./toaster";
|
||||
export * from "./toggle";
|
||||
export * from "./radio-group";
|
||||
export * from "./scroll-area";
|
||||
@@ -33,3 +33,4 @@ export * from "./select";
|
||||
export * from "./sheet";
|
||||
export * from "./hover-card";
|
||||
export * from "./floating-toolbar";
|
||||
export * from "./sonner";
|
||||
|
||||
31
enjoy/src/renderer/components/ui/sonner.tsx
Normal file
31
enjoy/src/renderer/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner, toast } from "sonner";
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "light" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster, toast };
|
||||
1
enjoy/src/renderer/components/users/index.ts
Normal file
1
enjoy/src/renderer/components/users/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './users-rankings';
|
||||
83
enjoy/src/renderer/components/users/users-rankings.tsx
Normal file
83
enjoy/src/renderer/components/users/users-rankings.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
Card,
|
||||
CardTitle,
|
||||
CardHeader,
|
||||
CardContent,
|
||||
} from "@renderer/components/ui";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
import { formatDuration } from "@renderer/lib/utils";
|
||||
|
||||
export const UsersRankings = () => {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
<RankingsCard range="day" />
|
||||
<RankingsCard range="week" />
|
||||
<RankingsCard range="month" />
|
||||
<RankingsCard range="all" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const RankingsCard = (props: {
|
||||
range: "day" | "week" | "month" | "year" | "all";
|
||||
}) => {
|
||||
const { range } = props;
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [rankings, setRankings] = useState<UserType[]>([]);
|
||||
|
||||
const fetchRankings = async () => {
|
||||
webApi.rankings(range).then(
|
||||
(res) => {
|
||||
setRankings(res.rankings);
|
||||
},
|
||||
(err) => {
|
||||
console.error(err);
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchRankings();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t(`${range}Rankings`)}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{rankings.length === 0 && (
|
||||
<div className="text-center text-gray-500">
|
||||
{t("noOneHasRecordedYet")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rankings.map((user, index) => (
|
||||
<div key={user.id} className="flex items-center space-x-4 p-2">
|
||||
<div className="font-mono text-sm">#{index + 1}</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Avatar className="w-8 h-8">
|
||||
<AvatarImage src={user.avatarUrl} />
|
||||
<AvatarFallback className="text-xl">
|
||||
{user.name[0].toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="max-w-20 truncate">{user.name}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 font-serif text-right">
|
||||
{formatDuration(user.recordingsDuration, "millisecond")}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -11,16 +11,29 @@ import {
|
||||
MediaTranscription,
|
||||
} from "@renderer/components";
|
||||
import { LoaderIcon } from "lucide-react";
|
||||
import { ScrollArea } from "@renderer/components/ui";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogHeader,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
AlertDialogContent,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
Button,
|
||||
ScrollArea,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const VideoDetail = (props: { id?: string; md5?: string }) => {
|
||||
const { id, md5 } = props;
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext);
|
||||
|
||||
const [video, setVideo] = useState<VideoType | null>(null);
|
||||
const [transcription, setTranscription] = useState<TranscriptionType>(null);
|
||||
const [initialized, setInitialized] = useState<boolean>(false);
|
||||
const [sharing, setSharing] = useState<boolean>(false);
|
||||
|
||||
// Player controls
|
||||
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||
@@ -35,6 +48,8 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isLooping, setIsLooping] = useState(false);
|
||||
const [playBackRate, setPlaybackRate] = useState<number>(1);
|
||||
const [displayInlineCaption, setDisplayInlineCaption] =
|
||||
useState<boolean>(true);
|
||||
|
||||
const onTransactionUpdate = (event: CustomEvent) => {
|
||||
const { model, action, record } = event.detail || {};
|
||||
@@ -43,6 +58,39 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
if (!video.source.startsWith("http")) {
|
||||
toast.error(t("shareFailed"), {
|
||||
description: t("cannotShareLocalVideo"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!video.source && !video.isUploaded) {
|
||||
try {
|
||||
await EnjoyApp.videos.upload(video.id);
|
||||
} catch (err) {
|
||||
toast.error(t("shareFailed"), { description: err.message });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
webApi
|
||||
.createPost({
|
||||
targetType: "Video",
|
||||
targetId: video.id,
|
||||
})
|
||||
.then(() => {
|
||||
toast.success(t("sharedSuccessfully"), {
|
||||
description: t("sharedVideo"),
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t("shareFailed"), { description: err.message });
|
||||
});
|
||||
setSharing(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const where = id ? { id } : { md5 };
|
||||
EnjoyApp.videos.findOne(where).then((video) => {
|
||||
@@ -90,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}
|
||||
@@ -109,6 +157,9 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
|
||||
setIsLooping={setIsLooping}
|
||||
playBackRate={playBackRate}
|
||||
setPlaybackRate={setPlaybackRate}
|
||||
displayInlineCaption={displayInlineCaption}
|
||||
setDisplayInlineCaption={setDisplayInlineCaption}
|
||||
onShare={() => setSharing(true)}
|
||||
/>
|
||||
|
||||
<ScrollArea
|
||||
@@ -149,8 +200,25 @@ export const VideoDetail = (props: { id?: string; md5?: string }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog open={sharing} onOpenChange={(value) => setSharing(value)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("shareAudio")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("areYouSureToShareThisAudioToCommunity")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
|
||||
<Button variant="default" onClick={handleShare}>
|
||||
{t("share")}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
VideosTable,
|
||||
VideoEditForm,
|
||||
AddMediaButton,
|
||||
LoaderSpin,
|
||||
} from "@renderer/components";
|
||||
import { t } from "i18next";
|
||||
import {
|
||||
@@ -19,10 +20,12 @@ import {
|
||||
AlertDialogDescription,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
DbProviderContext,
|
||||
@@ -43,11 +46,10 @@ export const VideosComponent = () => {
|
||||
|
||||
const { addDblistener, removeDbListener } = useContext(DbProviderContext);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const navigate = useNavigate();
|
||||
const [offset, setOffest] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
}, []);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
addDblistener(onVideosUpdate);
|
||||
@@ -59,12 +61,36 @@ export const VideosComponent = () => {
|
||||
}, []);
|
||||
|
||||
const fetchVideos = async () => {
|
||||
const videos = await EnjoyApp.videos.findAll({
|
||||
limit: 10,
|
||||
});
|
||||
if (!videos) return;
|
||||
if (loading) return;
|
||||
if (offset === -1) return;
|
||||
|
||||
dispatchVideos({ type: "set", records: videos });
|
||||
setLoading(true);
|
||||
const limit = 10;
|
||||
EnjoyApp.videos
|
||||
.findAll({
|
||||
offset,
|
||||
limit,
|
||||
})
|
||||
.then((_videos) => {
|
||||
if (_videos.length === 0) {
|
||||
setOffest(-1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_videos.length < limit) {
|
||||
setOffest(-1);
|
||||
} else {
|
||||
setOffest(offset + _videos.length);
|
||||
}
|
||||
|
||||
dispatchVideos({ type: "append", records: _videos });
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
};
|
||||
|
||||
const onVideosUpdate = (event: CustomEvent) => {
|
||||
@@ -93,6 +119,8 @@ export const VideosComponent = () => {
|
||||
};
|
||||
|
||||
if (videos.length === 0) {
|
||||
if (loading) return <LoaderSpin />;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-48 border border-dashed rounded-lg">
|
||||
<AddMediaButton />
|
||||
@@ -135,6 +163,14 @@ export const VideosComponent = () => {
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{offset > -1 && (
|
||||
<div className="flex items-center justify-center my-4">
|
||||
<Button variant="link" onClick={fetchVideos}>
|
||||
{t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog
|
||||
open={!!editing}
|
||||
onOpenChange={(value) => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
CardContent,
|
||||
CardFooter,
|
||||
ScrollArea,
|
||||
useToast,
|
||||
toast,
|
||||
Progress,
|
||||
} from "@renderer/components/ui";
|
||||
import { t } from "i18next";
|
||||
@@ -67,10 +67,8 @@ export const WhisperModelOptionsPanel = () => {
|
||||
export const WhisperModelOptions = () => {
|
||||
const [selectingModel, setSelectingModel] = useState<ModelType | null>(null);
|
||||
const [availableModels, setAvailableModels] = useState<ModelType[]>([]);
|
||||
const { whisperModelsPath, whisperModel, setWhisperModel, EnjoyApp } = useContext(
|
||||
AppSettingsProviderContext
|
||||
);
|
||||
const { toast } = useToast();
|
||||
const { whisperModelsPath, whisperModel, setWhisperModel, EnjoyApp } =
|
||||
useContext(AppSettingsProviderContext);
|
||||
|
||||
useEffect(() => {
|
||||
updateAvailableModels();
|
||||
@@ -126,10 +124,7 @@ export const WhisperModelOptions = () => {
|
||||
if (option.downloaded) {
|
||||
setWhisperModel(option.name);
|
||||
} else if (option.downloadState) {
|
||||
toast({
|
||||
title: "Downloading",
|
||||
description: `${option.name} is downloading...`,
|
||||
});
|
||||
toast.warning(t("downloading", { file: option.name }));
|
||||
} else {
|
||||
setSelectingModel(option);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { createContext, useEffect, useState } from "react";
|
||||
import { WEB_API_URL } from "@/constants";
|
||||
import { Client } from "@/api";
|
||||
import i18n from "@renderer/i18n";
|
||||
|
||||
type AppSettingsProviderState = {
|
||||
webApi: Client;
|
||||
user: UserType | null;
|
||||
initialized: boolean;
|
||||
version?: string;
|
||||
@@ -14,9 +18,12 @@ type AppSettingsProviderState = {
|
||||
ffmpegConfig?: FfmpegConfigType;
|
||||
setFfmegConfig?: (config: FfmpegConfigType) => void;
|
||||
EnjoyApp?: EnjoyAppType;
|
||||
language?: "en" | "zh-CN";
|
||||
switchLanguage?: (language: "en" | "zh-CN") => void;
|
||||
};
|
||||
|
||||
const initialState: AppSettingsProviderState = {
|
||||
webApi: null,
|
||||
user: null,
|
||||
initialized: false,
|
||||
};
|
||||
@@ -31,11 +38,14 @@ export const AppSettingsProvider = ({
|
||||
}) => {
|
||||
const [initialized, setInitialized] = useState<boolean>(false);
|
||||
const [version, setVersion] = useState<string>("");
|
||||
const [apiUrl, setApiUrl] = useState<string>(WEB_API_URL);
|
||||
const [webApi, setWebApi] = useState<Client>(null);
|
||||
const [user, setUser] = useState<UserType | null>(null);
|
||||
const [libraryPath, setLibraryPath] = useState("");
|
||||
const [whisperModelsPath, setWhisperModelsPath] = useState<string>("");
|
||||
const [whisperModel, setWhisperModel] = useState<string>(null);
|
||||
const [ffmpegConfig, setFfmegConfig] = useState<FfmpegConfigType>(null);
|
||||
const [language, setLanguage] = useState<"en" | "zh-CN">();
|
||||
const EnjoyApp = window.__ENJOY_APP__;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,6 +54,7 @@ export const AppSettingsProvider = ({
|
||||
fetchLibraryPath();
|
||||
fetchModel();
|
||||
fetchFfmpegConfig();
|
||||
fetchLanguage();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -54,6 +65,30 @@ export const AppSettingsProvider = ({
|
||||
validate();
|
||||
}, [user, libraryPath, whisperModel, ffmpegConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!apiUrl) return;
|
||||
|
||||
setWebApi(
|
||||
new Client({
|
||||
baseUrl: apiUrl,
|
||||
accessToken: user?.accessToken,
|
||||
})
|
||||
);
|
||||
}, [user, apiUrl]);
|
||||
|
||||
const fetchLanguage = async () => {
|
||||
const language = await EnjoyApp.settings.getLanguage();
|
||||
setLanguage(language as "en" | "zh-CN");
|
||||
i18n.changeLanguage(language);
|
||||
};
|
||||
|
||||
const switchLanguage = (language: "en" | "zh-CN") => {
|
||||
EnjoyApp.settings.switchLanguage(language).then(() => {
|
||||
i18n.changeLanguage(language);
|
||||
setLanguage(language);
|
||||
});
|
||||
};
|
||||
|
||||
const fetchFfmpegConfig = async () => {
|
||||
const config = await EnjoyApp.settings.getFfmpegConfig();
|
||||
setFfmegConfig(config);
|
||||
@@ -65,10 +100,18 @@ export const AppSettingsProvider = ({
|
||||
};
|
||||
|
||||
const fetchUser = async () => {
|
||||
const apiUrl = await EnjoyApp.app.apiUrl();
|
||||
setApiUrl(apiUrl);
|
||||
|
||||
const currentUser = await EnjoyApp.settings.getUser();
|
||||
if (!currentUser) return;
|
||||
|
||||
EnjoyApp.webApi.me().then((user) => {
|
||||
const client = new Client({
|
||||
baseUrl: apiUrl,
|
||||
accessToken: currentUser.accessToken,
|
||||
});
|
||||
|
||||
client.me().then((user) => {
|
||||
if (user?.id) {
|
||||
login(currentUser);
|
||||
} else {
|
||||
@@ -107,6 +150,10 @@ export const AppSettingsProvider = ({
|
||||
setWhisperModel(whisperModel);
|
||||
};
|
||||
|
||||
const fetchApiUrl = async () => {
|
||||
return apiUrl;
|
||||
};
|
||||
|
||||
const setModelHandler = async (name: string) => {
|
||||
await EnjoyApp.settings.setWhisperModel(name);
|
||||
setWhisperModel(name);
|
||||
@@ -121,8 +168,11 @@ export const AppSettingsProvider = ({
|
||||
return (
|
||||
<AppSettingsProviderContext.Provider
|
||||
value={{
|
||||
language,
|
||||
switchLanguage,
|
||||
EnjoyApp,
|
||||
version,
|
||||
webApi,
|
||||
user,
|
||||
login,
|
||||
logout,
|
||||
|
||||
@@ -19,10 +19,9 @@ i18n
|
||||
.use(initReactI18next) // passes i18n down to react-i18next
|
||||
.init({
|
||||
resources,
|
||||
lng: "zh-CN", // language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources
|
||||
// you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage
|
||||
// if you're using a language detector, do not define the lng option
|
||||
|
||||
lng: "en",
|
||||
supportedLngs: ["en", "zh-CN"],
|
||||
fallbackLng: "en",
|
||||
interpolation: {
|
||||
escapeValue: false, // react already safes from xss
|
||||
},
|
||||
|
||||
@@ -3,10 +3,12 @@ import { twMerge } from "tailwind-merge";
|
||||
import dayjs from "dayjs";
|
||||
import localizedFormat from "dayjs/plugin/localizedFormat";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
import duration, { type DurationUnitType } from "dayjs/plugin/duration";
|
||||
import "dayjs/locale/en";
|
||||
import "dayjs/locale/zh-cn";
|
||||
import i18next, { t } from "i18next";
|
||||
dayjs.extend(localizedFormat);
|
||||
dayjs.extend(duration);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
@@ -18,6 +20,23 @@ export function secondsToTimestamp(seconds: number) {
|
||||
return date.toISOString().substr(11, 8);
|
||||
}
|
||||
|
||||
export function humanizeDuration(
|
||||
duration: number,
|
||||
unit: DurationUnitType = "second"
|
||||
) {
|
||||
dayjs.locale(i18next.resolvedLanguage?.toLowerCase() || "en");
|
||||
return dayjs.duration(duration, unit).humanize();
|
||||
}
|
||||
|
||||
export function formatDuration(
|
||||
duration: number,
|
||||
unit: DurationUnitType = "second",
|
||||
format = "HH:mm:ss"
|
||||
) {
|
||||
dayjs.locale(i18next.resolvedLanguage?.toLowerCase() || "en");
|
||||
return dayjs.duration(duration, unit).format(format);
|
||||
}
|
||||
|
||||
export function bytesToSize(bytes: number) {
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
if (bytes === 0) {
|
||||
|
||||
49
enjoy/src/renderer/pages/community.tsx
Normal file
49
enjoy/src/renderer/pages/community.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Button,
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsContent,
|
||||
TabsTrigger,
|
||||
} from "@renderer/components/ui";
|
||||
import { UsersRankings, Posts } from "@renderer/components";
|
||||
import { ChevronLeftIcon } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { t } from "i18next";
|
||||
|
||||
export default () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="bg-muted h-full px-4 lg:px-8 py-6">
|
||||
<div className="max-w-screen-md mx-auto mb-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" />
|
||||
</Button>
|
||||
<span>{t("sidebar.community")}</span>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="square">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="square">{t("square")}</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="rankings"
|
||||
disabled
|
||||
className="cursor-not-allowed"
|
||||
data-tooltip-id="global-tooltip"
|
||||
data-tooltip-content={t("comingSoon")}
|
||||
>
|
||||
{t("rankings")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="square">
|
||||
<Posts />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rankings"></TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -6,13 +6,9 @@ import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetTrigger,
|
||||
useToast,
|
||||
toast,
|
||||
} from "@renderer/components/ui";
|
||||
import {
|
||||
MessageComponent,
|
||||
ConversationForm,
|
||||
SpeechForm,
|
||||
} from "@renderer/components";
|
||||
import { MessageComponent, ConversationForm } from "@renderer/components";
|
||||
import { SendIcon, BotIcon, LoaderIcon, SettingsIcon } from "lucide-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { t } from "i18next";
|
||||
@@ -32,7 +28,6 @@ export default () => {
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const [content, setConent] = useState<string>("");
|
||||
const [submitting, setSubmitting] = useState<boolean>(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const [messages, dispatchMessages] = useReducer(messagesReducer, []);
|
||||
const [offset, setOffest] = useState(0);
|
||||
@@ -82,10 +77,7 @@ export default () => {
|
||||
|
||||
const handleSubmit = async (text?: string, file?: string) => {
|
||||
if (submitting) {
|
||||
toast({
|
||||
title: t("warning"),
|
||||
description: t("anotherRequestIsPending"),
|
||||
});
|
||||
toast.warning(t("anotherRequestIsPending"));
|
||||
}
|
||||
text = text ? text : content;
|
||||
|
||||
@@ -279,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}
|
||||
@@ -287,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 />}
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { Button } from "@renderer/components/ui";
|
||||
import { StoryForm, StoryCard, LoaderSpin } from "@renderer/components";
|
||||
import { useState, useContext, useEffect } from "react";
|
||||
import { AppSettingsProviderContext } from "@renderer/context";
|
||||
import { t } from "i18next";
|
||||
|
||||
export default () => {
|
||||
const [stories, setStorys] = useState<StoryType[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [nextPage, setNextPage] = useState(1);
|
||||
|
||||
const fetchStorys = async () => {
|
||||
EnjoyApp.webApi
|
||||
const fetchStories = async (page: number = nextPage) => {
|
||||
if (!page) return;
|
||||
|
||||
webApi
|
||||
.mineStories()
|
||||
.then((response) => {
|
||||
if (response?.stories) {
|
||||
setStorys(response.stories);
|
||||
setStorys([...stories, ...response.stories]);
|
||||
}
|
||||
setNextPage(response.next);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
@@ -21,7 +27,7 @@ export default () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStorys();
|
||||
fetchStories();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
@@ -38,6 +44,14 @@ export default () => {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nextPage && (
|
||||
<div className="py-4 flex justify-center">
|
||||
<Button variant="link" onClick={() => fetchStories(nextPage)}>
|
||||
{t("loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Input, Button, ScrollArea, useToast } from "@renderer/components/ui";
|
||||
import { Input, Button, ScrollArea, toast } from "@renderer/components/ui";
|
||||
import {
|
||||
LoaderSpin,
|
||||
StoryViewer,
|
||||
@@ -26,8 +26,7 @@ export default () => {
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [readable, setReadable] = useState(true);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { toast } = useToast();
|
||||
const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext);
|
||||
const [meanings, setMeanings] = useState<MeaningType[]>([]);
|
||||
const [marked, setMarked] = useState<boolean>(false);
|
||||
const [doc, setDoc] = useState<any>(null);
|
||||
@@ -52,7 +51,7 @@ export default () => {
|
||||
const createStory = async () => {
|
||||
if (!story) return;
|
||||
|
||||
EnjoyApp.webApi
|
||||
webApi
|
||||
.createStory({
|
||||
url: story.metadata?.url || story.url,
|
||||
...story,
|
||||
@@ -73,10 +72,7 @@ export default () => {
|
||||
if (state == "did-fail-load") {
|
||||
setLoading(false);
|
||||
if (error) {
|
||||
toast({
|
||||
title: error,
|
||||
variant: "destructive",
|
||||
});
|
||||
toast.error(error);
|
||||
setError(error);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { t } from "i18next";
|
||||
import { ScrollArea } from "@renderer/components/ui";
|
||||
import { ScrollArea, toast } from "@renderer/components/ui";
|
||||
import {
|
||||
LoaderSpin,
|
||||
PagePlaceholder,
|
||||
@@ -16,7 +16,7 @@ nlp.plugin(paragraphs);
|
||||
let timeout: NodeJS.Timeout = null;
|
||||
export default () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [story, setStory] = useState<StoryType>();
|
||||
const [meanings, setMeanings] = useState<MeaningType[]>([]);
|
||||
@@ -26,7 +26,7 @@ export default () => {
|
||||
const [doc, setDoc] = useState<any>(null);
|
||||
|
||||
const fetchStory = async () => {
|
||||
EnjoyApp.webApi
|
||||
webApi
|
||||
.story(id)
|
||||
.then((story) => {
|
||||
setStory(story);
|
||||
@@ -41,7 +41,7 @@ export default () => {
|
||||
|
||||
const fetchMeanings = async () => {
|
||||
setScanning(true);
|
||||
EnjoyApp.webApi
|
||||
webApi
|
||||
.storyMeanings(id, { items: 500 })
|
||||
.then((response) => {
|
||||
if (!response) return;
|
||||
@@ -88,14 +88,14 @@ export default () => {
|
||||
});
|
||||
});
|
||||
|
||||
EnjoyApp.webApi.lookupInBatch(vocabulary).then((response) => {
|
||||
webApi.lookupInBatch(vocabulary).then((response) => {
|
||||
const { errors } = response;
|
||||
if (errors.length > 0) {
|
||||
console.warn(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
EnjoyApp.webApi.extractVocabularyFromStory(id).then(() => {
|
||||
webApi.extractVocabularyFromStory(id).then(() => {
|
||||
fetchStory();
|
||||
if (pendingLookups.length > 0) return;
|
||||
|
||||
@@ -108,16 +108,29 @@ export default () => {
|
||||
if (!story) return;
|
||||
|
||||
if (story.starred) {
|
||||
EnjoyApp.webApi.unstarStory(id).then((result) => {
|
||||
webApi.unstarStory(id).then((result) => {
|
||||
setStory({ ...story, starred: result.starred });
|
||||
});
|
||||
} else {
|
||||
EnjoyApp.webApi.starStory(id).then((result) => {
|
||||
webApi.starStory(id).then((result) => {
|
||||
setStory({ ...story, starred: result.starred });
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
webApi
|
||||
.createPost({ targetId: story.id, targetType: "Story" })
|
||||
.then(() => {
|
||||
toast.success(t("sharedStory"));
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.error(t("shareFailed"), {
|
||||
description: error.message,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStory();
|
||||
fetchMeanings();
|
||||
@@ -162,6 +175,7 @@ export default () => {
|
||||
starred={story.starred}
|
||||
toggleStarred={toggleStarred}
|
||||
pendingLookups={pendingLookups}
|
||||
handleShare={handleShare}
|
||||
/>
|
||||
|
||||
<StoryViewer
|
||||
|
||||
@@ -11,14 +11,14 @@ export default () => {
|
||||
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [meanings, setMeanings] = useState<MeaningType[]>([]);
|
||||
const { EnjoyApp } = useContext(AppSettingsProviderContext);
|
||||
const { webApi } = useContext(AppSettingsProviderContext);
|
||||
const [currentIndex, setCurrentIndex] = useState<number>(0);
|
||||
const [nextPage, setNextPage] = useState(1);
|
||||
|
||||
const fetchMeanings = async (page: number = nextPage) => {
|
||||
if (!page) return;
|
||||
|
||||
EnjoyApp.webApi
|
||||
webApi
|
||||
.mineMeanings({ page, items: 10 })
|
||||
.then((response) => {
|
||||
setMeanings([...meanings, ...response.meanings]);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
export const audiosReducer = (
|
||||
audios: AudioType[],
|
||||
action: {
|
||||
type: "create" | "update" | "destroy" | "set";
|
||||
type: "append" | "create" | "update" | "destroy" | "set";
|
||||
record?: Partial<AudioType>;
|
||||
records?: Partial<AudioType>[];
|
||||
}
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "append": {
|
||||
if (action.record) {
|
||||
return [...audios, action.record];
|
||||
} else if (action.records) {
|
||||
return [...audios, ...action.records];
|
||||
}
|
||||
}
|
||||
case "create": {
|
||||
return [action.record, ...audios];
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
export const videosReducer = (
|
||||
videos: VideoType[],
|
||||
action: {
|
||||
type: "create" | "update" | "destroy" | "set";
|
||||
type: "append" | "create" | "update" | "destroy" | "set";
|
||||
record?: Partial<VideoType>;
|
||||
records?: Partial<VideoType>[];
|
||||
}
|
||||
) => {
|
||||
switch (action.type) {
|
||||
case "append": {
|
||||
if (action.record) {
|
||||
return [...videos, action.record];
|
||||
} else if (action.records) {
|
||||
return [...videos, ...action.records];
|
||||
}
|
||||
}
|
||||
case "create": {
|
||||
return [action.record, ...videos];
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import Story from "./pages/story";
|
||||
import Books from "./pages/books";
|
||||
import Profile from "./pages/profile";
|
||||
import Home from "./pages/home";
|
||||
import Community from "./pages/community";
|
||||
import StoryPreview from "./pages/story-preview";
|
||||
|
||||
export default createHashRouter([
|
||||
@@ -23,6 +24,10 @@ export default createHashRouter([
|
||||
errorElement: <ErrorPage />,
|
||||
children: [
|
||||
{ index: true, element: <Home /> },
|
||||
{
|
||||
path: "/community",
|
||||
element: <Community />,
|
||||
},
|
||||
{
|
||||
path: "/profile",
|
||||
element: <Profile />,
|
||||
|
||||
30
enjoy/src/types.d.ts
vendored
30
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;
|
||||
};
|
||||
|
||||
@@ -105,37 +107,11 @@ type MeaningType = {
|
||||
lookups: LookupType[];
|
||||
};
|
||||
|
||||
type StoryType = {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
content: string;
|
||||
metadata: {
|
||||
[key: string]: string;
|
||||
};
|
||||
vocabulary?: string[];
|
||||
extracted?: boolean;
|
||||
starred?: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type CreateStoryParamsType = {
|
||||
title: string;
|
||||
content: string;
|
||||
url: string;
|
||||
html: string;
|
||||
metadata: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
|
||||
type PagyResponseType = {
|
||||
page: number;
|
||||
next: number | null;
|
||||
};
|
||||
|
||||
|
||||
type AudibleBookType = {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
|
||||
1
enjoy/src/types/audio.d.ts
vendored
1
enjoy/src/types/audio.d.ts
vendored
@@ -11,6 +11,7 @@ type AudioType = {
|
||||
transcribing?: boolean;
|
||||
recordingsCount?: number;
|
||||
recordingsDuration?: number;
|
||||
isUploaded?: boolean;
|
||||
uploadedAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
93
enjoy/src/types/enjoy-app.d.ts
vendored
93
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,9 @@ type EnjoyAppType = {
|
||||
LlmProviderType
|
||||
) => Promise<void>;
|
||||
getFfmpegConfig: () => Promise<FfmpegConfigType>;
|
||||
setFfmpegConfig: () => Promise<void>;
|
||||
setFfmpegConfig: (config: FfmpegConfigType) => Promise<void>;
|
||||
getLanguage: () => Promise<string>;
|
||||
switchLanguage: (language: string) => Promise<void>;
|
||||
};
|
||||
fs: {
|
||||
ensureDir: (path: string) => Promise<boolean>;
|
||||
@@ -93,7 +96,7 @@ type EnjoyAppType = {
|
||||
audios: {
|
||||
findAll: (params: object) => Promise<AudioType[]>;
|
||||
findOne: (params: object) => Promise<AudioType>;
|
||||
create: (source: string, params?: object) => Promise<AudioType>;
|
||||
create: (uri: string, params?: object) => Promise<AudioType>;
|
||||
update: (id: string, params: object) => Promise<AudioType | undefined>;
|
||||
destroy: (id: string) => Promise<undefined>;
|
||||
transcribe: (id: string) => Promise<void>;
|
||||
@@ -102,8 +105,8 @@ type EnjoyAppType = {
|
||||
videos: {
|
||||
findAll: (params: object) => Promise<VideoType[]>;
|
||||
findOne: (params: object) => Promise<VideoType>;
|
||||
create: (source: string, params?: object) => Promise<VideoType>;
|
||||
update: (id: string, params: object) => Promise<VideoType | undefined>;
|
||||
create: (uri: string, params?: any) => Promise<VideoType>;
|
||||
update: (id: string, params: any) => Promise<VideoType | undefined>;
|
||||
destroy: (id: string) => Promise<undefined>;
|
||||
transcribe: (id: string) => Promise<void>;
|
||||
upload: (id: string) => Promise<void>;
|
||||
@@ -143,9 +146,9 @@ type EnjoyAppType = {
|
||||
) => Promise<SegementRecordingStatsType>;
|
||||
};
|
||||
conversations: {
|
||||
findAll: (params: object) => Promise<ConversationType[]>;
|
||||
findOne: (params: object) => Promise<ConversationType>;
|
||||
create: (params: object) => Promise<ConversationType>;
|
||||
findAll: (params: any) => Promise<ConversationType[]>;
|
||||
findOne: (params: any) => Promise<ConversationType>;
|
||||
create: (params: any) => Promise<ConversationType>;
|
||||
update: (id: string, params: object) => Promise<ConversationType>;
|
||||
destroy: (id: string) => Promise<void>;
|
||||
ask: (
|
||||
@@ -159,7 +162,7 @@ type EnjoyAppType = {
|
||||
arrayBuffer: ArrayBuffer;
|
||||
};
|
||||
}
|
||||
) => Promise<MessageType>;
|
||||
) => Promise<MessageType[]>;
|
||||
};
|
||||
messages: {
|
||||
findAll: (params: object) => Promise<MessageType[]>;
|
||||
@@ -177,6 +180,12 @@ type EnjoyAppType = {
|
||||
};
|
||||
ffmpeg: {
|
||||
download: () => Promise<FfmpegConfigType>;
|
||||
check: () => Promise<boolean>;
|
||||
discover: () => Promise<{
|
||||
ffmpegPath: string;
|
||||
ffprobePath: string;
|
||||
scanDirs: string[];
|
||||
}>;
|
||||
};
|
||||
download: {
|
||||
onState: (callback: (event, state) => void) => void;
|
||||
@@ -185,70 +194,6 @@ type EnjoyAppType = {
|
||||
dashboard: () => Promise<DownloadStateType[]>;
|
||||
removeAllListeners: () => void;
|
||||
};
|
||||
webApi: {
|
||||
auth: (params: { provider: string; code: string }) => Promise<UserType>;
|
||||
me: () => Promise<UserType>;
|
||||
lookup: (params: {
|
||||
word: string;
|
||||
context?: string;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
}) => Promise<LookupType>;
|
||||
lookupInBatch: (
|
||||
params: {
|
||||
word: string;
|
||||
context?: string;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
}[]
|
||||
) => Promise<{ successCount: number; errors: string[]; total: number }>;
|
||||
mineMeanings: (params?: {
|
||||
page?: number;
|
||||
items?: number;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
}) => Promise<
|
||||
{
|
||||
meanings: MeaningType[];
|
||||
} & PagyResponseType
|
||||
>;
|
||||
createStory: (params: {
|
||||
title: string;
|
||||
content: string;
|
||||
url: string;
|
||||
metadata: {
|
||||
[key: string]: any;
|
||||
};
|
||||
}) => Promise<StoryType>;
|
||||
extractVocabularyFromStory: (id: string) => Promise<string[]>;
|
||||
story: (id: string) => Promise<StoryType>;
|
||||
stories: (params?: { page: number }) => Promise<{
|
||||
stories: StoryType[];
|
||||
page: number;
|
||||
next: number | null;
|
||||
}>;
|
||||
mineStories: (params?: { page: number }) => Promise<{
|
||||
stories: StoryType[];
|
||||
page: number;
|
||||
next: number | null;
|
||||
}>;
|
||||
storyMeanings: (
|
||||
storyId: string,
|
||||
params?: {
|
||||
page?: number;
|
||||
items?: number;
|
||||
sourceId?: string;
|
||||
sourceType?: string;
|
||||
}
|
||||
) => Promise<
|
||||
{
|
||||
meanings: MeaningType[];
|
||||
pendingLookups: LookupType[];
|
||||
} & PagyResponseType
|
||||
>;
|
||||
starStory: (id: string) => Promise<{ starred: boolean }>;
|
||||
unstarStory: (id: string) => Promise<{ starred: boolean }>;
|
||||
};
|
||||
cacheObjects: {
|
||||
get: (key: string) => Promise<any>;
|
||||
set: (key: string, value: any, ttl?: number) => Promise<void>;
|
||||
@@ -260,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>;
|
||||
};
|
||||
};
|
||||
|
||||
10
enjoy/src/types/medium.d.ts
vendored
Normal file
10
enjoy/src/types/medium.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
type MediumType = {
|
||||
id: string;
|
||||
md5: string;
|
||||
mediumType: string;
|
||||
coverUrl?: string;
|
||||
sourceUrl?: string;
|
||||
extname?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
17
enjoy/src/types/post.d.ts
vendored
Normal file
17
enjoy/src/types/post.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
type PostType = {
|
||||
id: string;
|
||||
metadata: {
|
||||
type: 'text' | 'prompt' | 'llm_configuration';
|
||||
content:
|
||||
| string
|
||||
| {
|
||||
[key: string]: any;
|
||||
};
|
||||
};
|
||||
user: UserType;
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
target?: MediumType | StoryType | RecordingType;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
6
enjoy/src/types/recording.d.ts
vendored
6
enjoy/src/types/recording.d.ts
vendored
@@ -1,12 +1,12 @@
|
||||
type RecordingType = {
|
||||
id: string;
|
||||
filename: string;
|
||||
filename?: string;
|
||||
target?: AudioType | (MessageType & any);
|
||||
targetId: string;
|
||||
targetType: string;
|
||||
pronunciationAssessment?: PronunciationAssessmentType & any;
|
||||
segmentIndex: number;
|
||||
segmentText?: string;
|
||||
referenceId: number;
|
||||
referenceText?: string;
|
||||
duration?: number;
|
||||
src?: string;
|
||||
md5: string;
|
||||
|
||||
24
enjoy/src/types/story.d.ts
vendored
Normal file
24
enjoy/src/types/story.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
type StoryType = {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
content: string;
|
||||
metadata: {
|
||||
[key: string]: string;
|
||||
};
|
||||
vocabulary?: string[];
|
||||
extracted?: boolean;
|
||||
starred?: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type CreateStoryParamsType = {
|
||||
title: string;
|
||||
content: string;
|
||||
url: string;
|
||||
html: string;
|
||||
metadata: {
|
||||
[key: string]: string;
|
||||
};
|
||||
};
|
||||
2
enjoy/src/types/user.d.ts
vendored
2
enjoy/src/types/user.d.ts
vendored
@@ -3,4 +3,6 @@ type UserType = {
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
accessToken?: string;
|
||||
recordingsCount?: number;
|
||||
recordingsDuration?: number;
|
||||
};
|
||||
|
||||
1
enjoy/src/types/video.d.ts
vendored
1
enjoy/src/types/video.d.ts
vendored
@@ -12,6 +12,7 @@ type VideoType = {
|
||||
transcribing: boolean;
|
||||
recordingsCount?: number;
|
||||
recordingsDuration?: number;
|
||||
isUploaded?: boolean;
|
||||
uploadedAt?: Date;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
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[];
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user