96 Commits

Author SHA1 Message Date
azalea 9ce4bd53e6 [U] Release 1.0.3 2025-12-09 03:29:18 +09:00
azalea a3031a6fb7 Update README.md 2025-12-09 03:22:45 +09:00
azalea 612dfac06e [F] 浊音没打完的时候打其他字应该算打错 2025-12-09 02:58:48 +09:00
azalea 4e3ceb6876 [F] Fix mobile virtual keyboard affecting page height 2025-12-09 02:56:39 +09:00
azalea 6501008c8c [F] Fix mobile view height causing button to be invisible when browser url bar is not hidden 2025-12-09 02:52:58 +09:00
azalea 1d2daef9a1 [F] Fix katakana 浊音 marked incorrectly when using 12-key input 2025-12-09 02:19:01 +09:00
azalea 41288532ba [U] Bump version 2025-12-04 22:41:54 +09:00
azalea ceb12ba8b6 [U] Changelog 2025-12-04 22:22:44 +09:00
azalea 190b6143c9 [F] Fix 12-key IME 2025-12-04 22:11:58 +09:00
azalea 9f92e90237 [+] Changelog 2025-11-29 13:38:10 +08:00
azalea 7909327326 [+] Dynamic version number 2025-11-29 13:36:04 +08:00
azalea 0bb68ae554 [F] Space between English words 2025-11-29 13:22:13 +08:00
azalea cf02921078 Merge branch 'main' of https://github.com/MaigoLabs/amaoke.app 2025-11-25 22:35:01 +08:00
azalea 91a28b1998 [+] Caddy 2025-11-25 22:34:46 +08:00
azalea 29fc505bf1 Revert "[+] File server"
This reverts commit f5cfa3cdb8.
2025-11-25 22:33:05 +08:00
azalea 0245230696 Update README.md 2025-11-25 22:13:58 +08:00
azalea 5aa29d13db [F] Fix settings persistence 2025-11-25 21:17:37 +08:00
azalea 02da7827af Update README.md 2025-11-25 12:30:07 +08:00
azalea d8ce084a8e [+] Metatags 2025-11-25 12:17:57 +08:00
azalea f5cfa3cdb8 [+] File server 2025-11-25 11:36:30 +08:00
azalea c4f3435d9f [F] Fix disabled conditions 2025-11-25 11:27:02 +08:00
azalea f9030f36ad [F] Fix default 2025-11-25 11:24:14 +08:00
azalea 2b9aa80aaa Update README.md 2025-11-25 11:21:19 +08:00
azalea 564f6f80e5 Update README.md 2025-11-25 11:13:56 +08:00
azalea 15287669b6 Update docker-compose.yml 2025-11-25 11:11:28 +08:00
azalea 3294956e64 [+] Docs 2025-11-25 11:08:54 +08:00
azalea ac15c174ab Update docker-compose.yml 2025-11-25 11:07:30 +08:00
azalea fc944e1355 [+] cloudflared 2025-11-25 10:43:31 +08:00
azalea 6932d586d4 [+] Password for admin login 2025-11-25 10:36:22 +08:00
azalea a5317c670f Update README.md 2025-11-25 10:33:16 +08:00
azalea 0be54adf20 [+] About page link 2025-11-25 10:29:29 +08:00
azalea 1b4de5634a [O] Better logging 2025-11-25 10:16:59 +08:00
azalea 33cabbcefc [F] Fix deploy 2025-11-25 10:03:58 +08:00
azalea 8ec2c47746 [+] .env 2025-11-25 09:51:37 +08:00
azalea ce0494eb36 [+] Bun adapter 2025-11-25 03:59:01 +08:00
azalea a6bb5e37fd Update lyrics.ts 2025-11-25 03:53:25 +08:00
azalea d8b1ec0456 Update lyrics.ts 2025-11-25 03:49:01 +08:00
azalea 9d0f99c368 [F] Fix warnings 2025-11-25 03:13:30 +08:00
azalea 07da64f31a [F] Fix warnings 2025-11-25 03:00:11 +08:00
azalea 6c44a3650c [+] Dockerfile deploy 2025-11-25 01:59:12 +08:00
azalea 229f1282db [O] Correctly split dev & non-dev dependencies 2025-11-25 01:16:52 +08:00
azalea 8ce2089eef [O] tab > spaces 2025-11-25 01:15:53 +08:00
azalea ec6583f9dc Update README.md 2025-11-25 01:13:59 +08:00
azalea 676d91e2b4 Merge branch 'main' of https://github.com/MaigoLabs/amaoke.app 2025-11-25 01:12:28 +08:00
azalea 5cc48926c2 [+] About page 2025-11-25 01:12:24 +08:00
azalea 866a868116 Update README.md 2025-11-23 22:48:29 +08:00
azalea 5e68d87be3 Update README.md 2025-11-23 22:45:26 +08:00
azalea 1ad51f36a5 Update README.md 2025-11-23 22:34:09 +08:00
azalea deb136e30f Update README.md 2025-11-23 22:26:24 +08:00
azalea 539c82b1a9 [M] Rename project 2025-11-23 20:00:31 +08:00
azalea a5596fb4ff [+] i18n for progress 2025-11-23 15:18:29 +08:00
azalea 51b2335db7 Update README.md 2025-11-23 15:09:16 +08:00
azalea 6f7df5361b [O] More i18n 2025-11-23 15:07:07 +08:00
azalea a45316bd16 Update zh.ts 2025-11-23 15:02:18 +08:00
azalea f1ef175700 Update +page.svelte 2025-11-23 14:59:17 +08:00
azalea aa8d61a8ac [O] Auto prepare next song 2025-11-23 14:58:12 +08:00
azalea 2927243237 [F] Fix next song 2025-11-23 14:57:00 +08:00
azalea 2febbea6ec [O] Parallelize song preparation 2025-11-23 14:41:22 +08:00
azalea 93b674942d [F] Fix lyrics out of sync when using speed change 2025-11-23 14:37:39 +08:00
azalea cb797cbb61 Update README.md 2025-11-23 14:31:32 +08:00
azalea 2f4f8f0d48 [+] Pause/play button 2025-11-23 14:31:15 +08:00
azalea 1de19d82aa [F] Fix caret 2025-11-23 14:20:29 +08:00
azalea ffca84bf7b [O] Typing experience 2025-11-23 14:12:52 +08:00
azalea cec1e4a968 Update +page.svelte 2025-11-23 14:08:13 +08:00
azalea 03d8e2896b Update lyrics.ts 2025-11-23 14:02:28 +08:00
azalea 44c9b646ea Update +page.svelte 2025-11-23 14:01:07 +08:00
azalea 47557883bb [+] Remember vocals volume 2025-11-23 13:54:44 +08:00
azalea 656c81b82e [+] Speed control 2025-11-23 13:50:48 +08:00
azalea 53a4d6428e Merge branch 'feature#404-page' 2025-11-23 10:36:25 +08:00
azalea c5293f34b3 Update README.md 2025-11-23 10:36:14 +08:00
azalea 44d99dc8c1 [+] Update playlist option 2025-11-23 10:32:09 +08:00
azalea 3bb4ff8e9a Update README.md 2025-11-23 10:09:55 +08:00
azalea f6ba9a8897 [+] Next song button 2025-11-23 10:09:37 +08:00
azalea f70fd90032 Update README.md 2025-11-23 09:31:44 +08:00
azalea 3afb72538d Merge pull request #3 from hykilpikonna/feature#404-page
Feature#404 page
2025-11-23 09:30:34 +08:00
azalea 3d42b72c8b [+] i18n for error page 2025-11-23 09:29:37 +08:00
azalea d2a1de93ee [S] Use flexbox instead of position: absolute 2025-11-23 09:27:29 +08:00
azalea 8ebad5d32b [O] css > tailwind 2025-11-23 09:18:21 +08:00
azalea 11d20c560c [-] Revert unintended changes 2025-11-23 09:00:44 +08:00
Courier a3fbcbffb2 404 page init 2025-11-22 23:17:00 +04:00
Courier fd13846a5b 404 page init 2025-11-22 23:13:25 +04:00
Courier 1c2086ac42 404 page init 2025-11-22 23:11:48 +04:00
Courier 7445966a61 404 page ui 2025-11-22 23:04:51 +04:00
Courier 33e7db5642 lang var update, ui update 2025-11-22 22:35:21 +04:00
Courier b9fcfd41a5 Merge branch 'main' of https://github.com/hykilpikonna/KaraDash into feature#404-page 2025-11-22 22:02:53 +04:00
azalea 4f76bfc61f Update README.md 2025-11-23 02:02:38 +08:00
Courier ca5b68132c readme update, minor style fixes 2025-11-22 22:01:41 +04:00
azalea 957094bc2b Update README.md 2025-11-23 01:50:10 +08:00
azalea f3e1c802d7 Merge branch 'prep-screen-improvements' 2025-11-23 01:44:24 +08:00
azalea 9947b35829 Update README.md 2025-11-23 01:44:15 +08:00
azalea 37b0a9df46 Merge pull request #2 from hykilpikonna/prep-screen-improvements
Prep screen improvements
2025-11-23 01:26:08 +08:00
azalea 460566c792 Update README.md 2025-11-23 01:25:42 +08:00
azalea 3ae05f27da [+] Step-by-step buttons 2025-11-23 01:20:31 +08:00
azalea fc22edd3d9 [O] don't repeat code 2025-11-23 01:15:14 +08:00
azalea 36651c0f6a [+] Singing mode button 2025-11-23 01:12:33 +08:00
azalea 348f6ac699 Merge pull request #1 from hykilpikonna/i18n
I18n
2025-11-23 00:55:54 +08:00
59 changed files with 1102 additions and 290 deletions
+92 -10
View File
@@ -1,8 +1,17 @@
# KaraDash (IPR)
# アマオケ / amaoke.app
Practice Japanese Karaoke lyrics reading and typing at the same time with KaraDash!
Practice Japanese Karaoke lyrics reading and typing at the same time with amaoke.app!
在这里可以同时练习日语卡拉 OK 歌词阅读速度和打字速度
是一个日语卡拉 OK 阅读打字唱歌练习软件
<img width="30%" alt="image" src="https://github.com/user-attachments/assets/4f47bc67-56c9-4c4f-98e6-aff4d1886672" />
## 使用教程
用手机打开 [amaoke.app](https://amaoke.app) 就可以了!
(电脑上也可以用,虽然没有做视图适配所以可能有点怪)
## 主要功能
@@ -15,21 +24,94 @@ Practice Japanese Karaoke lyrics reading and typing at the same time with KaraDa
* [ ] 历史成绩和进步曲线
* [x] 唱歌模式
* [x] 自动分离人声和伴奏
* [ ] 分段处理以加快初始加载速度
* [x] 自动预处理下一首歌
* [x] 调节人声伴奏比例
* [x] 跟随音乐滚动歌词
* [ ] 升降调
* [ ] 播放/暂停音乐控制
* [ ] 变速
* [x] 播放/暂停音乐控制
* [x] 实时变调变速(速度越快音调就越高)
* [ ] 单独控制变调和变速(非实时)
* [ ] 音域分析(自动推荐升降调幅度)
* [ ] 电视模式
* [ ] 和手机配对、用手机点歌
* [ ] 从网易云搜索歌曲
## Technical Tasks
* [ ] i18n
* [ ] 404 page
* [ ] Update an existing playlist
* [x] i18n
* [x] 404 page
* [x] Previous song / next song buttons
* [x] Update an existing playlist
* [ ] Allow users to correct lyric pronunciations through correction feedback
* [ ] Correct lyrics timing inconsistencies (i.e. 网易云的歌词因为是业余用户上传的,时间戳不一定准确。但是 waveform 里面可以分析出每句歌词的具体开始结束时间,也许可以自动修正)
* [ ] Correct lyrics timing inconsistencies (i.e. 网易云的歌词因为是业余用户上传的,时间戳不一定准确。但是 waveform 里面可以分析出每段人声的具体开始结束时间,也许可以自动修正)
* [x] Processing lyrics and audio should be parallel
* [x] Add admin password to admin pages
* [x] About page
* [ ] Intro popup
* [ ] Re-encode songs using opus
* [x] Meta tags
* [ ] Allow deleting incorrect characters
## 自搭服务器文档 / Self-hosting Guide
**运行服务器需要的东西:**
1. 一个 openrouter.ai 的 API key
2. 一个网易云账号
3. docker
4. 一张 >2GB 显存的显卡(推荐 Nvidia,其他显卡需要改 Dockerfile
5. 小猫
**自搭教程**
1. 克隆仓库
```sh
git clone https://github.com/MaigoLabs/amaoke.app
cd amaoke.app/deploy
```
2. 创建一个叫 `.env` 的文件,把下面这些写进去
```.env
OPENROUTER_API_KEY="你的 openrouter.ai API key"
ADMIN_PASSWORD=一个随机管理密码
```
3. 运行
```sh
docker compose up -d
```
4. 去登录网易云账号
```
http://127.0.0.1:3000/admin/netease-login?pwd=你的管理密码
```
5. 让小猫喵喵
6. 完成了!
## 开发者文档 / Developer Guide
欢迎给这个项目贡献代码!想要贡献代码的话请参考 [README_DEVELOPER.md](README_DEVELOPER.md)
## 更新日志 / Changelog
### v1.0.3
* 修复了手机浏览器展开输入法会挡住视图和自动居中偏移的问题
* 修复了手机浏览器收缩地址栏之前最下面的按钮会看不到的问题
* 修复了 12 キー输入法输入平假名濁音会被判错的问题
* 修复了 12 キー输入法输入濁音时,如果输入完对应的清音之后并没有转换而是继续输入,不会被判错的问题
### v1.0.2
* 修复了 12 キー(日语九宫格)输入法输入某些正确的濁音(e.g. が)会被判错的问题
* 修复了 12 キー(日语九宫格)输入法输入不正确的濁音不会被判错的问题
### v1.0.1
* 修复了英文单词之间显示没有空格的问题
+25
View File
@@ -0,0 +1,25 @@
## Development server
### Requirements
- Bun
- Docker
### 1. Environment setup
1. Create your `.env` file by renaming `.env.example` to `.env`
2. Add the following variable:
OPENROUTER_API_KEY=your_key_here
(Request the key from the repository owner)
### 2. Start the database
Run in the project root:
docker compose up
### 3. Install dependencies
Install Bun:
https://bun.com/get
Then install project dependencies:
bun install
### 4. Start development server
bun run dev
+77 -27
View File
@@ -3,47 +3,51 @@
"configVersion": 1,
"workspaces": {
"": {
"name": "karadash",
"name": "amaoke",
"dependencies": {
"@fontsource/roboto": "^5.2.8",
"@iconify-json/material-symbols": "^1.2.46",
"@iconify-json/svg-spinners": "^1.2.4",
"@neteasecloudmusicapienhanced/api": "^4.29.17",
"@neteasecloudmusicapienhanced/api": "4.29.17",
"@openrouter/sdk": "^0.1.17",
"@unocss/core": "^66.5.6",
"@unocss/extractor-svelte": "^66.5.6",
"@unocss/preset-attributify": "^66.5.6",
"@unocss/preset-icons": "^66.5.6",
"@unocss/reset": "^66.5.6",
"chart.js": "^4.5.1",
"franc": "^6.2.0",
"m3-svelte": "^5.14.1",
"mongodb": "^7.0.0",
"openai": "^6.9.0",
"sass": "^1.94.0",
"scope-extensions-js": "^1.1.0",
"tone": "^15.3.7",
"unocss-preset-animations": "^1.3.0",
"wanakana": "^5.3.1",
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.38.0",
"@fontsource/darumadrop-one": "^5.2.8",
"@fontsource/roboto": "^5.2.8",
"@iconify-json/material-symbols": "^1.2.46",
"@iconify-json/mdi": "^1.2.3",
"@iconify-json/solar": "^1.2.5",
"@iconify-json/svg-spinners": "^1.2.4",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@types/bun": "^1.3.2",
"@types/node": "^24.10.1",
"@unocss/core": "^66.5.6",
"@unocss/extractor-svelte": "^66.5.6",
"@unocss/preset-attributify": "^66.5.6",
"@unocss/preset-icons": "^66.5.6",
"@unocss/preset-wind3": "^66.5.6",
"@unocss/reset": "^66.5.6",
"eslint": "^9.38.0",
"eslint-plugin-svelte": "^3.12.4",
"globals": "^16.4.0",
"sass": "^1.94.0",
"svelte": "^5.41.0",
"svelte-adapter-bun": "^1.0.1",
"svelte-check": "^4.3.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.1",
"unocss": "^66.5.6",
"unocss-preset-animations": "^1.3.0",
"vite": "^7.1.10",
"wanakana": "^5.3.1",
},
},
},
@@ -72,6 +76,12 @@
"@borewit/text-codec": ["@borewit/text-codec@0.2.0", "", {}, "sha512-X999CKBxGwX8wW+4gFibsbiNdwqmdQEXmUejIWaIqdrHBgS5ARIOOeyiQbHjP9G58xVEPcuvP6VwwH3A0OFTOA=="],
"@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
@@ -144,6 +154,8 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@fontsource/darumadrop-one": ["@fontsource/darumadrop-one@5.2.8", "", {}, "sha512-pN0RVFzd3cCpG3cQOeFLDZJ/2W/HMQR9KtCNhwHOMhEndsSiR1Dnk0e1qj/v8xzVTu+Q3/ZyTc0sYvIDj3z36A=="],
"@fontsource/roboto": ["@fontsource/roboto@5.2.8", "", {}, "sha512-oh9g4Cg3loVMz9MWeKWfDI+ooxxG1aRVetkiKIb2ESS2rrryGecQ/y4pAj4z5A5ebyw450dYRi/c4k/I3UBhHA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
@@ -156,6 +168,10 @@
"@iconify-json/material-symbols": ["@iconify-json/material-symbols@1.2.46", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-cNWdSAa5Z3f0TlqdCt28rmeYWGKwe68J1ORdyHyqC4D6H7CWiVKBJXV3TDTocOQVDO372bz+cmsFeo4+pbRy+A=="],
"@iconify-json/mdi": ["@iconify-json/mdi@1.2.3", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-O3cLwbDOK7NNDf2ihaQOH5F9JglnulNDFV7WprU2dSoZu3h3cWH//h74uQAB87brHmvFVxIOkuBX2sZSzYhScg=="],
"@iconify-json/solar": ["@iconify-json/solar@1.2.5", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-WMAiNwchU8zhfrySww6KQBRIBbsQ6SvgIu2yA+CHGyMima/0KQwT5MXogrZPJGoQF+1Ye3Qj6K+1CiyNn3YkoA=="],
"@iconify-json/svg-spinners": ["@iconify-json/svg-spinners@1.2.4", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-ayn0pogFPwJA1WFZpDnoq9/hjDxN+keeCMyThaX4d3gSJ3y0mdKUxIA/b1YXWGtY9wVtZmxwcvOIeEieG4+JNg=="],
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
@@ -180,6 +196,8 @@
"@mongodb-js/saslprep": ["@mongodb-js/saslprep@1.3.2", "", { "dependencies": { "sparse-bitfield": "^3.0.3" } }, "sha512-QgA5AySqB27cGTXBFmnpifAi7HxoGUeezwo6p9dI03MuDB6Pp33zgclqVb6oVK3j6I9Vesg0+oojW2XxB59SGg=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="],
"@neteasecloudmusicapienhanced/api": ["@neteasecloudmusicapienhanced/api@4.29.17", "", { "dependencies": { "@unblockneteasemusic/server": "^0.28.0", "axios": "^1.13.2", "crypto-js": "^4.2.0", "dotenv": "^17.2.3", "express": "^5.1.0", "express-fileupload": "^1.5.2", "md5": "^2.3.0", "music-metadata": "^11.10.0", "node-forge": "^1.3.1", "pac-proxy-agent": "^7.2.0", "qrcode": "^1.5.4", "safe-decode-uri-component": "^1.2.1", "tunnel": "^0.0.6", "xml2js": "^0.6.2", "yargs": "^18.0.0" }, "bin": { "api": "app.js" } }, "sha512-zKqmA7NoP+H3dK0b4/1K7SkxAYz69z9zwPd6+9wXcQNA42EO4AK/rDZ0ZPC9bonQjigHrvK4beFMaIl01S1iig=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
@@ -190,6 +208,10 @@
"@openrouter/sdk": ["@openrouter/sdk@0.1.17", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" }, "peerDependencies": { "@tanstack/react-query": "^5", "react": "^18 || ^19", "react-dom": "^18 || ^19" }, "optionalPeers": ["@tanstack/react-query", "react", "react-dom"] }, "sha512-RFN0sfe83G85MirfpkZSuoX8hLLucemnwqrTr53vlrJmBJZ244CCnuZ33vpVUI8rLg+hP1i/smW6IExzYRDGDg=="],
"@oxc-project/runtime": ["@oxc-project/runtime@0.71.0", "", {}, "sha512-QwoF5WUXIGFQ+hSxWEib4U/aeLoiDN9JlP18MnBgx9LLPRDfn1iICtcow7Jgey6HLH4XFceWXQD5WBJ39dyJcw=="],
"@oxc-project/types": ["@oxc-project/types@0.71.0", "", {}, "sha512-5CwQ4MI+P4MQbjLWXgNurA+igGwu/opNetIE13LBs9+V93R64MLvDKOOLZIXSzEfovU3Zef3q3GjPnMTgJTn2w=="],
"@parcel/watcher": ["@parcel/watcher@2.5.1", "", { "dependencies": { "detect-libc": "^1.0.3", "is-glob": "^4.0.3", "micromatch": "^4.0.5", "node-addon-api": "^7.0.0" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.1", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-freebsd-x64": "2.5.1", "@parcel/watcher-linux-arm-glibc": "2.5.1", "@parcel/watcher-linux-arm-musl": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", "@parcel/watcher-linux-arm64-musl": "2.5.1", "@parcel/watcher-linux-x64-glibc": "2.5.1", "@parcel/watcher-linux-x64-musl": "2.5.1", "@parcel/watcher-win32-arm64": "2.5.1", "@parcel/watcher-win32-ia32": "2.5.1", "@parcel/watcher-win32-x64": "2.5.1" } }, "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg=="],
"@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.1", "", { "os": "android", "cpu": "arm64" }, "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA=="],
@@ -222,6 +244,32 @@
"@quansync/fs": ["@quansync/fs@0.1.5", "", { "dependencies": { "quansync": "^0.2.11" } }, "sha512-lNS9hL2aS2NZgNW7BBj+6EBl4rOf8l+tQ0eRY6JWCI8jI2kc53gSoqbjojU0OnAWhzoXiOjFyGsHcDGePB3lhA=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-beta.9-commit.d91dfb5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Mp0/gqiPdepHjjVm7e0yL1acWvI0rJVVFQEADSezvAjon9sjQ7CEg9JnXICD4B1YrPmN9qV/e7cQZCp87tTV4w=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-beta.9-commit.d91dfb5", "", { "os": "darwin", "cpu": "x64" }, "sha512-40re4rMNrsi57oavRzIOpRGmg3QRlW6Ea8Q3znaqgOuJuKVrrm2bIQInTfkZJG7a4/5YMX7T951d0+toGLTdCA=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-beta.9-commit.d91dfb5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-8BDM939bbMariZupiHp3OmP5N+LXPT4mULA0hZjDaq970PCxv4krZOSMG+HkWUUwmuQROtV+/00xw39EO0P+8g=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.9-commit.d91dfb5", "", { "os": "linux", "cpu": "arm" }, "sha512-sntsPaPgrECpBB/+2xrQzVUt0r493TMPI+4kWRMhvMsmrxOqH1Ep5lM0Wua/ZdbfZNwm1aVa5pcESQfNfM4Fhw=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-beta.9-commit.d91dfb5", "", { "os": "linux", "cpu": "arm64" }, "sha512-5clBW/I+er9F2uM1OFjJFWX86y7Lcy0M+NqsN4s3o07W+8467Zk8oQa4B45vdaXoNUF/yqIAgKkA/OEdQDxZqA=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-beta.9-commit.d91dfb5", "", { "os": "linux", "cpu": "arm64" }, "sha512-wv+rnAfQDk9p/CheX8/Kmqk2o1WaFa4xhWI9gOyDMk/ljvOX0u0ubeM8nI1Qfox7Tnh71eV5AjzSePXUhFOyOg=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-beta.9-commit.d91dfb5", "", { "os": "linux", "cpu": "x64" }, "sha512-gxD0/xhU4Py47IH3bKZbWtvB99tMkUPGPJFRfSc5UB9Osoje0l0j1PPbxpUtXIELurYCqwLBKXIMTQGifox1BQ=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-beta.9-commit.d91dfb5", "", { "os": "linux", "cpu": "x64" }, "sha512-HotuVe3XUjDwqqEMbm3o3IRkP9gdm8raY/btd/6KE3JGLF/cv4+3ff1l6nOhAZI8wulWDPEXPtE7v+HQEaTXnA=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-beta.9-commit.d91dfb5", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.4" }, "cpu": "none" }, "sha512-8Cx+ucbd8n2dIr21FqBh6rUvTVL0uTgEtKR7l+MUZ5BgY4dFh1e4mPVX8oqmoYwOxBiXrsD2JIOCz4AyKLKxWA=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-beta.9-commit.d91dfb5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Vhq5vikrVDxAa75fxsyqj0c0Y/uti/TwshXI71Xb8IeUQJOBnmLUsn5dgYf5ljpYYkNa0z9BPAvUDIDMmyDi+w=="],
"@rolldown/binding-win32-ia32-msvc": ["@rolldown/binding-win32-ia32-msvc@1.0.0-beta.9-commit.d91dfb5", "", { "os": "win32", "cpu": "ia32" }, "sha512-lN7RIg9Iugn08zP2aZN9y/MIdG8iOOCE93M1UrFlrxMTqPf8X+fDzmR/OKhTSd1A2pYNipZHjyTcb5H8kyQSow=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-beta.9-commit.d91dfb5", "", { "os": "win32", "cpu": "x64" }, "sha512-7/7cLIn48Y+EpQ4CePvf8reFl63F15yPUlg4ZAhl+RXJIfydkdak1WD8Ir3AwAO+bJBXzrfNL+XQbxm0mcQZmw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.9-commit.d91dfb5", "", {}, "sha512-8sExkWRK+zVybw3+2/kBkYBFeLnEUWz1fT7BLHplpzmtqkOfTbAQ9gkt4pzwGIIZmg4Qn5US5ACjUBenrhezwQ=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.53.2", "", { "os": "android", "cpu": "arm" }, "sha512-yDPzwsgiFO26RJA4nZo8I+xqzh7sJTZIWQOxn+/XOdPE31lAvLIYCKqjV+lNH/vxE2L2iH3plKxDCRK6i+CwhA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.53.2", "", { "os": "android", "cpu": "arm64" }, "sha512-k8FontTxIE7b0/OGKeSN5B6j25EuppBcWM33Z19JoVT7UTXFSo3D9CdU39wGTeb29NO3XxpMNauh09B+Ibw+9g=="],
@@ -278,12 +326,14 @@
"@sveltejs/vite-plugin-svelte-inspector": ["@sveltejs/vite-plugin-svelte-inspector@5.0.1", "", { "dependencies": { "debug": "^4.4.1" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.3.1", "", { "dependencies": { "debug": "^4.4.1", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-4oeoZEBQdLdt5WmP/hx1KZ6D3/Oid/0cUb2nk4F0pTDAWy+KCH3/EnAkZF/bvckWo8I33EqBm01lIPgmgc8rCA=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
"@types/cookie": ["@types/cookie@0.6.0", "", {}, "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA=="],
@@ -386,6 +436,8 @@
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
@@ -464,7 +516,7 @@
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="],
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
@@ -594,11 +646,9 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"file-type": ["file-type@21.1.0", "", { "dependencies": { "@tokenizer/inflate": "^0.3.1", "strtok3": "^10.3.1", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-boU4EHmP3JXkwDo4uhyBhTt5pPstxB6eEXKJBu2yu2l7aAMMm7QQYQEzssJmKReZYrFdFOJS8koVo6bXIBGDqA=="],
"file-type": ["file-type@21.1.1", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.4.0" } }, "sha512-ifJXo8zUqbQ/bLbl9sFoqHNTNWbnPY1COImFfM6CCy7z+E+jC1eY9YfOKkx0fckIg+VljAy2/87T61fp0+eEkg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -614,7 +664,7 @@
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
@@ -654,7 +704,7 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
@@ -756,7 +806,7 @@
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
@@ -772,7 +822,7 @@
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"music-metadata": ["music-metadata@11.10.0", "", { "dependencies": { "@borewit/text-codec": "^0.2.0", "@tokenizer/token": "^0.3.0", "content-type": "^1.0.5", "debug": "^4.4.3", "file-type": "^21.0.0", "media-typer": "^1.1.0", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.5.0" } }, "sha512-alZYPjpqAPFgVZaFQob0PMq/9tSqaR+3m159vavrptxj09P0GcyBkDQI/wuCyn4uz/TDCrS8gN+9SzURlahmdQ=="],
"music-metadata": ["music-metadata@11.10.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.0", "@tokenizer/token": "^0.3.0", "content-type": "^1.0.5", "debug": "^4.4.3", "file-type": "^21.1.1", "media-typer": "^1.1.0", "strtok3": "^10.3.4", "token-types": "^6.1.1", "uint8array-extras": "^1.5.0" } }, "sha512-sGbF+Si+GqYJGO6qQDyfvesfxb1M49m0QjLLkGR5zoRlPaZtHRvo8DDI5R/vySJVtUzTQ6Lwfd7nspYpmmInsA=="],
"n-gram": ["n-gram@2.0.2", "", {}, "sha512-S24aGsn+HLBxUGVAUFOwGpKs7LBcG4RudKU//eWzt/mQ97/NMKQxDWHyHx63UNWk/OOdihgmzoETn1tf5nQDzQ=="],
@@ -884,7 +934,7 @@
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.1", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.7.0", "unpipe": "1.0.0" } }, "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
@@ -900,6 +950,8 @@
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rolldown": ["rolldown@1.0.0-beta.9-commit.d91dfb5", "", { "dependencies": { "@oxc-project/runtime": "0.71.0", "@oxc-project/types": "0.71.0", "@rolldown/pluginutils": "1.0.0-beta.9-commit.d91dfb5", "ansis": "^4.0.0" }, "optionalDependencies": { "@rolldown/binding-darwin-arm64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-darwin-x64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-freebsd-x64": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-linux-x64-musl": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-wasm32-wasi": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-ia32-msvc": "1.0.0-beta.9-commit.d91dfb5", "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.9-commit.d91dfb5" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-FHkj6gGEiEgmAXQchglofvUUdwj2Oiw603Rs+zgFAnn9Cb7T7z3fiaEc0DbN3ja4wYkW6sF2rzMEtC1V4BGx/g=="],
"rollup": ["rollup@4.53.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.2", "@rollup/rollup-android-arm64": "4.53.2", "@rollup/rollup-darwin-arm64": "4.53.2", "@rollup/rollup-darwin-x64": "4.53.2", "@rollup/rollup-freebsd-arm64": "4.53.2", "@rollup/rollup-freebsd-x64": "4.53.2", "@rollup/rollup-linux-arm-gnueabihf": "4.53.2", "@rollup/rollup-linux-arm-musleabihf": "4.53.2", "@rollup/rollup-linux-arm64-gnu": "4.53.2", "@rollup/rollup-linux-arm64-musl": "4.53.2", "@rollup/rollup-linux-loong64-gnu": "4.53.2", "@rollup/rollup-linux-ppc64-gnu": "4.53.2", "@rollup/rollup-linux-riscv64-gnu": "4.53.2", "@rollup/rollup-linux-riscv64-musl": "4.53.2", "@rollup/rollup-linux-s390x-gnu": "4.53.2", "@rollup/rollup-linux-x64-gnu": "4.53.2", "@rollup/rollup-linux-x64-musl": "4.53.2", "@rollup/rollup-openharmony-arm64": "4.53.2", "@rollup/rollup-win32-arm64-msvc": "4.53.2", "@rollup/rollup-win32-ia32-msvc": "4.53.2", "@rollup/rollup-win32-x64-gnu": "4.53.2", "@rollup/rollup-win32-x64-msvc": "4.53.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-MHngMYwGJVi6Fmnk6ISmnk7JAHRNF0UkuucA0CUW3N3a4KnONPEZz+vUanQP/ZC/iY1Qkf3bwPWzyY84wEks1g=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
@@ -986,6 +1038,8 @@
"svelte": ["svelte@5.43.6", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/estree": "^1.0.5", "acorn": "^8.12.1", "aria-query": "^5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "esm-env": "^1.2.1", "esrap": "^2.1.0", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-RnyO9VXI85Bmsf4b8AuQFBKFYL3LKUl+ZrifOjvlrQoboAROj5IITVLK1yOXBjwUWUn2BI5cfmurktgCzuZ5QA=="],
"svelte-adapter-bun": ["svelte-adapter-bun@1.0.1", "", { "dependencies": { "rolldown": "^1.0.0-beta.38" }, "peerDependencies": { "@sveltejs/kit": "^2.4.0", "typescript": "^5" } }, "sha512-tNOvfm8BGgG+rmEA7hkmqtq07v7zoo4skLQc+hIoQ79J+1fkEMpJEA2RzCIe3aPc8JdrsMJkv3mpiZPMsgahjA=="],
"svelte-check": ["svelte-check@4.3.4", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "chokidar": "^4.0.1", "fdir": "^6.2.0", "picocolors": "^1.0.0", "sade": "^1.7.4" }, "peerDependencies": { "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": ">=5.0.0" }, "bin": { "svelte-check": "bin/svelte-check" } }, "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw=="],
"svelte-eslint-parser": ["svelte-eslint-parser@1.4.0", "", { "dependencies": { "eslint-scope": "^8.2.0", "eslint-visitor-keys": "^4.0.0", "espree": "^10.0.0", "postcss": "^8.4.49", "postcss-scss": "^4.0.9", "postcss-selector-parser": "^7.0.0" }, "peerDependencies": { "svelte": "^3.37.0 || ^4.0.0 || ^5.0.0" }, "optionalPeers": ["svelte"] }, "sha512-fjPzOfipR5S7gQ/JvI9r2H8y9gMGXO3JtmrylHLLyahEMquXI0lrebcjT+9/hNgDej0H7abTyox5HpHmW1PSWA=="],
@@ -1118,8 +1172,6 @@
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"http-errors/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="],
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
@@ -1198,8 +1250,6 @@
"qrcode/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"qrcode/yargs/yargs-parser/camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
"args/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.3", "", {}, "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw=="],
"node-windows/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
+10
View File
@@ -0,0 +1,10 @@
:80 {
handle_path /audio/* {
root * /srv/audio
file_server
}
handle {
reverse_proxy web:3000
}
}
+22
View File
@@ -0,0 +1,22 @@
FROM nvidia/cuda:12.1.1-cudnn8-runtime-ubuntu22.04
WORKDIR /app
# Install system dependencies
# ffmpeg is required for audio processing
# python3 and pip are required since we are using a base cuda image
RUN apt-get update && \
apt-get install -y ffmpeg python3 python3-pip && \
rm -rf /var/lib/apt/lists/*
# Install Python dependencies
# We install dependencies globally as this is a container
# Install audio-separator with GPU support
RUN pip3 install --no-cache-dir fastapi uvicorn[standard] audio-separator[gpu] python-multipart
# Copy the server script
COPY scripts/server.py .
EXPOSE 24801
CMD ["python3", "server.py"]
+27
View File
@@ -0,0 +1,27 @@
FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
# Build the application
RUN bun run build
################################################################################
# Production image
FROM oven/bun:1
WORKDIR /app
COPY --from=builder /app/build ./build
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
# Note: If using adapter-auto, ensure it resolves to a Node-compatible build.
# If you encounter issues, consider installing @sveltejs/adapter-node and updating svelte.config.js.
CMD ["bun", "./build/index.js"]
+69
View File
@@ -0,0 +1,69 @@
services:
web:
container_name: amaoke-web
build:
context: ..
dockerfile: deploy/Dockerfile.web
# ports:
# - "127.0.0.1:3000:3000"
volumes:
- ./data/storage:/app/static
environment:
- ORIGIN=http://localhost:3000
- MONGO_URL=mongodb://cat:meow@db:27017/amaoke?authSource=admin
- AUDIO_SEPARATOR_API=http://ai:24801
env_file: .env
depends_on:
- db
- ai
restart: unless-stopped
caddy:
image: caddy:alpine
container_name: amaoke-caddy
restart: unless-stopped
ports:
- "127.0.0.1:3000:80"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- ./data/storage/audio:/srv/audio
depends_on:
- web
ai:
container_name: amaoke-ai
build:
context: ..
dockerfile: deploy/Dockerfile.python
# ports:
# - "24801:24801"
env_file: .env
volumes:
- python_temp:/app/temp_audio
- python_cache:/root/.cache
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
restart: unless-stopped
db:
image: mongo:latest
restart: unless-stopped
container_name: amaoke-db
ports:
- "127.0.0.1:27017:27017"
environment:
MONGO_INITDB_ROOT_USERNAME: cat
MONGO_INITDB_ROOT_PASSWORD: meow
env_file: .env
volumes:
- mongodb_data:/data/db
volumes:
mongodb_data:
python_temp:
python_cache:
+59 -55
View File
@@ -1,56 +1,60 @@
{
"name": "karadash",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.38.0",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@types/bun": "^1.3.2",
"@types/node": "^24.10.1",
"@unocss/preset-wind3": "^66.5.6",
"eslint": "^9.38.0",
"eslint-plugin-svelte": "^3.12.4",
"globals": "^16.4.0",
"svelte": "^5.41.0",
"svelte-check": "^4.3.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.1",
"unocss": "^66.5.6",
"vite": "^7.1.10",
"wanakana": "^5.3.1"
},
"dependencies": {
"@fontsource/roboto": "^5.2.8",
"@iconify-json/material-symbols": "^1.2.46",
"@iconify-json/svg-spinners": "^1.2.4",
"@neteasecloudmusicapienhanced/api": "^4.29.17",
"@openrouter/sdk": "^0.1.17",
"@unocss/core": "^66.5.6",
"@unocss/extractor-svelte": "^66.5.6",
"@unocss/preset-attributify": "^66.5.6",
"@unocss/preset-icons": "^66.5.6",
"@unocss/reset": "^66.5.6",
"chart.js": "^4.5.1",
"franc": "^6.2.0",
"m3-svelte": "^5.14.1",
"mongodb": "^7.0.0",
"openai": "^6.9.0",
"sass": "^1.94.0",
"scope-extensions-js": "^1.1.0",
"tone": "^15.3.7",
"unocss-preset-animations": "^1.3.0"
}
}
"name": "amaoke",
"private": true,
"version": "1.0.3",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "eslint ."
},
"devDependencies": {
"@eslint/compat": "^1.4.0",
"@eslint/js": "^9.38.0",
"@fontsource/darumadrop-one": "^5.2.8",
"@fontsource/roboto": "^5.2.8",
"@iconify-json/material-symbols": "^1.2.46",
"@iconify-json/mdi": "^1.2.3",
"@iconify-json/solar": "^1.2.5",
"@iconify-json/svg-spinners": "^1.2.4",
"@sveltejs/adapter-auto": "^7.0.0",
"@sveltejs/kit": "^2.47.1",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@types/bun": "^1.3.2",
"@types/node": "^24.10.1",
"@unocss/core": "^66.5.6",
"@unocss/extractor-svelte": "^66.5.6",
"@unocss/preset-attributify": "^66.5.6",
"@unocss/preset-icons": "^66.5.6",
"@unocss/preset-wind3": "^66.5.6",
"@unocss/reset": "^66.5.6",
"eslint": "^9.38.0",
"eslint-plugin-svelte": "^3.12.4",
"globals": "^16.4.0",
"sass": "^1.94.0",
"svelte": "^5.41.0",
"svelte-adapter-bun": "^1.0.1",
"svelte-check": "^4.3.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.1",
"unocss": "^66.5.6",
"unocss-preset-animations": "^1.3.0",
"vite": "^7.1.10"
},
"dependencies": {
"@neteasecloudmusicapienhanced/api": "4.29.17",
"@openrouter/sdk": "^0.1.17",
"mongodb": "^7.0.0",
"openai": "^6.9.0",
"chart.js": "^4.5.1",
"franc": "^6.2.0",
"m3-svelte": "^5.14.1",
"scope-extensions-js": "^1.1.0",
"tone": "^15.3.7",
"wanakana": "^5.3.1"
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ export const API = {
netease: {
startImport: async (link: string) => await post('/api/import/netease/start', { link }),
checkProgress: async (id: string) => await post('/api/import/netease/progress', { id }),
checkLogin: async () => await post('/admin/netease-login', {})
checkLogin: async (pwd?: string) => await post('/admin/netease-login', { pwd })
},
user: {
+42 -2
View File
@@ -37,6 +37,7 @@ export default {
tip: 'Go to NetEase Music App, find a Japanese playlist you like, click share, copy link, and paste it here to start importing!',
inputLabel: 'NetEase Playlist Link / ID',
btnStart: 'Start Import',
btnUpdate: 'Update Playlist',
btnView: 'View Playlist'
}
},
@@ -47,7 +48,8 @@ export default {
count: 'Songs: ',
startPractice: 'Start Practice',
songList: 'Song List',
songs: 'songs'
songs: 'songs',
updateFromNetease: 'Update from NetEase'
},
list: {
mine: 'My Playlists',
@@ -86,7 +88,8 @@ export default {
song: {
mode: {
typing: 'Typing Mode',
music: 'Music Mode'
music: 'Music Mode',
karaoke: 'Singing Mode'
},
karaoke: {
noVocals: 'No vocal separation track detected, cannot adjust vocal volume. Please process in song details page first.'
@@ -100,6 +103,13 @@ export default {
wrong: 'Wrong: ',
remaining: 'Remaining: '
}
},
prepare: {
lyrics: 'Fetching lyrics from NetEase',
ai: 'AI analyzing lyrics',
music: 'Fetching music from NetEase',
separation: 'AI vocal separation',
error: 'Error: '
}
},
user: {
@@ -126,5 +136,35 @@ export default {
loginWithCode: 'Login with Sync Code',
login: 'Login'
}
},
errorPage: {
title: 'Oops!',
message: 'The page youre looking for doesnt exist. It might have been removed, renamed, or never existed.\n\n </br></br> Go back to the <a class="error-page__link" href="/">homepage</a> to continue browsing',
return: 'Return home',
},
player: {
menu: {
showFuri: 'Show Furigana',
hideFuri: 'Hide Furigana',
revertHiragana: 'Revert to Hiragana',
convertToKatakana: 'Convert all to Katakana',
showRomaji: 'Show Romaji',
hideRomaji: 'Hide Romaji',
showRomajiOnError: 'Show Romaji on Error',
hideRomajiOnError: 'Don\'t Show Romaji on Error',
musicModeUnavailable: 'Not available in music mode',
showRepeated: 'Show Repeated Lines',
hideRepeated: 'Hide Repeated Lines',
shuffle: 'Current: Shuffle',
sequential: 'Current: Sequential',
nextSong: 'Next Song'
}
},
dialog: {
close: 'Close',
error: {
title: 'Error',
refresh: 'Refresh to Retry'
}
}
}
+5
View File
@@ -31,6 +31,11 @@ export const setLanguage = (lang: Lang) => {
location.reload()
}
export const useMsg = () => {
const i18n = getI18n()
return (key: string) => key.split('.').reduce((o, i) => (o as any)?.[i], i18n) as unknown as string
}
export {}
declare global {
+42 -2
View File
@@ -37,6 +37,7 @@ export default {
tip: 'NetEase Musicアプリでお気に入りの日本語プレイリストを見つけ、共有をクリックし、リンクをコピーしてここに貼り付けると、インポートを開始できます!',
inputLabel: 'NetEaseプレイリストリンク / ID',
btnStart: 'インポート開始',
btnUpdate: 'プレイリストを更新',
btnView: 'プレイリストを表示'
}
},
@@ -47,7 +48,8 @@ export default {
count: '曲数: ',
startPractice: '練習開始',
songList: '曲リスト',
songs: '曲'
songs: '曲',
updateFromNetease: 'NetEaseから更新'
},
list: {
mine: 'マイプレイリスト',
@@ -86,7 +88,8 @@ export default {
song: {
mode: {
typing: 'タイピングモード',
music: '音楽モード'
music: '音楽モード',
karaoke: '歌うモード'
},
karaoke: {
noVocals: 'ボーカル分離トラックが検出されないため、ボーカル音量を調整できません。まず曲の詳細ページで処理してください。'
@@ -100,6 +103,13 @@ export default {
wrong: '不正解:',
remaining: '残り:'
}
},
prepare: {
lyrics: 'NetEaseから歌詞を取得中',
ai: 'AIが歌詞を分析中',
music: 'NetEaseから音楽を取得中',
separation: 'AIボーカル分離',
error: 'エラー: '
}
},
user: {
@@ -126,5 +136,35 @@ export default {
loginWithCode: '引き継ぎコードでログイン',
login: 'ログイン'
}
},
errorPage: {
title: 'おっと!',
message: 'お探しのページは見つかりませんでした。削除されたか、名前が変更されたか、あるいは最初から存在しなかった可能性があります。\n\n </br></br> <a class="error-page__link" href="/">トップページ</a> に戻って閲覧を続けてください',
return: 'トップページに戻る',
},
player: {
menu: {
showFuri: 'ふりがなを表示',
hideFuri: 'ふりがなを隠す',
revertHiragana: 'ひらがなに戻す',
convertToKatakana: 'すべてカタカナに変換',
showRomaji: 'ローマ字を表示',
hideRomaji: 'ローマ字を隠す',
showRomajiOnError: 'エラー時にローマ字を表示',
hideRomajiOnError: 'エラー時にローマ字を表示しない',
musicModeUnavailable: '音楽モードでは利用できません',
showRepeated: '重複行を表示',
hideRepeated: '重複行を隠す',
shuffle: '現在:シャッフル再生',
sequential: '現在:順次再生',
nextSong: '次の曲'
}
},
dialog: {
close: '閉じる',
error: {
title: 'エラー',
refresh: '更新して再試行'
}
}
}
+42 -2
View File
@@ -37,6 +37,7 @@ export default {
tip: '去网易云 APP 找一个你喜欢的日本语歌单,点击分享,再点击复制链接,然后把链接粘贴到这里就可以开始导入了!',
inputLabel: '网易云歌单链接 / ID',
btnStart: '开始导入',
btnUpdate: '更新歌单',
btnView: '查看歌单'
}
},
@@ -47,7 +48,8 @@ export default {
count: '歌曲数: ',
startPractice: '开始练习',
songList: '歌曲列表',
songs: '首歌曲'
songs: '首歌曲',
updateFromNetease: '从网易云更新歌单'
},
list: {
mine: '我的歌单',
@@ -86,7 +88,8 @@ export default {
song: {
mode: {
typing: '打字模式',
music: '音乐模式'
music: '音乐模式',
karaoke: '唱歌模式'
},
karaoke: {
noVocals: '未检测到人声分离音轨,无法调节人声音量。请先在歌曲详情页进行处理。'
@@ -100,6 +103,13 @@ export default {
wrong: '错误:',
remaining: '剩余:'
}
},
prepare: {
lyrics: '从网易云获取歌词',
ai: 'AI 标注歌词读音',
music: '从网易云获取音乐',
separation: 'AI 人声分离',
error: '错误: '
}
},
user: {
@@ -126,5 +136,35 @@ export default {
loginWithCode: '用引继码登录',
login: '登录'
}
},
errorPage: {
title: '页面不存在',
message: '很抱歉,您访问的页面不存在。可能已被删除、更名,或链接输入错误。\n\n </br></br> 返回 <a class="error-page__link" href="/">首页</a> 继续浏览',
return: '返回首页',
},
player: {
menu: {
showFuri: '显示假名标注',
hideFuri: '隐藏假名标注',
revertHiragana: '恢复平假名',
convertToKatakana: '全部转换为片假名',
showRomaji: '显示罗马音',
hideRomaji: '隐藏罗马音',
showRomajiOnError: '错误时显示罗马音',
hideRomajiOnError: '不在错误时显示罗马音',
musicModeUnavailable: '音乐模式下不可用',
showRepeated: '显示重复行',
hideRepeated: '隐藏重复行',
shuffle: '当前:随机播放',
sequential: '当前:顺序播放',
nextSong: '下首'
}
},
dialog: {
close: '关闭',
error: {
title: '错误',
refresh: '刷新重试'
}
}
}
+2
View File
@@ -5,11 +5,13 @@ import { ObjectId } from "mongodb"
export async function saveResult(data: Omit<ResultDocument, "_id" | "createdAt">): Promise<string> {
const doc = { ...data, createdAt: new Date() }
const res = await dbs.results.insertOne(doc)
console.log(`Saved result ${res.insertedId} for user ${data.userId}`)
return res.insertedId.toString()
}
export async function getResult(id: string): Promise<ResultDocument | null> {
try {
console.log(`Getting result ${id}`)
return await dbs.results.findOne({ _id: new ObjectId(id) })
} catch {
return null
+15 -3
View File
@@ -12,7 +12,12 @@ const API_URL = process.env.AUDIO_SEPARATOR_API
export async function separateSong(inputPath: string, outputDir: string) {
const vocalsPath = path.join(outputDir, 'vocals.opus')
const instrumentalPath = path.join(outputDir, 'instrumental.opus')
if (await fs.exists(vocalsPath) && await fs.exists(instrumentalPath)) return
if (await fs.exists(vocalsPath) && await fs.exists(instrumentalPath)) {
console.log(`Separation already done for ${inputPath}`)
return
}
console.log(`Starting separation for ${inputPath}`)
// Read file and create FormData
const fileBuffer = await fs.readFile(inputPath)
@@ -40,6 +45,7 @@ export async function separateSong(inputPath: string, outputDir: string) {
await downloadStem('vocals', vocalsPath)
await downloadStem('instrumental', instrumentalPath)
console.log(`Separation completed for ${inputPath}`)
// Clean up task on server
try {
@@ -49,8 +55,14 @@ export async function separateSong(inputPath: string, outputDir: string) {
}
break
}
if (status.status === 'error') throw error(500, status.error || '分离失败')
if (status.status === 'not_found') throw error(500, '任务丢失')
if (status.status === 'error') {
console.error(`Separation failed for ${inputPath}: ${status.error}`)
throw error(500, status.error || '分离失败')
}
if (status.status === 'not_found') {
console.error(`Separation task lost for ${inputPath}`)
throw error(500, '任务丢失')
}
}
}
+27 -18
View File
@@ -50,6 +50,7 @@ function parsePlaylistRef(ref: string): number {
const getPlaylistRaw = cached(dbs.playlistsRaw,
async (id: number) => {
console.log(`Fetching playlist raw ${id}`)
const pl = ((await ne.playlist_detail({ id })).body as any).playlist
for (const track of pl.tracks)
await dbs.songsRaw.replaceOne({ _id: track.id }, { _id: track.id, data: track }, { upsert: true })
@@ -140,32 +141,33 @@ export const getSongUrl = async (id: number | string) => {
// /////////////////////////////////////////////////////////////////////////////
// API for Song Preparation
export interface ProgressItem { task: string, progress: number }
export interface ProgressItem { id: string, task: string, progress: number }
export interface SongProcessState { items: ProgressItem[], status: 'running' | 'done' | 'error' }
const songProcessingStatus = new Map<number, SongProcessState>()
export const getSongStatus = (songId: number) => songProcessingStatus.get(songId) || { items: [], status: 'idle' }
export const checkLyricsProcessed = async (songId: number) => !!await dbs.lyricsProcessed.findOne({ _id: songId as any })
export const prepareSong = async (songId: number) => {
if (songProcessingStatus.has(songId)) return
console.log(`Preparing song ${songId}`)
if (songProcessingStatus.has(songId)) {
console.log(`Song ${songId} is already being processed`)
return
}
const state: SongProcessState = { items: [], status: 'running' }
songProcessingStatus.set(songId, state)
const addTask = (task: string) => ({ task, progress: 0 }).also(it => state.items.push(it))
try {
const addTask = (id: string, task: string) => ({ id, task, progress: 0 }).also(it => state.items.push(it))
const processLyrics = async () => {
// 1. Get Lyrics
const taskLyrics = addTask('从网易云获取歌词')
const taskLyrics = addTask('lyrics', 'lyrics')
const raw = await getLyricsRaw(songId)
taskLyrics.progress = 1
if (raw.lang !== 'jpn') {
addTask('错误: 不是日语歌曲').progress = -1
return state.status = 'error'
}
if (raw.lang !== 'jpn') throw new Error('不是日语歌曲')
// 2. AI Process
const taskAI = addTask('AI 标注歌词读音')
const taskAI = addTask('ai', 'ai')
// Check cache
if (await checkLyricsProcessed(songId)) taskAI.progress = 1
@@ -174,14 +176,16 @@ export const prepareSong = async (songId: number) => {
await dbs.lyricsProcessed.replaceOne({ _id: songId as any }, { _id: songId, data: lrc }, { upsert: true })
taskAI.progress = 1
}
}
const processMusic = async () => {
// 3. Audio
const taskAudio = addTask('从网易云获取音乐')
const taskAudio = addTask('music', 'music')
await getSongUrl(songId)
taskAudio.progress = 1
// 4. Source Separation
const taskSeparation = addTask('AI 人声分离')
const taskSeparation = addTask('separation', 'separation')
const inputPath = path.join(CACHE_DIR, `${songId}/exhigh.mp3`)
const outputDir = path.join(CACHE_DIR, `${songId}`)
@@ -189,14 +193,18 @@ export const prepareSong = async (songId: number) => {
await separateSong(inputPath, outputDir)
taskSeparation.progress = 1
} catch (e: any) {
addTask(`错误: ${e.message}`).progress = -1
addTask('error', e.message).progress = -1
// Don't fail the whole process, just this step
}
state.status = 'done'
}
try {
await Promise.all([processLyrics(), processMusic()])
state.status = 'done'
console.log(`Song ${songId} preparation done`)
} catch (e) {
addTask(`错误: ${eToString(e)}`).progress = -1
console.error(`Song ${songId} preparation failed`, e)
addTask('error', eToString(e)).progress = -1
state.status = 'error'
}
}
@@ -226,7 +234,7 @@ export const getSession = (id: string) => sessions.get(id)
* @returns Import session
*/
export async function startImport(link: string, userId?: number): Promise<ImportSession> {
const meta = await getPlaylistRaw(parsePlaylistRef(link))
const meta = await getPlaylistRaw(parsePlaylistRef(link), true)
const importId = crypto.randomUUID()
const session: ImportSession = {
@@ -260,7 +268,6 @@ async function processImport(session: ImportSession, data: any) {
data.tracks = (await Promise.all(session.songs.map(async item => {
try {
const lyrics = await getLyricsRaw(item.song.id)
console.log(`Song ${item.song.id} lang ${lyrics.lang}`)
if (lyrics.lang === 'jpn') {
item.status = 'success'
return item.song
@@ -297,6 +304,7 @@ export const listRecPlaylists = async () => {
const list = await dbs.playlists.find({
_id: { $in: defaultPlaylists }
} as any).map(it => it.data).toArray()
console.log(`Listing recommended playlists: ${list.length} found`)
return list.sort((a: any, b: any) => defaultPlaylists.indexOf(a.id) - defaultPlaylists.indexOf(b.id))
}
export const listMyPlaylists = async (user: UserDocument) => (await user.data.myPlaylists?.let(pl => dbs.playlists.find({
@@ -304,6 +312,7 @@ export const listMyPlaylists = async (user: UserDocument) => (await user.data.my
}).map(it => it.data).toArray())) ?? []
export const getPlaylist = async (playlistId: number | string) => {
console.log(`Getting playlist ${playlistId}`)
const plData = await dbs.playlists.findOne({ _id: +playlistId as any })
if (!plData) throw error(404, 'Playlist not found')
return plData.data
+4 -3
View File
@@ -1,11 +1,12 @@
import { OpenRouter } from '@openrouter/sdk'
import type { LyricLine, LyricSegment } from '../../types'
import { isKana, isKanji } from 'wanakana'
import { building } from '$app/environment'
// Please put OPENROUTER_API_KEY in your environment variables.
if (!process.env.OPENROUTER_API_KEY) throw new Error('Please set OPENROUTER_API_KEY in your environment variables.')
if (!building && !process.env.OPENROUTER_API_KEY) throw new Error('Please set OPENROUTER_API_KEY in your environment variables.')
const client = new OpenRouter({
apiKey: process.env.OPENROUTER_API_KEY!
apiKey: process.env.OPENROUTER_API_KEY ?? ""
})
const req = {
model: "openai/gpt-5-mini",
@@ -179,7 +180,7 @@ function parseFuriganaText(text: string): LyricLine[] {
else if (kanji && furigana) {
let splitIndex = -1
for (let i = kanji.length - 1; i >= 0; i--) {
if (!isKanji(kanji[i]) && !isKana(kanji[i]) && !('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'.includes(kanji[i]))) {
if (!isKanji(kanji[i]) && !('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890'.includes(kanji[i]))) {
splitIndex = i
break
}
+5 -1
View File
@@ -16,6 +16,7 @@ void users.createIndex({ syncCode: 1 }, { name: "users_sync_code_idx" })
export async function createUser(registUA: string): Promise<string> {
const ses = `${crypto.randomUUID()}-${Date.now().toString(36)}`
await users.insertOne({ registUA, createdAt: new Date(), sessions: [ses], data: {} })
console.log(`Created new user with session ${ses}`)
return ses
}
@@ -41,6 +42,7 @@ export async function createSyncCode(session: string): Promise<string> {
// Sync code is 4 * 5 numbers
const code = Array.from({ length: 4 }, () => Math.floor(Math.random() * 100000).toString().padStart(5, '0')).join('-')
await users.updateOne({ _id: user._id }, { $set: { syncCode: code, syncCodeCreated: new Date() } })
console.log(`Created sync code for user ${user._id}`)
return code
}
@@ -52,6 +54,7 @@ export async function createSyncCode(session: string): Promise<string> {
export async function updateUserData(user: UserDocument, data: Partial<UserData>): Promise<void> {
const newData = { ...(user.data || {}), ...data }
await users.updateOne({ _id: user._id }, { $set: { data: newData } })
console.log(`Updated user data for ${user._id}`)
}
/**
@@ -80,6 +83,7 @@ export async function loginWithSyncCode(code: string, newUA: string): Promise<st
$unset: { syncCode: "", syncCodeCreated: "" }
}
)
console.log(`User ${user._id} logged in with sync code`)
return ses
}
+4 -1
View File
@@ -85,9 +85,10 @@ export interface ResultDocument {
export const typingSettingsDefault = {
isFuri: true,
allKata: false,
showRomaji: true,
showRomaji: false,
showRomajiOnError: true,
hideRepeated: false,
ignoreEnglish: false,
};
export type TypingSettings = typeof typingSettingsDefault;
@@ -106,4 +107,6 @@ export interface UserData {
isFinished: boolean;
lastResultId: string | null;
};
vocalsVolume?: number;
}
+2 -2
View File
@@ -1,10 +1,10 @@
<script lang="ts">
import { Layer } from "m3-svelte";
let { icon, ...rest } = $props();
let { icon, size = 24, ...rest } = $props();
</script>
<button class="cbox size-48px relative rounded-8px" {...rest}>
<!-- <Layer/> -->
<span class="size-24px {icon}"></span>
<span class="{icon}" style="width: {size}px; height: {size}px"></span>
</button>
+4 -4
View File
@@ -27,14 +27,14 @@
<LinearProgress percent={percentage ?? 0}/>
<div class="vbox p-content scroll-here gap-8px">
<div class="vbox p-content scroll-here gap-8px overflow-x-hidden">
{#each items as item}
<div class="hbox gap-12px items-center h-40px">
<span class="{item.icon} text-xl"></span>
<div class="vbox">
<span class="m3-font-title-medium">{item.title}</span>
<div class="vbox min-w-0 flex-1">
<span class="m3-font-title-medium truncate">{item.title}</span>
{#if item.subtitle}
<span class="m3-font-body-small mfg-on-surface-variant">{item.subtitle}</span>
<span class="m3-font-body-small mfg-on-surface-variant truncate">{item.subtitle}</span>
{/if}
</div>
</div>
+17 -4
View File
@@ -5,22 +5,24 @@
interface Icon {
icon: string
size?: number
onclick: () => void
}
let { title, sub, account, right, children, moreIcon }: {
let { title, sub, account, right, children, moreIcon, gradient }: {
title?: string
sub?: string
account?: () => void
right?: Icon[]
children?: any
moreIcon?: string
gradient?: boolean
} = $props()
let showMenu = $state(false)
</script>
<div class="hbox h-64px">
<div class="hbox h-64px" class:appbar-gradient={gradient}>
{#if account}
<IconButton icon="i-material-symbols:account-circle" onclick={account} aria-label="Account" />
{:else}
@@ -34,7 +36,7 @@
</div>
{#each right as item}
<IconButton icon={item.icon} onclick={item.onclick} />
<IconButton icon={item.icon} onclick={item.onclick} size={item.size} />
{/each}
{#if children}
@@ -50,4 +52,15 @@
</Menu>
</div>
</div>
{/if}
{/if}
<style lang="sass">
.appbar-gradient
background: linear-gradient(180deg, rgba(var(--m3-scheme-surface) / 1), transparent)
z-index: 100
@media (prefers-color-scheme: dark)
.appbar-gradient
background: linear-gradient(180deg, rgba(var(--m3-scheme-surface) / 0.2), transparent)
// background: none
// mix-blend-mode: overlay
</style>
+17 -10
View File
@@ -17,7 +17,9 @@ export function processLrcLine(line: LyricSegment[]): ProcLrcLine {
// Fuzzy matching rules
const fuzzyMatch = [['わ', 'は'], ['を', 'お'], ['ず', 'づ'], ['が', 'は'],
['ぁ', 'あ'], ['ぃ', 'い'], ['ぅ', 'う'], ['ぇ', 'え'], ['ぉ', 'お'],
['ゃ', 'や'], ['ゅ', 'ゆ'], ['ょ', 'よ'], ['っ', 'つ']]
['ゃ', 'や'], ['ゅ', 'ゆ'], ['ょ', 'よ'], ['っ', 'つ'],
['た', 'だ'], ['て', 'で'], ['か', 'が'],
]
export function fuzzyEquals(kana1: string, kana2: string): string {
[kana1, kana2] = [toHiragana(kana1), toHiragana(kana2)]
if (kana1 === kana2) return 'right'
@@ -26,14 +28,17 @@ export function fuzzyEquals(kana1: string, kana2: string): string {
}
// List of characters need to be composed instead of directly typed
export const composeList = [
'っ', 'ゃ', '', '', '', '', '', '', '',
'が', 'ぎ', 'ぐ', '', '',
'ざ', 'じ', 'ず', 'ぜ', 'ぞ',
'だ', 'ぢ', 'づ', 'で', 'ど',
'ば', 'び', 'ぶ', 'べ', 'ぼ',
'ぱ', 'ぴ', 'ぷ', 'ぺ', ''
]
export const composeMap = new Map(Object.entries({
'っ': 'つ', 'ゃ': '', '': '', '': '', '': '',
'ぃ': 'い', 'ぅ': 'う', 'ぇ': 'え', '': '',
'が': 'か', 'ぎ': 'き', 'ぐ': 'く', 'げ': 'け', 'ご': 'こ',
'ざ': 'さ', 'じ': 'し', 'ず': 'す', 'ぜ': 'せ', 'ぞ': 'そ',
'だ': 'た', 'ぢ': 'ち', 'づ': 'つ', 'で': 'て', '': 'と',
'ば': 'は', 'び': 'ひ', 'ぶ': 'ふ', 'べ': 'へ', 'ぼ': 'ほ',
'ぱ': 'は', 'ぴ': 'ひ', 'ぷ': 'ふ', 'ぺ': 'へ', 'ぽ': 'ほ',
}))
export const composeList = Array.from(composeMap.keys())
/**
* Remove duplicate lyric lines based on their content.
@@ -47,4 +52,6 @@ export function dedupLines(lrc: LyricLine[], hide: boolean) {
seen.add(key);
return true;
});
}
}
export const isEnglish = (str: string | undefined) => str && /^[a-zA-Z\s.,'!?]+$/.test(str)
+12 -5
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { tick } from "svelte"
import { isKana, isKanji, toKatakana, toRomaji } from "wanakana"
import type { ProcLrcLine, ProcLrcSeg } from "./IMEHelper"
import { isEnglish, type ProcLrcLine, type ProcLrcSeg } from "./IMEHelper"
import type { TypingSettings } from "$lib/types"
import { animateCaret } from "./animation"
@@ -31,11 +31,15 @@
const _preprocessKana = (kana: string) => settings.allKata ? toKatakana(kana) : kana
const preprocessKana = (kana: string, state?: string) => (settings.showRomaji || (settings.showRomajiOnError && state === 'wrong')) ? `<ruby>${_preprocessKana(kana)}<rt>${toRomaji(kana)}</rt></ruby>` : _preprocessKana(kana)
const allStates = (l: number, seg: ProcLrcSeg) => states[l]?.slice(seg.swi, seg.swi + seg.kana.length) ?? []
const getKanjiState = (l: number, seg: ProcLrcSeg) => {
if (settings.ignoreEnglish && isEnglish(seg.kanji)) return 'ignored'
let sts = allStates(l, seg)
if (sts.every(s => s === 'right')) return 'right'
if (sts.some(s => s === 'wrong')) return 'wrong'
if (sts.some(s => s === 'fuzzy')) return 'fuzzy'
return 'typing'
}
@@ -67,14 +71,12 @@
<div bind:this={lrcWrapper} class="lrc-wrapper scroll-here" lang="ja-JP">
<div class="vbox gap-12px py-32px relative min-h-full lrc-content">
{#if showCaret}
<div bind:this={caret} class="absolute bg-amber w-2px h-24px transition-all duration-75 z-10"></div>
{/if}
<div bind:this={caret} class="absolute bg-amber w-2px h-24px transition-all duration-75 z-5" hidden={!showCaret}></div>
{#each lines as line, l}
<div class="lrc p-content text-center m3-font-body-large" class:active={l === currentLineIndex} role="button" tabindex="0"
onclick={() => onLineClick?.()}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onLineClick?.() } }}>
{#each line.parts as seg}
{#each line.parts as seg, i}
{#if !seg.kanji}
{#each seg.kana as char, c}
<span class="{states[l]?.[seg.swi + c] ?? ''}" class:here={l === currentLineIndex && currentWordIndex === seg.swi + c}
@@ -91,6 +93,9 @@
</rt>{/if}
</ruby>
{/if}
{#if line.parts[i+1] && (isEnglish(seg.kanji ?? seg.kana) || isEnglish(line.parts[i+1].kanji ?? line.parts[i+1].kana))}
&nbsp;
{/if}
{/each}
</div>
{/each}
@@ -126,4 +131,6 @@
color: #7b78c2
.punctuation
opacity: 0.5
.ignored
opacity: 0.5
</style>
+41 -2
View File
@@ -4,6 +4,7 @@ import type { LyricLine } from '$lib/types'
export class MusicControl {
player: Tone.Player
vocalsPlayer?: Tone.Player
speed: number = 1
lyrics: LyricLine[] = []
currentLineIndex: number = 0
checkInterval: any
@@ -12,7 +13,12 @@ export class MusicControl {
audioUrl: string
vocalsUrl?: string
private _lastSpeedChangeTransportTime: number = 0
private _accumulatedAudioTime: number = 0
constructor(audioUrl: string, vocalsUrl?: string) {
this.audioUrl = audioUrl
this.vocalsUrl = vocalsUrl
this.audioUrl = audioUrl
this.vocalsUrl = vocalsUrl
this.player = new Tone.Player(audioUrl).toDestination()
@@ -60,6 +66,10 @@ export class MusicControl {
await Promise.all(promises)
this.log('Audio loaded')
// Reset time tracking
this._lastSpeedChangeTransportTime = 0
this._accumulatedAudioTime = 0
// Sync player to transport and schedule start at 0
// We do this regardless of transport state to ensure it's scheduled
this.player.sync().start(0)
@@ -72,12 +82,29 @@ export class MusicControl {
this.startCheckLoop()
}
setSpeed(speed: number) {
// Calculate how much audio time has passed since the last speed change
const currentTransportTime = Tone.getTransport().seconds
const transportDelta = currentTransportTime - this._lastSpeedChangeTransportTime
this._accumulatedAudioTime += transportDelta * this.speed
this._lastSpeedChangeTransportTime = currentTransportTime
this.speed = speed
this.player.playbackRate = this.speed
if (this.vocalsPlayer) {
this.vocalsPlayer.playbackRate = this.speed
}
}
setVocalsVolume(db: number) {
if (this.vocalsPlayer) this.vocalsPlayer.volume.value = db
}
getTime() {
return Tone.getTransport().seconds
const currentTransportTime = Tone.getTransport().seconds
const transportDelta = currentTransportTime - this._lastSpeedChangeTransportTime
return this._accumulatedAudioTime + transportDelta * this.speed
}
startCheckLoop() {
@@ -91,7 +118,7 @@ export class MusicControl {
// In karaoke mode (dual tracks), we don't pause for typing
if (this.vocalsPlayer) return
const ct = Tone.getTransport().seconds
const ct = this.getTime()
const ni = this.currentLineIndex + 1
if (ni >= this.lyrics.length) return
const nt = this.parseTime(this.lyrics[ni].time)
@@ -121,4 +148,16 @@ export class MusicControl {
Tone.getTransport().stop()
Tone.getTransport().cancel()
}
togglePlay() {
if (Tone.getTransport().state === 'started') {
Tone.getTransport().pause()
} else {
Tone.getTransport().start()
}
}
get isPlaying() {
return Tone.getTransport().state === 'started'
}
}
+41 -9
View File
@@ -2,12 +2,18 @@
import AppBar from "$lib/ui/appbar/AppBar.svelte"
import MenuItem from "$lib/ui/material3/MenuItem.svelte"
import { artistAndAlbum } from "$lib/utils"
import type { TypingSettings, UserData, NeteaseSong } from "$lib/types"
import type { TypingSettings, UserData, NeteaseSong, NeteasePlaylist } from "$lib/types"
import { goto } from "$app/navigation"
import { API } from "$lib/client"
import { getNextSong, getNextLoc } from "./SongSwitching"
import { getI18n } from "$lib/i18n"
interface Props {
song: NeteaseSong
settings: TypingSettings
loc?: UserData['loc']
playlist?: NeteasePlaylist
showRomajiOnError?: boolean
disableHideRepeated?: boolean
isKaraoke?: boolean
@@ -17,30 +23,56 @@
song,
settings = $bindable(),
loc = $bindable(),
playlist,
showRomajiOnError = true,
disableHideRepeated = false,
isKaraoke = false
}: Props = $props()
const t = getI18n().player.menu
let isHideRepeated = $derived(settings.hideRepeated && !disableHideRepeated)
const nextSongId = $derived(getNextSong(playlist, loc))
async function handleNext() {
if (!loc || !playlist) return
if (nextSongId) {
const newLoc = getNextLoc(playlist, loc, nextSongId)
loc = newLoc // Update local state
await API.saveUserData({ loc })
goto(`/song/${nextSongId}`, { replaceState: true })
} else {
// Playlist finished
loc.isFinished = true
await API.saveUserData({ loc })
goto(`/playlist/${playlist.id}`)
}
}
</script>
<AppBar title={song.name} sub={artistAndAlbum(song)}>
<MenuItem textIcon="あ" onclick={() => settings.isFuri = !settings.isFuri}>{settings.isFuri ? "隐藏" : "显示"}假名标注</MenuItem>
<MenuItem textIcon="カ" onclick={() => settings.allKata = !settings.allKata}>{settings.allKata ? "恢复平假名" : "全部转换为片假名"}</MenuItem>
<MenuItem icon="i-material-symbols:language-japanese-kana-rounded" onclick={() => settings.showRomaji = !settings.showRomaji}>{settings.showRomaji ? "隐藏罗马音" : "显示罗马音"}</MenuItem>
<MenuItem textIcon="あ" onclick={() => settings.isFuri = !settings.isFuri}>{settings.isFuri ? t.hideFuri : t.showFuri}</MenuItem>
<MenuItem textIcon="カ" onclick={() => settings.allKata = !settings.allKata}>{settings.allKata ? t.revertHiragana : t.convertToKatakana}</MenuItem>
<MenuItem icon="i-material-symbols:language-japanese-kana-rounded" onclick={() => settings.showRomaji = !settings.showRomaji}>{settings.showRomaji ? t.hideRomaji : t.showRomaji}</MenuItem>
{#if showRomajiOnError}
<MenuItem icon="i-material-symbols:error-circle-rounded" onclick={() => settings.showRomajiOnError = !settings.showRomajiOnError}>{settings.showRomajiOnError ? "不在错误时显示罗马音" : "错误时显示罗马音"}</MenuItem>
<MenuItem icon="i-material-symbols:error-circle-rounded" onclick={() => settings.showRomajiOnError = !settings.showRomajiOnError}>{settings.showRomajiOnError ? t.hideRomajiOnError : t.showRomajiOnError}</MenuItem>
{/if}
<MenuItem icon="i-material-symbols:compress-rounded"
disabled={disableHideRepeated}
sub={disableHideRepeated ? "音乐模式下不可用" : ""}
onclick={() => settings.hideRepeated = !settings.hideRepeated}>{isHideRepeated ? "显示重复行" : "隐藏重复行"}</MenuItem>
sub={disableHideRepeated ? t.musicModeUnavailable : ""}
onclick={() => settings.hideRepeated = !settings.hideRepeated}>{isHideRepeated ? t.showRepeated : t.hideRepeated}</MenuItem>
{#if loc}
<MenuItem icon={loc.playMode === 'random' ? "i-material-symbols:shuffle-rounded" : "i-material-symbols:repeat-rounded"} onclick={() =>
loc.playMode = loc.playMode === 'random' ? 'sequential' : 'random'}>{loc.playMode === 'random' ? "当前:随机播放" : "当前:顺序播放"}</MenuItem>
<MenuItem icon={loc.playMode === 'random' ? "i-material-symbols:shuffle-rounded" : "i-material-symbols:repeat-rounded"}
onclick={() => loc!.playMode = loc!.playMode === 'random' ? 'sequential' : 'random'}>
{loc.playMode === 'random' ? t.shuffle : t.sequential}
</MenuItem>
{#if nextSongId}
<MenuItem icon="i-material-symbols:skip-next-rounded" onclick={handleNext}>{t.nextSong}</MenuItem>
{/if}
{/if}
</AppBar>
+29
View File
@@ -0,0 +1,29 @@
import type { NeteasePlaylist, UserData } from "$lib/types"
export function getNextSong(playlist?: NeteasePlaylist, loc?: NonNullable<UserData['loc']>) {
if (!playlist || !loc) return null
if (loc.playMode === 'random') {
const unplayed = playlist.tracks.filter(t => !loc.playedSongIds.includes(t.id))
if (unplayed.length > 0) {
return unplayed[Math.floor(Math.random() * unplayed.length)].id
}
} else {
const nextIndex = loc.currentSongIndex + 1
if (nextIndex < playlist.tracks.length) {
return playlist.tracks[nextIndex].id
}
}
return null
}
export function getNextLoc(playlist: NeteasePlaylist, loc: NonNullable<UserData['loc']>, nextSongId: number): NonNullable<UserData['loc']> {
const nextIndex = playlist.tracks.findIndex(t => t.id === nextSongId)
return {
...loc,
currentSongIndex: nextIndex,
isFinished: false,
playedSongIds: loc.playedSongIds.includes(nextSongId)
? loc.playedSongIds
: [...loc.playedSongIds, nextSongId]
}
}
+24
View File
@@ -0,0 +1,24 @@
import { API } from "$lib/client"
import { typingSettingsDefault, type TypingSettings, type UserData } from "$lib/types"
export class UserDataSync {
settings = $state<TypingSettings>(typingSettingsDefault)
loc = $state<UserData['loc']>(undefined)
constructor(data: any) {
this.settings = data.user.data?.typingSettings ?? typingSettingsDefault
this.loc = data.user.data.loc
$effect(() => {
data.user.data = data.user.data || {}
data.user.data.typingSettings = this.settings
API.saveUserData({ typingSettings: this.settings })
})
$effect(() => {
data.user.data = data.user.data || {}
data.user.data.loc = this.loc
API.saveUserData({ loc: this.loc })
})
}
}
+4 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { Layer } from "m3-svelte"
import { fade } from "svelte/transition"
import { getI18n } from "$lib/i18n"
let { open = $bindable(), ...p }: {
title: string,
@@ -13,8 +14,10 @@
noClose?: boolean
} = $props()
const t = getI18n().dialog
let buttons = $derived([...(p.buttons ?? []), ...(p.noClose ? [] : [{
text: '关闭', onclick: () => open = false
text: t.close, onclick: () => open = false
}])])
</script>
+5 -2
View File
@@ -1,15 +1,18 @@
<script lang="ts">
import Dialog from "./Dialog.svelte";
import { getI18n } from "$lib/i18n"
let p: {
error?: string
} = $props()
const t = getI18n().dialog.error
let open = $derived(!!p.error)
</script>
<Dialog title="错误" bind:open buttons={[{
text: "刷新重试",
<Dialog title={t.title} bind:open buttons={[{
text: t.refresh,
onclick: () => location.reload()
}]} noClose>
<div class="text-red-500">
+17
View File
@@ -0,0 +1,17 @@
<script>
import AppBar from "$lib/ui/appbar/AppBar.svelte";
import Button from "$lib/ui/Button.svelte";
import { getI18n } from "$lib/i18n";
const t = getI18n().errorPage;
</script>
<AppBar title={t.title} />
<div class="vbox p-content flex-1">
<p class="mt-12px scroll-here">{@html t.message}</p>
<div class="py-16px">
<a href="/">
<Button big>{t.return}</Button>
</a>
</div>
</div>
+23 -1
View File
@@ -3,6 +3,7 @@
import "@fontsource/roboto"
import 'virtual:uno.css'
import "../style/app.sass"
import "../style/app.scss"
import "../style/material.scss"
import '@unocss/reset/normalize.css'
import '@unocss/reset/tailwind-v4.css'
@@ -31,9 +32,30 @@
<svelte:head>
<link rel="icon" href={favicon} />
<!-- Primary Meta Tags -->
<title>🍬アマオケ🎤</title>
<meta name="title" content="🍬アマオケ🎤" />
<meta name="description" content="是一个日语卡拉 OK 阅读打字唱歌练习软件!" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://amaoke.app/" />
<meta property="og:title" content="🍬アマオケ🎤" />
<meta property="og:description" content="是一个日语卡拉 OK 阅读打字唱歌练习软件!" />
<meta property="og:image" content="https://amaoke.app/images/meta.png" />
<!-- X (Twitter) -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://amaoke.app/" />
<meta property="twitter:title" content="🍬アマオケ🎤" />
<meta property="twitter:description" content="是一个日语卡拉 OK 阅读打字唱歌练习软件!" />
<meta property="twitter:image" content="https://amaoke.app/images/meta.png" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, interactive-widget=resizes-content">
</svelte:head>
<div class="vbox h-screen min-h-screen box-border overflow-hidden relative">
<div id="layout-view" class="vbox box-border overflow-hidden relative max-w-1200px mx-auto">
{@render children()}
</div>
+4 -4
View File
@@ -7,20 +7,20 @@
import { Layer } from "m3-svelte"
import { goto } from "$app/navigation"
import { getI18n, setLanguage } from "$lib/i18n"
import MenuItem from "$lib/ui/material3/MenuItem.svelte";
import MenuItem from "$lib/ui/material3/MenuItem.svelte"
let { data }: PageProps = $props()
const t = getI18n().home
console.log(data.recPlaylists)
const loc = data.user.data.loc
const href = loc?.isFinished && loc?.lastResultId ? `/results/${loc.lastResultId}` : `/song/${data.last?.id}`
</script>
<AppBar account={() => goto('/user')} moreIcon="i-material-symbols:translate-rounded">
<AppBar account={() => goto('/user')} moreIcon="i-material-symbols:translate-rounded" right={[
{ icon: 'i-solar:cat-broken', size: 26, onclick: () => goto('/about') },
]}>
<MenuItem onclick={() => setLanguage('en')}>English</MenuItem>
<MenuItem onclick={() => setLanguage('zh')}>中文</MenuItem>
<MenuItem onclick={() => setLanguage('ja')}>日本語</MenuItem>
+112
View File
@@ -0,0 +1,112 @@
<script lang="ts">
import AppBar from "$lib/ui/appbar/AppBar.svelte"
import Button from "$lib/ui/Button.svelte"
import { version } from '$app/environment';
import "@fontsource/darumadrop-one"
// Generate 20 random lines of hiragana
const hiragana = "あいうえおかきくけこさしすせそたちつてとなにぬねのはひふへほまみむめもやゆよらりるれろわをん"
function genRandomLines() {
const lines = []
for (let i = 0; i < 20; i++) {
lines.push(hiragana.split('').sort(() => Math.random() - 0.5).join(''))
}
return lines
}
const randomLines = genRandomLines()
const spinningMics = 10
</script>
<AppBar title="About" right={[
{
icon: 'i-mdi:github',
onclick: () => window.open('https://github.com/MaigoLabs/amaoke.app', '_blank')
}
]} gradient></AppBar>
<!-- <div class="gradient2"></div> -->
<div class="cbox vbox overflow-hidden! absolute inset-0">
<div class="gradient1"></div>
<div class="gradient2"></div>
<div class="vbox justify-center items-center text-center relative z-10">
<div class="cbox absolute inset-0 z-5">
<div class="size-360px rounded-full opacity-70 gradient-center"></div>
</div>
<div class="z-10">
<div class="app-name text-32px">アマオケ</div>
<div class="m3-font-body-medium mfg-on-surface-variant">FOSS {version}</div>
<div class="m3-font-body-medium mfg-on-surface">
Made with ♥ and 🔮 by <br>
<a href="https://maigo.dev" target="_blank" class="mfg-primary">MaigoLabs</a> 2025
</div>
</div>
<div class="cbox absolute inset-0 z-5 spin-circle opacity-50 pointer-events-none">
{#each Array(spinningMics) as _, i}
<div class="size-300px rounded-full absolute" style:transform={`rotate(${i * 360 / spinningMics}deg)`}>
<div class="rotate--140 text-24px">🎤</div>
</div>
{/each}
</div>
</div>
<!-- <div class="h-64px w-full"> </div> -->
<div class="vbox absolute overflow-hidden">
{#each randomLines as line}
<div class="mfg-on-surface mix-blend-color-burn opacity-07 truncate text-48px rotate--15 pointer-events-none line-height-64px tracking-8px kana-line">{line}</div>
{/each}
</div>
</div>
<!-- <div class="p-content py-16px">
<Button big href="https://github.com/MaigoLabs/amaoke.app">GitHub</Button>
</div> -->
<style lang="sass">
// @import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400..700&family=Darumadrop+One&family=Hachi+Maru+Pop&family=Kiwi+Maru&family=Livvic:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,900&family=M+PLUS+Rounded+1c&family=Quicksand:wght@300..700&family=Rampart+One&family=Stick&family=Yuji+Boku&family=Zen+Maru+Gothic&display=swap')
.app-name
// font-family: "Yuji Boku"
font-family: "Darumadrop One"
font-size: 48px
.gradient1,.gradient2
--size: calc(max(100vw, 100vh) * 1.5)
position: absolute
width: 200vw
height: 200vh
border-radius: 50%
.gradient1
// Radial gradient from D9C5F7 100% to FFFFFF 0%
background: radial-gradient(50% 50%, rgba(217,197,247,1), rgba(255,255,255,0))
bottom: 0
left: 0
.gradient2
// Radial gradient from F7C5C5 100% to FFFFFF 0%
background: radial-gradient(50% 50%, rgba(247,197,197,1), rgba(255,255,255,0))
right: 0
top: 0
.gradient-center
background: radial-gradient(50% 50%, rgba(255,255,255,1), rgba(255,255,255,0))
@media (prefers-color-scheme: dark)
.gradient-center
background: radial-gradient(50% 50%, rgba(0,0,0,1), rgba(0,0,0,0))
opacity: 0.1
.kana-line
opacity: 0.2
.spin-circle
animation: spin 30s linear infinite
@keyframes spin
from
transform: rotate(360deg)
to
transform: rotate(0deg)
</style>
+3 -1
View File
@@ -9,6 +9,7 @@ This page is vibe-coded. It's not a part of the regular UI intended for users an
-->
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { page } from '$app/stores';
import { API } from '$lib/client';
import { fade, scale } from 'svelte/transition';
import AppBar from "$lib/ui/appbar/AppBar.svelte";
@@ -23,7 +24,8 @@ This page is vibe-coded. It's not a part of the regular UI intended for users an
async function check() {
try {
const res = await API.netease.checkLogin();
const pwd = $page.url.searchParams.get('pwd') ?? undefined;
const res = await API.netease.checkLogin(pwd);
if (res.code === 801) {
if (status !== 'waiting_scan') {
status = 'waiting_scan';
@@ -1,5 +1,6 @@
import * as ne from '@neteasecloudmusicapienhanced/api'
import { error, json } from '@sveltejs/kit'
import { env } from '$env/dynamic/private'
import { loginWithSyncCode } from '$lib/server/user'
import type { RequestHandler } from './$types'
import { db } from '$lib/server/db'
@@ -15,6 +16,11 @@ async function createQr() {
}
export const POST: RequestHandler = async ({ request, cookies }) => {
const { pwd } = await request.json().catch(() => ({}))
if (env.ADMIN_PASSWORD && pwd !== env.ADMIN_PASSWORD) {
throw error(403, 'Invalid password')
}
if (!globalSession.key) await createQr()
// Check key validity
+1
View File
@@ -7,6 +7,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (!code) throw error(400, 'Missing sync code')
const ua = request.headers.get('user-agent') || 'unknown'
console.log(`Login attempt with sync code from UA: ${ua}`)
const session = await loginWithSyncCode(code, ua)
// Set session cookie
@@ -7,7 +7,10 @@ export const POST: RequestHandler = async ({ request }) => {
if (!id) throw error(400, 'Import ID is required')
const session = getSession(id)
if (!session) throw error(404, 'Session not found')
if (!session) {
console.log(`API: Import session ${id} not found`)
throw error(404, 'Session not found')
}
return json(session);
};
@@ -11,6 +11,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
if (!user) throw error(401, 'Unauthorized');
try {
console.log(`API: Starting import for link ${link} by user ${user._id}`)
return json(await startImport(link, user._id))
} catch (e) {
console.error(e)
+1
View File
@@ -10,6 +10,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
data.userId = user._id
// Validate data here if needed
console.log(`API: Saving result for user ${user._id}`)
const id = await saveResult(data)
return json({ id })
} catch (e) {
@@ -3,6 +3,7 @@ import { prepareSong, getSongStatus } from '$lib/server/songs'
export async function POST({ params }) {
const songId = +params.id
console.log(`API: Requesting preparation for song ${songId}`)
prepareSong(songId) // Start in background
return json({ status: 'started' })
}
+1
View File
@@ -9,6 +9,7 @@ export const POST: RequestHandler = async ({ request, cookies }) => {
try {
const data = await request.json()
console.log(`API: Updating user data for ${user._id}`)
await updateUserData(user, data)
return json({ success: true })
} catch (e) {
+1
View File
@@ -7,5 +7,6 @@ export const POST: RequestHandler = async ({ cookies }) => {
if (!session) throw error(401, 'Unauthorized')
const code = await createSyncCode(session)
console.log(`API: Created sync code for session ${session}`)
return json({ code })
}
+4 -2
View File
@@ -7,10 +7,12 @@
import ErrorDialog from "$lib/ui/status/ErrorDialog.svelte"
import ProgressList from "$lib/ui/ProgressList.svelte"
import { getI18n } from "$lib/i18n"
import { page } from '$app/state'
const t = getI18n().import.netease
let link = $state('')
let link = $state(page.url.searchParams.get('id') ?? '')
let isUpdate = $derived(!!page.url.searchParams.get('id'))
interface SongImportStatus {
song: NeteaseSong
@@ -91,7 +93,7 @@
<div class="py-16px p-content">
{#if status === 'idle'}
<Button big icon="i-material-symbols:download" onclick={startImport}>{t.btnStart}</Button>
<Button big icon={isUpdate ? "i-material-symbols:sync" : "i-material-symbols:download"} onclick={startImport}>{isUpdate ? t.btnUpdate : t.btnStart}</Button>
{:else if status === 'success'}
<a href="/playlist/{id}">
<Button big icon="i-material-symbols:right-arrow">{t.btnView}</Button>
+5 -3
View File
@@ -6,6 +6,7 @@
import SongInfo from "$lib/ui/listitem/SongInfo.svelte"
import { API } from "$lib/client"
import { getI18n } from "$lib/i18n"
import MenuItem from "$lib/ui/material3/MenuItem.svelte";
const t = getI18n().playlist.detail
@@ -49,9 +50,10 @@
{
icon: isFavorite ? "i-material-symbols:bookmark-rounded" : "i-material-symbols:bookmark-add-outline-rounded",
onclick: toggleFavorite
},
{icon: "i-material-symbols:more-vert", onclick: () => alert('More clicked')}
]} />
}
]}>
<MenuItem icon="i-material-symbols:update" onclick={() => goto(`/import/netease?id=${meta.id}`)}>{t.updateFromNetease}</MenuItem>
</AppBar>
<div class="hbox px-16px py-8px gap-24px">
<img src="{meta.coverImgUrl}" alt="" class="size-128px rounded-16px">
+2 -1
View File
@@ -14,6 +14,7 @@ export const load: PageServerLoad = async ({ params, parent }) => {
result: structuredClone(result),
lrc: await getLyricsProcessed(result.songId),
song,
playlist: await user.data?.loc?.currentPlaylistId?.let(getPlaylist)
playlist: await user.data?.loc?.currentPlaylistId?.let(getPlaylist),
resultId: params.id
}
}
+7 -27
View File
@@ -8,6 +8,7 @@
import Chart from "chart.js/auto"
import { API } from "$lib/client"
import { getI18n } from "$lib/i18n"
import { getNextSong, getNextLoc } from "$lib/ui/player/SongSwitching"
const t = getI18n().results
@@ -95,45 +96,24 @@
})
// Playlist Navigation Logic
let nextSongId = $state<number | null>(null)
let isPlaylistFinished = $state(false)
const loc = data.user.data.loc
const playlist = data.playlist
let nextSongId = $state<number | null>(getNextSong(playlist, loc))
let isPlaylistFinished = $state(false)
// Check if this is the latest result for the current playlist session
const isCurrentResult = loc?.lastResultId === data.result._id
const isCurrentResult = loc?.lastResultId === data.resultId
// Compute next state immediately
if (playlist && loc && isCurrentResult) {
if (loc.playMode === 'random') {
const unplayed = playlist.tracks.filter((t: NeteaseSong) => !loc.playedSongIds.includes(t.id))
if (unplayed.length > 0) {
const nextSong = unplayed[Math.floor(Math.random() * unplayed.length)]
nextSongId = nextSong.id
} else isPlaylistFinished = true
} else {
const nextIndex = loc.currentSongIndex + 1
if (nextIndex < playlist.tracks.length) {
nextSongId = playlist.tracks[nextIndex].id
} else isPlaylistFinished = true
}
if (nextSongId === null) isPlaylistFinished = true
}
async function handleNext() {
if (nextSongId !== null) {
if (!data.user.data.loc || !data.playlist) return
const nextIndex = data.playlist.tracks.findIndex((t: NeteaseSong) => t.id === nextSongId)
const newLoc = {
...data.user.data.loc,
currentSongIndex: nextIndex,
isFinished: false
}
if (!newLoc.playedSongIds.includes(nextSongId)) {
newLoc.playedSongIds = [...newLoc.playedSongIds, nextSongId]
}
const newLoc = getNextLoc(data.playlist, data.user.data.loc, nextSongId)
data.user.data.loc = newLoc
await API.saveUserData({ loc: newLoc })
+10
View File
@@ -0,0 +1,10 @@
import { getSongRaw, getPlaylist } from "$lib/server/songs"
export const load = async ({ params, parent }) => {
const { user } = await parent()
const songId = +params.id
const song = await getSongRaw(songId)
const playlist = await user.data?.loc?.currentPlaylistId?.let(getPlaylist)
return { song, playlist }
}
-6
View File
@@ -1,6 +0,0 @@
import { getSongRaw } from "$lib/server/songs"
export const load = async ({ params }) => {
const song = await getSongRaw(+params.id)
return { song }
}
+58 -23
View File
@@ -2,16 +2,33 @@
import { API } from "$lib/client"
import { onMount } from "svelte"
import Button from "$lib/ui/Button.svelte"
import AppBar from "$lib/ui/appbar/AppBar.svelte"
import PlayerAppBar from "$lib/ui/player/PlayerAppBar.svelte"
import ProgressList from "$lib/ui/ProgressList.svelte"
import { goto } from "$app/navigation"
import { artistAndAlbum } from "$lib/utils"
import { getI18n } from "$lib/i18n"
import { getI18n, useMsg } from "$lib/i18n"
import { getNextSong } from "$lib/ui/player/SongSwitching.js"
import { UserDataSync } from "$lib/ui/player/state.svelte"
const t = getI18n().song.mode
const getMsg = useMsg()
let { data } = $props()
let loadStatus = $state<"idle" | "loading" | "done">("idle")
const ud = new UserDataSync(data)
let taskStatus = $state({
lyrics: false,
ai: false,
music: false,
separation: false
})
let modes = $derived([
{ icon: "i-material-symbols:keyboard-rounded", label: t.typing, url: `/song/${data.song.id}/play`, disabled: !taskStatus.ai },
{ icon: "i-material-symbols:music-note-rounded", label: t.music, url: `/song/${data.song.id}/play?music=true`, disabled: !taskStatus.ai || !taskStatus.music },
{ icon: "i-material-symbols:mic-rounded", label: t.karaoke, url: `/song/${data.song.id}/karaoke`, disabled: !taskStatus.ai || !taskStatus.separation },
])
let progressItems = $state<any[]>([])
let progressPercentage = $state(0)
@@ -20,42 +37,60 @@
})
async function startLoading() {
loadStatus = "loading"
await API.song.prepare(data.song.id)
// Auto prepare next song
const nextSongId = getNextSong(data.playlist, data.user.data.loc)
if (nextSongId) await API.song.prepare(nextSongId)
const interval = setInterval(async () => {
const res = await API.song.status(data.song.id)
const state = res.status
if (state && state.items) {
progressItems = state.items.map((item: any) => ({
title: item.task + (item.progress > 0 && item.progress < 1 ? ` (${Math.round(item.progress * 100)}%)` : ''),
icon: item.progress === 1 ? 'i-material-symbols:check text-green-500' :
item.progress === -1 ? 'i-material-symbols:error text-red-500' :
'i-material-symbols:sync animate-spin'
}))
const totalProgress = state.items.reduce((acc: number, cur: any) => acc + Math.max(0, cur.progress), 0)
progressPercentage = Math.min(100, Math.round((totalProgress / 4) * 100))
// Update task status
for (const item of state.items) {
if (item.progress !== 1) continue
if (item.id === 'lyrics') taskStatus.lyrics = true
if (item.id === 'ai') taskStatus.ai = true
if (item.id === 'music') taskStatus.music = true
if (item.id === 'separation') taskStatus.separation = true
}
progressItems = state.items.map((item: any) => {
let taskName = getMsg(`song.prepare.${item.id}`) || item.task
if (item.id === 'error') {
taskName = getMsg('song.prepare.error') + item.task
}
return {
title: taskName + (item.progress > 0 && item.progress < 1 ? ` (${Math.round(item.progress * 100)}%)` : ''),
icon: item.progress === 1 ? 'i-material-symbols:check text-green-500' :
item.progress === -1 ? 'i-material-symbols:error text-red-500' :
'i-material-symbols:sync animate-spin'
}
})
const totalProgress = state.items.reduce((acc: number, cur: any) => acc + Math.max(0, cur.progress), 0)
progressPercentage = Math.min(100, Math.round((totalProgress / 4) * 100))
}
if (state.status === "done") {
clearInterval(interval)
loadStatus = "done"
progressPercentage = 100
} else if (state.status === "error") {
clearInterval(interval)
clearInterval(interval)
}
}, 1000)
}
</script>
<AppBar title={data.song.name} sub={artistAndAlbum(data.song)} />
<PlayerAppBar song={data.song} bind:settings={ud.settings} bind:loc={ud.loc} playlist={data.playlist} />
<ProgressList percentage={progressPercentage} items={progressItems} />
{#if loadStatus === "done"}
<div class="hbox gap-4 p-16px">
<Button big icon="i-material-symbols:keyboard-rounded" onclick={() => goto(`/song/${data.song.id}/play`)}>{t.typing}</Button>
<Button big icon="i-material-symbols:music-note-rounded" onclick={() => goto(`/song/${data.song.id}/play?music=true`)}>{t.music}</Button>
</div>
{/if}
<div class="hbox gap-4 p-16px flex-wrap">
{#each modes as mode}
<Button big icon={mode.icon} onclick={() => goto(mode.url)} disabled={mode.disabled} class="!w-auto !min-w-[calc(50%-8px)] grow disabled:opacity-50 disabled:cursor-not-allowed">{mode.label}</Button>
{/each}
</div>
+2 -3
View File
@@ -1,10 +1,9 @@
import type { PageServerLoad } from './$types'
import { getLyricsProcessed, getSongRaw, getSongUrl, checkLyricsProcessed } from "$lib/server/songs.ts"
import { getLyricsProcessed, getSongUrl, checkLyricsProcessed } from "$lib/server/songs.ts"
import { redirect } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ params }) => {
const songId = +params.id
const song = await getSongRaw(songId)
const hasLrc = await checkLyricsProcessed(songId)
if (!hasLrc) throw redirect(302, `/song/${songId}`)
@@ -12,5 +11,5 @@ export const load: PageServerLoad = async ({ params }) => {
const lrc = await getLyricsProcessed(songId)!
const audioData = await getSongUrl(songId)
return { song, lrc, audioData }
return { lrc, audioData }
}
+31 -9
View File
@@ -1,41 +1,47 @@
<script lang="ts">
import type { PageProps } from "./$types"
import { LinearProgress } from "m3-svelte"
import { onMount } from "svelte"
import { typingSettingsDefault } from "$lib/types"
import { processLrcLine, dedupLines, type ProcLrcLine } from "$lib/ui/player/IMEHelper"
import "$lib/ext.ts"
import { API } from "$lib/client"
import { MusicControl } from "$lib/ui/player/MusicControl"
import Lyrics from "$lib/ui/player/Lyrics.svelte"
import PlayerAppBar from "$lib/ui/player/PlayerAppBar.svelte"
import { UserDataSync } from "$lib/ui/player/state.svelte"
import { getI18n } from "$lib/i18n"
import { Layer } from "m3-svelte";
const t = getI18n().song.karaoke
let { data }: PageProps = $props()
let li = $state(0)
let settings = $state(data.user.data?.typingSettings ?? typingSettingsDefault)
$effect(() => { API.saveUserData({ typingSettings: settings }) })
const ud = new UserDataSync(data)
let vocalsVolume = $state(100) // 0-100
let vocalsVolume = $state(data.user.data.vocalsVolume ?? 100)
$effect(() => {
API.saveUserData({ vocalsVolume })
data.user.data.vocalsVolume = vocalsVolume
})
let speed = $state(1)
let isPlaying = $state(false)
// Process lyrics
const isHideRepeated = $derived(settings.hideRepeated)
const isHideRepeated = $derived(ud.settings.hideRepeated)
let deduplicatedLyrics = $derived(dedupLines(data.lrc, isHideRepeated))
let processedLrc: ProcLrcLine[] = $derived(deduplicatedLyrics.map(line => processLrcLine(line.lyric)))
let musicControl = $state<MusicControl>()
$effect(() => { li; musicControl?.updateLine(li) })
// Volume control
// Volume, Speed control
$effect(() => {
if (musicControl) {
// Tone.js volume is in decibels. 0 is full, -Infinity is silent.
// Simple mapping: 100 -> 0, 0 -> -60 (or mute)
const db = vocalsVolume === 0 ? -Infinity : 20 * Math.log10(vocalsVolume / 100)
musicControl.setVocalsVolume(db)
musicControl.setSpeed(speed)
}
})
@@ -69,6 +75,7 @@
}
if (nextLi !== -1 && nextLi !== li) li = nextLi
isPlaying = musicControl.isPlaying
}, 100)
return () => {
@@ -80,7 +87,7 @@
<svelte:window onclick={() => musicControl?.ready()} onkeydown={() => musicControl?.ready()}/>
<PlayerAppBar song={data.song} bind:settings showRomajiOnError={false} isKaraoke={true} disableHideRepeated />
<PlayerAppBar song={data.song} bind:settings={ud.settings} bind:loc={ud.loc} showRomajiOnError={false} isKaraoke={true} disableHideRepeated playlist={data.playlist} />
<div class="vbox p-content py-4 gap-2 mfg-on-surface-variant">
{#if data.audioData.vocalsUrl}
@@ -94,6 +101,21 @@
{t.noVocals}
</div>
{/if}
<div class="hbox gap-4 items-center">
<div class="i-material-symbols:speed-rounded text-2xl" title="Speed"></div>
<input type="range" min="0.5" max="1.5" step="0.05" bind:value={speed} class="flex-1" />
<div class="w-12 text-right">{speed.toFixed(2)}x</div>
</div>
</div>
<Lyrics lines={processedLrc} currentLineIndex={li} {settings} {states} />
<Lyrics lines={processedLrc} currentLineIndex={li} settings={ud.settings} {states} />
<button
class="fixed bottom-6 right-6 z-5 size-64px cbox rounded-full surface-variant mbg-surface-container mfg-on-surface-variant shadow-lg hover:shadow-xl transition-all active:scale-90"
onclick={() => { musicControl?.togglePlay(); isPlaying = musicControl?.isPlaying ?? false }}
aria-label="Play/Pause"
>
<Layer />
<div class={isPlaying ? "i-material-symbols:pause-rounded text-3xl" : "i-material-symbols:play-arrow-rounded text-3xl"}></div>
</button>
+2 -3
View File
@@ -1,15 +1,14 @@
import type { PageServerLoad } from './$types'
import { getLyricsProcessed, getSongRaw, getSongUrl, checkLyricsProcessed } from "$lib/server/songs.ts"
import { getLyricsProcessed, getSongUrl, checkLyricsProcessed } from "$lib/server/songs.ts"
import { redirect } from '@sveltejs/kit'
export const load: PageServerLoad = async ({ params, url }) => {
const songId = +params.id
const song = await getSongRaw(songId)
const hasLrc = await checkLyricsProcessed(songId)
if (!hasLrc) throw redirect(302, `/song/${songId}`)
const lrc = await getLyricsProcessed(songId)!
const audioUrl = url.searchParams.get('music') === 'true' ? await getSongUrl(songId) : undefined
return { song, lrc, audioUrl }
return { lrc, audioUrl }
}
+22 -19
View File
@@ -4,15 +4,15 @@
import { onMount } from "svelte"
import { typingSettingsDefault } from "$lib/types.ts"
import { isKana, isKanji, toHiragana } from "wanakana"
import { composeList, fuzzyEquals, processLrcLine, dedupLines, type ProcLrcLine } from "$lib/ui/player/IMEHelper.ts"
import { composeList, fuzzyEquals, processLrcLine, dedupLines, isEnglish, type ProcLrcLine, composeMap } from "$lib/ui/player/IMEHelper.ts"
import "$lib/ext.ts"
import { API } from "$lib/client.ts"
import { goto } from '$app/navigation'
import { artistAndAlbum } from "$lib/utils.ts"
import { MusicControl } from "$lib/ui/player/MusicControl.ts"
import Lyrics from "$lib/ui/player/Lyrics.svelte"
import PlayerAppBar from "$lib/ui/player/PlayerAppBar.svelte"
import { getI18n } from "$lib/i18n"
import { UserDataSync } from "$lib/ui/player/state.svelte"
const t = getI18n().song.play
@@ -26,18 +26,14 @@
let inp = $state("")
// Settings stored in user data
let settings = $state(data.user.data?.typingSettings ?? typingSettingsDefault)
$effect(() => { API.saveUserData({ typingSettings: settings }) })
// Playlist location state
let loc = $state(data.user.data.loc)
$effect(() => { API.saveUserData({ loc }) })
const ud = new UserDataSync(data)
// Process each line into segments with swi (start word index) and kanji/kana
const isHideRepeated = $derived(settings.hideRepeated && !data.audioUrl)
const isHideRepeated = $derived(ud.settings.hideRepeated && !data.audioUrl)
let deduplicatedLyrics = $derived(dedupLines(data.lrc, isHideRepeated))
let processedLrc: ProcLrcLine[] = $derived(deduplicatedLyrics.map(line => processLrcLine(line.lyric)))
// State tracking for each kana character: UNSEEN, RIGHT, WRONG
// svelte-ignore state_referenced_locally
let states = $state(processedLrc.map(line => new Array(line.totalLen).fill('unseen')))
let musicControl: MusicControl | undefined
@@ -117,8 +113,13 @@
// Check if it matches current character
let { cLine, cSeg, exp } = findLoc()
let exph = toHiragana(exp)
let res = fuzzyEquals(char, exp)
if (res === 'wrong' && !imeUsed && !isComposed && composeList.includes(exp)) return // Need to compose, stop here
// Need to compose, stop here
if (res !== 'right' && !imeUsed && !isComposed
&& composeList.includes(exph) && composeMap.get(exph) === char
&& inp.length === 1
) return
states[li][wi] = res
// Record stats
@@ -133,7 +134,11 @@
}
// Check next expected character, if it's neither kana nor kanji, skip it
while (findLoc().let(({ exp, cLine }) => !isKana(exp) && !isKanji(exp) && incr(cLine))) {}
while (findLoc().let(({ exp, cLine, cSeg }) => {
const isPunctuation = !isKana(exp) && !isKanji(exp)
const isIgnoredEnglish = ud.settings.ignoreEnglish && isEnglish(cSeg.kanji)
return (isPunctuation || isIgnoredEnglish) && incr(cLine)
})) {}
}
$effect(() => inputChanged(inp, false))
@@ -146,10 +151,10 @@
totalTyped, totalRight, startTime, statsHistory
})
if (loc?.currentPlaylistId) {
loc.isFinished = true
loc.lastResultId = res.id
await API.saveUserData({ loc })
if (ud.loc) {
ud.loc.isFinished = true
ud.loc.lastResultId = res.id
await API.saveUserData({ loc: ud.loc })
}
goto(`/results/${res.id}`, { replaceState: true })
@@ -158,9 +163,7 @@
<svelte:window onclick={() => musicControl?.ready()} onkeydown={() => musicControl?.ready()}/>
<PlayerAppBar song={data.song} bind:settings bind:loc disableHideRepeated={!!data.audioUrl} />
<LinearProgress percent={progress} />
<PlayerAppBar song={data.song} bind:settings={ud.settings} bind:loc={ud.loc} disableHideRepeated={!!data.audioUrl} playlist={data.playlist} />
<LinearProgress percent={progress} />
@@ -182,4 +185,4 @@
</div>
<!-- Lines -->
<Lyrics lines={processedLrc} currentLineIndex={li} currentWordIndex={wi} {states} {settings} showCaret={true} onLineClick={() => hiddenInput.focus()} />
<Lyrics lines={processedLrc} currentLineIndex={li} currentWordIndex={wi} {states} settings={ud.settings} showCaret={true} onLineClick={() => hiddenInput.focus()} />
+4 -1
View File
@@ -10,6 +10,9 @@ body
margin: 0
background: rgb(var(--m3-scheme-background))
#layout-view
height: var(--svh)
.hbox
display: flex
flex-direction: row
@@ -37,4 +40,4 @@ body
align-self: stretch
overflow-y: auto
min-height: 0
flex: 1
flex: 1
+3
View File
@@ -0,0 +1,3 @@
:root { --svh: 100vh; --dvh: 100vh; --svw: 100vw; --dvw: 100vw; }
@supports (height: 100svh) { :root { --svh: 100svh; --svw: 100svw; } }
@supports (height: 100dvh) { :root { --dvh: 100dvh; --dvw: 100dvw; } }
Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

+5 -2
View File
@@ -1,5 +1,5 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
import adapter from "svelte-adapter-bun"
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
/** @type {import('@sveltejs/kit').Config} */
const config = {
@@ -8,6 +8,9 @@ const config = {
preprocess: vitePreprocess(),
kit: {
version: {
name: process.env.npm_package_version
},
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
+6 -20
View File
@@ -1,6 +1,5 @@
// https://github.com/Menci/Marina/blob/main/apps/web/uno.config.ts
import { createLocalFontProcessor } from '@unocss/preset-web-fonts/local';
import { defineConfig, presetWind3, presetAttributify, presetIcons, presetTypography, presetWebFonts, transformerDirectives } from 'unocss';
import { defineConfig, presetWind3, presetAttributify, presetIcons, presetTypography, transformerDirectives } from 'unocss';
import presetAnimations from 'unocss-preset-animations';
function createColorSchemeConfig(hueBaseVariable: string, hueOffset = 0) {
@@ -37,24 +36,7 @@ export default defineConfig({
presetTypography(),
presetIcons({
scale: 1.2,
warn: true,
}),
presetWebFonts({
fonts: {
sans: {
name: 'Normalized Quicksand',
provider: 'none',
},
mono: {
name: 'Maple Mono',
provider: 'fontsource',
},
},
processors: createLocalFontProcessor({
cacheDir: 'node_modules/.cache/unocss/fonts',
fontAssetsDir: 'public/assets/fonts/cache',
fontServeBaseUrl: '/assets/fonts/cache',
}),
warn: false,
}),
],
// By default, `.ts` and `.js` files are NOT extracted.
@@ -70,6 +52,10 @@ export default defineConfig({
'**/shadcn-ui/**/*.{vue,js,ts}',
'**/ui/**/*.{vue,js,ts}',
],
exclude: [
'src/lib/server/**/*',
'**/*.server.ts',
]
},
},
theme: {