Compare commits
26 Commits
i18n
...
feature#404-page
| Author | SHA1 | Date | |
|---|---|---|---|
| c5293f34b3 | |||
| 44d99dc8c1 | |||
| 3bb4ff8e9a | |||
| f6ba9a8897 | |||
| f70fd90032 | |||
| 3d42b72c8b | |||
| d2a1de93ee | |||
| 8ebad5d32b | |||
| 11d20c560c | |||
| a3fbcbffb2 | |||
| fd13846a5b | |||
| 1c2086ac42 | |||
| 7445966a61 | |||
| 33e7db5642 | |||
| b9fcfd41a5 | |||
| 4f76bfc61f | |||
| ca5b68132c | |||
| 957094bc2b | |||
| f3e1c802d7 | |||
| 9947b35829 | |||
| 37b0a9df46 | |||
| 460566c792 | |||
| 3ae05f27da | |||
| fc22edd3d9 | |||
| 36651c0f6a | |||
| 348f6ac699 |
@@ -15,6 +15,8 @@ Practice Japanese Karaoke lyrics reading and typing at the same time with KaraDa
|
||||
* [ ] 历史成绩和进步曲线
|
||||
* [x] 唱歌模式
|
||||
* [x] 自动分离人声和伴奏
|
||||
* [ ] 分段处理以加快初始加载速度
|
||||
* [ ] 自动预处理下一首歌
|
||||
* [x] 调节人声伴奏比例
|
||||
* [x] 跟随音乐滚动歌词
|
||||
* [ ] 升降调
|
||||
@@ -23,13 +25,40 @@ Practice Japanese Karaoke lyrics reading and typing at the same time with KaraDa
|
||||
* [ ] 音域分析(自动推荐升降调幅度)
|
||||
* [ ] 电视模式
|
||||
* [ ] 和手机配对、用手机点歌
|
||||
* [ ] 从网易云搜索歌曲
|
||||
|
||||
## 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 里面可以分析出每句歌词的具体开始结束时间,也许可以自动修正)
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"@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",
|
||||
@@ -278,7 +278,7 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -464,7 +464,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 +594,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 +612,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 +652,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 +754,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 +770,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 +882,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=="],
|
||||
|
||||
@@ -1118,8 +1116,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 +1194,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=="],
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@
|
||||
"@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",
|
||||
|
||||
+10
-2
@@ -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.'
|
||||
@@ -126,5 +129,10 @@ export default {
|
||||
loginWithCode: 'Login with Sync Code',
|
||||
login: 'Login'
|
||||
}
|
||||
},
|
||||
errorPage: {
|
||||
title: 'Oops!',
|
||||
message: 'The page you’re looking for doesn’t 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',
|
||||
}
|
||||
}
|
||||
+10
-2
@@ -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: 'ボーカル分離トラックが検出されないため、ボーカル音量を調整できません。まず曲の詳細ページで処理してください。'
|
||||
@@ -126,5 +129,10 @@ export default {
|
||||
loginWithCode: '引き継ぎコードでログイン',
|
||||
login: 'ログイン'
|
||||
}
|
||||
},
|
||||
errorPage: {
|
||||
title: 'おっと!',
|
||||
message: 'お探しのページは見つかりませんでした。削除されたか、名前が変更されたか、あるいは最初から存在しなかった可能性があります。\n\n </br></br> <a class="error-page__link" href="/">トップページ</a> に戻って閲覧を続けてください',
|
||||
return: 'トップページに戻る',
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -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: '未检测到人声分离音轨,无法调节人声音量。请先在歌曲详情页进行处理。'
|
||||
@@ -126,5 +129,10 @@ export default {
|
||||
loginWithCode: '用引继码登录',
|
||||
login: '登录'
|
||||
}
|
||||
},
|
||||
errorPage: {
|
||||
title: '哎呀!',
|
||||
message: '您寻找的页面不存在。它可能已被删除、重命名或从未存在过。\n\n </br></br> 返回 <a class="error-page__link" href="/">首页</a> 继续浏览',
|
||||
return: '返回首页',
|
||||
}
|
||||
}
|
||||
+10
-10
@@ -140,7 +140,7 @@ 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>()
|
||||
@@ -152,20 +152,20 @@ export const prepareSong = async (songId: number) => {
|
||||
const state: SongProcessState = { items: [], status: 'running' }
|
||||
songProcessingStatus.set(songId, state)
|
||||
|
||||
const addTask = (task: string) => ({ task, progress: 0 }).also(it => state.items.push(it))
|
||||
const addTask = (id: string, task: string) => ({ id, task, progress: 0 }).also(it => state.items.push(it))
|
||||
try {
|
||||
// 1. Get Lyrics
|
||||
const taskLyrics = addTask('从网易云获取歌词')
|
||||
const taskLyrics = addTask('lyrics', '从网易云获取歌词')
|
||||
const raw = await getLyricsRaw(songId)
|
||||
taskLyrics.progress = 1
|
||||
|
||||
if (raw.lang !== 'jpn') {
|
||||
addTask('错误: 不是日语歌曲').progress = -1
|
||||
addTask('error', '错误: 不是日语歌曲').progress = -1
|
||||
return state.status = 'error'
|
||||
}
|
||||
|
||||
// 2. AI Process
|
||||
const taskAI = addTask('AI 标注歌词读音')
|
||||
const taskAI = addTask('ai', 'AI 标注歌词读音')
|
||||
|
||||
// Check cache
|
||||
if (await checkLyricsProcessed(songId)) taskAI.progress = 1
|
||||
@@ -176,12 +176,12 @@ export const prepareSong = async (songId: number) => {
|
||||
}
|
||||
|
||||
// 3. Audio
|
||||
const taskAudio = addTask('从网易云获取音乐')
|
||||
const taskAudio = addTask('music', '从网易云获取音乐')
|
||||
await getSongUrl(songId)
|
||||
taskAudio.progress = 1
|
||||
|
||||
// 4. Source Separation
|
||||
const taskSeparation = addTask('AI 人声分离')
|
||||
const taskSeparation = addTask('separation', 'AI 人声分离')
|
||||
const inputPath = path.join(CACHE_DIR, `${songId}/exhigh.mp3`)
|
||||
const outputDir = path.join(CACHE_DIR, `${songId}`)
|
||||
|
||||
@@ -189,14 +189,14 @@ 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'
|
||||
|
||||
} catch (e) {
|
||||
addTask(`错误: ${eToString(e)}`).progress = -1
|
||||
addTask('error', `错误: ${eToString(e)}`).progress = -1
|
||||
state.status = 'error'
|
||||
}
|
||||
}
|
||||
@@ -226,7 +226,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 = {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
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"
|
||||
|
||||
interface Props {
|
||||
song: NeteaseSong
|
||||
settings: TypingSettings
|
||||
loc?: UserData['loc']
|
||||
playlist?: NeteasePlaylist
|
||||
showRomajiOnError?: boolean
|
||||
disableHideRepeated?: boolean
|
||||
isKaraoke?: boolean
|
||||
@@ -17,12 +21,30 @@
|
||||
song,
|
||||
settings = $bindable(),
|
||||
loc = $bindable(),
|
||||
playlist,
|
||||
showRomajiOnError = true,
|
||||
disableHideRepeated = false,
|
||||
isKaraoke = false
|
||||
}: Props = $props()
|
||||
|
||||
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)}>
|
||||
@@ -40,7 +62,13 @@
|
||||
onclick={() => settings.hideRepeated = !settings.hideRepeated}>{isHideRepeated ? "显示重复行" : "隐藏重复行"}</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' ? "当前:随机播放" : "当前:顺序播放"}
|
||||
</MenuItem>
|
||||
|
||||
{#if nextSongId}
|
||||
<MenuItem icon="i-material-symbols:skip-next-rounded" onclick={handleNext}>下首</MenuItem>
|
||||
{/if}
|
||||
{/if}
|
||||
</AppBar>
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -33,7 +33,7 @@
|
||||
<link rel="icon" href={favicon} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="vbox h-screen min-h-screen box-border overflow-hidden relative">
|
||||
<div class="vbox h-screen min-h-screen box-border overflow-hidden relative max-w-1200px mx-auto">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -105,35 +106,15 @@
|
||||
|
||||
// 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
|
||||
}
|
||||
nextSongId = getNextSong(playlist, loc)
|
||||
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 })
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { getSongRaw } from "$lib/server/songs"
|
||||
|
||||
export const load = async ({ params }) => {
|
||||
const song = await getSongRaw(+params.id)
|
||||
return { song }
|
||||
}
|
||||
@@ -2,16 +2,36 @@
|
||||
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 { typingSettingsDefault } from "$lib/types"
|
||||
|
||||
const t = getI18n().song.mode
|
||||
|
||||
let { data } = $props()
|
||||
let loadStatus = $state<"idle" | "loading" | "done">("idle")
|
||||
|
||||
let settings = $state(data.user.data?.typingSettings ?? typingSettingsDefault)
|
||||
$effect(() => { API.saveUserData({ typingSettings: settings }) })
|
||||
|
||||
let loc = $state(data.user.data.loc)
|
||||
$effect(() => { API.saveUserData({ loc }) })
|
||||
|
||||
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.lyrics || !taskStatus.ai },
|
||||
{ icon: "i-material-symbols:music-note-rounded", label: t.music, url: `/song/${data.song.id}/play?music=true`, disabled: !taskStatus.music },
|
||||
{ icon: "i-material-symbols:mic-rounded", label: t.karaoke, url: `/song/${data.song.id}/karaoke`, disabled: !taskStatus.separation },
|
||||
])
|
||||
|
||||
let progressItems = $state<any[]>([])
|
||||
let progressPercentage = $state(0)
|
||||
|
||||
@@ -20,13 +40,21 @@
|
||||
})
|
||||
|
||||
async function startLoading() {
|
||||
loadStatus = "loading"
|
||||
await API.song.prepare(data.song.id)
|
||||
const interval = setInterval(async () => {
|
||||
const res = await API.song.status(data.song.id)
|
||||
const state = res.status
|
||||
|
||||
if (state && state.items) {
|
||||
// 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) => ({
|
||||
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' :
|
||||
@@ -40,7 +68,6 @@
|
||||
|
||||
if (state.status === "done") {
|
||||
clearInterval(interval)
|
||||
loadStatus = "done"
|
||||
progressPercentage = 100
|
||||
} else if (state.status === "error") {
|
||||
clearInterval(interval)
|
||||
@@ -49,13 +76,12 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppBar title={data.song.name} sub={artistAndAlbum(data.song)} />
|
||||
<PlayerAppBar song={data.song} bind:settings bind: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>
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<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"
|
||||
@@ -19,6 +18,9 @@
|
||||
let settings = $state(data.user.data?.typingSettings ?? typingSettingsDefault)
|
||||
$effect(() => { API.saveUserData({ typingSettings: settings }) })
|
||||
|
||||
let loc = $state(data.user.data.loc)
|
||||
$effect(() => { API.saveUserData({ loc }) })
|
||||
|
||||
let vocalsVolume = $state(100) // 0-100
|
||||
|
||||
// Process lyrics
|
||||
@@ -80,7 +82,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 bind: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}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -8,7 +8,6 @@
|
||||
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"
|
||||
@@ -158,7 +157,7 @@
|
||||
|
||||
<svelte:window onclick={() => musicControl?.ready()} onkeydown={() => musicControl?.ready()}/>
|
||||
|
||||
<PlayerAppBar song={data.song} bind:settings bind:loc disableHideRepeated={!!data.audioUrl} />
|
||||
<PlayerAppBar song={data.song} bind:settings bind:loc disableHideRepeated={!!data.audioUrl} playlist={data.playlist} />
|
||||
|
||||
<LinearProgress percent={progress} />
|
||||
|
||||
|
||||
+1
-1
@@ -37,4 +37,4 @@ body
|
||||
align-self: stretch
|
||||
overflow-y: auto
|
||||
min-height: 0
|
||||
flex: 1
|
||||
flex: 1
|
||||
|
||||
Reference in New Issue
Block a user