Merge branch 'main' of https://github.com/hykilpikonna/KaraDash into feature#404-page

This commit is contained in:
Courier
2025-11-22 22:02:53 +04:00
23 changed files with 632 additions and 108 deletions
+1
View File
@@ -1,2 +1,3 @@
MONGO_URL="mongodb://cat:meow@localhost:27017/"
AUDIO_SEPARATOR_API="http://127.0.0.1:24801"
OPENROUTER_API_KEY=""
+5 -1
View File
@@ -15,6 +15,8 @@ Practice Japanese Karaoke lyrics reading and typing at the same time with KaraDa
* [ ] 历史成绩和进步曲线
* [x] 唱歌模式
* [x] 自动分离人声和伴奏
* [ ] 分段处理以加快初始加载速度
* [ ] 自动预处理下一首歌
* [x] 调节人声伴奏比例
* [x] 跟随音乐滚动歌词
* [ ] 升降调
@@ -23,11 +25,13 @@ Practice Japanese Karaoke lyrics reading and typing at the same time with KaraDa
* [ ] 音域分析(自动推荐升降调幅度)
* [ ] 电视模式
* [ ] 和手机配对、用手机点歌
* [ ] 从网易云搜索歌曲
## Technical Tasks
* [ ] 多语言支持
* [x] i18n
* [ ] 404 page
* [ ] Previous song / next song buttons
* [ ] Update an existing playlist
* [ ] Allow users to correct lyric pronunciations through correction feedback
* [ ] Correct lyrics timing inconsistencies (i.e. 网易云的歌词因为是业余用户上传的,时间戳不一定准确。但是 waveform 里面可以分析出每句歌词的具体开始结束时间,也许可以自动修正)
+9 -7
View File
@@ -1,13 +1,15 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
namespace App {
// interface Error {}
interface Locals {
lang: 'en' | 'zh' | 'ja'
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="zh-CN">
<html lang="%lang%">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
+24
View File
@@ -1,3 +1,27 @@
import { checkAudioSeparator } from '$lib/server/separator';
import type { Handle } from '@sveltejs/kit';
checkAudioSeparator().catch(e => console.error('Audio separator check failed:', e));
export const handle: Handle = async ({ event, resolve }) => {
const langCookie = event.cookies.get('lang');
if (langCookie === 'zh' || langCookie === 'en' || langCookie === 'ja') {
event.locals.lang = langCookie;
} else {
const acceptLanguage = event.request.headers.get('accept-language');
// Simple check: if zh is present, prefer it (unless en is weighted higher, but let's keep it simple for now)
// A better parser would parse q-values.
// For now, let's assume if 'zh' is in the header, the user likely understands Chinese.
if (acceptLanguage && acceptLanguage.includes('zh')) {
event.locals.lang = 'zh';
} else if (acceptLanguage && acceptLanguage.includes('ja')) {
event.locals.lang = 'ja';
} else {
event.locals.lang = 'en';
}
}
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%lang%', event.locals.lang)
});
};
+131
View File
@@ -0,0 +1,131 @@
export default {
home: {
titles: {
continue: 'Continue From Last Session',
history: 'History',
myPlaylists: 'My Playlists',
recPlaylists: 'Recommended Playlists'
},
btn: {
importFromNetease: 'Import from NetEase'
},
text: {
playlistCreatedBy: 'From {u}'
}
},
admin: {
neteaseLogin: {
title: 'NetEase Login',
scanTitle: 'Scan to Login',
scanTip: 'Please use NetEase Music App to scan',
generating: 'Generating QR Code...',
scanned: 'Scanned',
confirm: 'Please confirm login on your phone',
success: 'Login Successful',
errorPrefix: 'Error: '
}
},
import: {
netease: {
title: 'Import from NetEase',
status: {
importing: 'Importing',
success: 'Import Complete',
error: 'Import Failed'
},
songs: 'songs',
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',
btnView: 'View Playlist'
}
},
playlist: {
detail: {
title: 'Playlist Details',
creator: 'Creator: ',
count: 'Songs: ',
startPractice: 'Start Practice',
songList: 'Song List',
songs: 'songs'
},
list: {
mine: 'My Playlists',
rec: 'Recommended Playlists',
import: 'Import from NetEase',
created: 'Created by {u}',
count: '{n} songs'
}
},
results: {
title: 'Practice Results',
fields: {
speed: 'Speed',
accuracy: 'Accuracy',
realtime: 'Realtime Rate',
count: 'Count',
time: 'Time',
duration: 'Duration'
},
units: {
cpm: 'CPM',
percent: '%',
x: 'x',
char: 'chars'
},
chart: {
speed: 'Speed (CPM)',
accuracy: 'Accuracy (%)'
},
btn: {
next: 'Next Song',
back: 'Back to Playlist',
retry: 'Retry'
}
},
song: {
mode: {
typing: 'Typing 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.'
},
play: {
speed: 'Speed: ',
accuracy: 'Accuracy: ',
stats: {
right: 'Right: ',
fuzzy: 'Fuzzy: ',
wrong: 'Wrong: ',
remaining: 'Remaining: '
}
}
},
user: {
title: 'Account Management',
loginSuccess: {
title: 'Login Successful',
content: 'Login Successful!',
jump: 'Jump'
},
generateCode: {
title: 'Generate Sync Code',
copy: 'Copy',
success: 'Sync Code Generated! Code is: {code}',
expiry: 'This code will expire after use or in 7 days if unused.'
},
desc: {
intro: 'This App uses a sync code system like Japanese mobile games, no email/password registration needed.',
instruction: 'To login on another device, click "Generate Sync Code" then click "Login" on the target device.',
loginMode: 'You are in "Sync Login" page. Get a code from another device and enter it below to login.'
},
input: 'Enter Sync Code',
btn: {
generate: 'Generate Sync Code',
loginWithCode: 'Login with Sync Code',
login: 'Login'
}
}
}
+46
View File
@@ -0,0 +1,46 @@
import { getContext, setContext } from 'svelte'
import EN from "./en"
import ZH from "./zh"
import JA from "./ja"
// i18n related files:
// src\hooks.server.ts
// src\routes\+layout.server.ts
type Lang = 'en' | 'zh' | 'ja'
const msgs: Record<Lang, typeof ZH> = {
en: EN,
zh: ZH,
ja: JA
}
const I18N_KEY = Symbol('i18n')
export const initI18n = (lang: Lang) => {
setContext(I18N_KEY, msgs[lang])
}
export const getI18n = () => {
return getContext<typeof ZH>(I18N_KEY) || msgs['en']
}
export const setLanguage = (lang: Lang) => {
document.cookie = `lang=${lang}; path=/; max-age=31536000`
location.reload()
}
export {}
declare global {
interface String {
sed(variables: { [index: string]: any }): string
}
}
String.prototype.sed = function (variables: { [index: string]: any }) {
return this.replace(/{(.*?)}/g, (_: string, v: string | number) => variables[v] + "")
}
Object.defineProperty(String.prototype, 'sed', { enumerable: false })
+131
View File
@@ -0,0 +1,131 @@
export default {
home: {
titles: {
continue: '前回のセッションから続ける',
history: '履歴',
myPlaylists: 'マイプレイリスト',
recPlaylists: 'おすすめプレイリスト'
},
btn: {
importFromNetease: 'NetEaseからインポート'
},
text: {
playlistCreatedBy: '{u} 作成'
}
},
admin: {
neteaseLogin: {
title: 'NetEaseログイン',
scanTitle: 'スキャンしてログイン',
scanTip: 'NetEase Musicアプリでスキャンしてください',
generating: 'QRコードを生成中...',
scanned: 'スキャン完了',
confirm: '携帯電話でログインを確認してください',
success: 'ログイン成功',
errorPrefix: 'エラー: '
}
},
import: {
netease: {
title: 'NetEaseからインポート',
status: {
importing: 'インポート中',
success: 'インポート完了',
error: 'インポート失敗'
},
songs: '曲',
tip: 'NetEase Musicアプリでお気に入りの日本語プレイリストを見つけ、共有をクリックし、リンクをコピーしてここに貼り付けると、インポートを開始できます!',
inputLabel: 'NetEaseプレイリストリンク / ID',
btnStart: 'インポート開始',
btnView: 'プレイリストを表示'
}
},
playlist: {
detail: {
title: 'プレイリスト詳細',
creator: '作成者: ',
count: '曲数: ',
startPractice: '練習開始',
songList: '曲リスト',
songs: '曲'
},
list: {
mine: 'マイプレイリスト',
rec: 'おすすめプレイリスト',
import: 'NetEaseからインポート',
created: '{u} 作成',
count: '{n} 曲'
}
},
results: {
title: '練習結果',
fields: {
speed: '速度',
accuracy: '正確率',
realtime: 'リアルタイム率',
count: '文字数',
time: '時間',
duration: '曲の長さ'
},
units: {
cpm: 'CPM',
percent: '%',
x: 'x',
char: '文字'
},
chart: {
speed: '速度 (CPM)',
accuracy: '正確率 (%)'
},
btn: {
next: '次の曲',
back: 'プレイリストに戻る',
retry: 'もう一度'
}
},
song: {
mode: {
typing: 'タイピングモード',
music: '音楽モード',
karaoke: '歌うモード'
},
karaoke: {
noVocals: 'ボーカル分離トラックが検出されないため、ボーカル音量を調整できません。まず曲の詳細ページで処理してください。'
},
play: {
speed: '速度: ',
accuracy: '正確率: ',
stats: {
right: '正解:',
fuzzy: '曖昧:',
wrong: '不正解:',
remaining: '残り:'
}
}
},
user: {
title: 'アカウント管理',
loginSuccess: {
title: 'ログイン成功',
content: 'ログインに成功しました!',
jump: '移動'
},
generateCode: {
title: '引き継ぎコード生成',
copy: 'コピー',
success: '引き継ぎコードが生成されました!コードは:{code}',
expiry: 'このコードは使用後、または未使用の場合は7日後に無効になります。'
},
desc: {
intro: 'このアプリは日本のモバイルゲームのように引き継ぎコードシステムを使用しており、メールアドレスやパスワードの登録は不要です。',
instruction: '別のデバイスでログインするには、「引き継ぎコード生成」をクリックし、ログインしたいデバイスで「ログイン」をクリックしてください。',
loginMode: '現在は「引き継ぎログイン」ページです。別のデバイスで引き継ぎコードを取得し、下の入力欄に入力して「ログイン」をクリックしてください。'
},
input: '引き継ぎコードを入力',
btn: {
generate: '引き継ぎコード生成',
loginWithCode: '引き継ぎコードでログイン',
login: 'ログイン'
}
}
}
+131
View File
@@ -0,0 +1,131 @@
export default {
home: {
titles: {
continue: '从暂停的位置继续',
history: '历史数据',
myPlaylists: '我的歌单',
recPlaylists: '推荐歌单'
},
btn: {
importFromNetease: '从网易云导入'
},
text: {
playlistCreatedBy: '{u} 创建'
}
},
admin: {
neteaseLogin: {
title: '网易云登录',
scanTitle: '扫码登录',
scanTip: '请使用网易云音乐 APP 扫码',
generating: '正在生成二维码...',
scanned: '已扫描',
confirm: '请在手机上确认登录',
success: '登录成功',
errorPrefix: '错误: '
}
},
import: {
netease: {
title: '从网易云导入',
status: {
importing: '正在导入',
success: '导入完成',
error: '导入出错'
},
songs: '首歌曲',
tip: '去网易云 APP 找一个你喜欢的日本语歌单,点击分享,再点击复制链接,然后把链接粘贴到这里就可以开始导入了!',
inputLabel: '网易云歌单链接 / ID',
btnStart: '开始导入',
btnView: '查看歌单'
}
},
playlist: {
detail: {
title: '歌单详情',
creator: '创建者: ',
count: '歌曲数: ',
startPractice: '开始练习',
songList: '歌曲列表',
songs: '首歌曲'
},
list: {
mine: '我的歌单',
rec: '推荐歌单',
import: '从网易云导入',
created: '{u} 创建',
count: '{n} 首歌'
}
},
results: {
title: '练习结果',
fields: {
speed: '速度',
accuracy: '准确率',
realtime: '实时率',
count: '字数',
time: '用时',
duration: '歌曲时长'
},
units: {
cpm: 'CPM',
percent: '%',
x: 'x',
char: '字'
},
chart: {
speed: '速度 (CPM)',
accuracy: '准确率 (%)'
},
btn: {
next: '下一首',
back: '返回歌单',
retry: '再来一次'
}
},
song: {
mode: {
typing: '打字模式',
music: '音乐模式',
karaoke: '唱歌模式'
},
karaoke: {
noVocals: '未检测到人声分离音轨,无法调节人声音量。请先在歌曲详情页进行处理。'
},
play: {
speed: '速度: ',
accuracy: '正确率: ',
stats: {
right: '正确:',
fuzzy: '模糊:',
wrong: '错误:',
remaining: '剩余:'
}
}
},
user: {
title: '账号管理',
loginSuccess: {
title: '登录成功',
content: '登录成功!',
jump: '跳转'
},
generateCode: {
title: '生成引继码',
copy: '复制',
success: '引继码生成成功!生成的引继码是:{code}',
expiry: '这个引继码将会在使用之后、或者未使用的 7 天后会失效'
},
desc: {
intro: '这个 App 像日本的手机游戏一样采用引继码系统管理账号,并不需要用邮箱密码注册帐号。',
instruction: '如果想要在另一个设备上登录账号,请点击「生成引继码」然后在想要登录的设备上点击「登录」',
loginMode: '您当前在「引继登录」页面,请在另一个设备上获取引继码之后输入到下面的输入框内点击「登录」'
},
input: '输入引继码',
btn: {
generate: '生成引继码',
loginWithCode: '用引继码登录',
login: '登录'
}
}
}
+9 -9
View File
@@ -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'
}
}
+3 -2
View File
@@ -8,12 +8,13 @@
onclick: () => void
}
let { title, sub, account, right, children }: {
let { title, sub, account, right, children, moreIcon }: {
title?: string
sub?: string
account?: () => void
right?: Icon[]
children?: any
moreIcon?: string
} = $props()
let showMenu = $state(false)
@@ -37,7 +38,7 @@
{/each}
{#if children}
<IconButton icon="i-material-symbols:more-vert" onclick={() => showMenu = !showMenu} />
<IconButton icon={moreIcon ?? 'i-material-symbols:more-vert'} onclick={() => showMenu = !showMenu} />
{/if}
</div>
+3 -3
View File
@@ -1,7 +1,7 @@
import { createUser, login } from '$lib/server/user'
import type { LayoutServerLoad } from './$types'
export const load: LayoutServerLoad = async ({ cookies, request }) => {
export const load: LayoutServerLoad = async ({ cookies, request, locals }) => {
let session = cookies.get('session')
const registUA = request.headers.get('user-agent') || ''
@@ -20,7 +20,7 @@ export const load: LayoutServerLoad = async ({ cookies, request }) => {
try {
const user = structuredClone(await login(session))
return { user }
return { user, lang: locals.lang }
} catch (e) {
// Invalid session, create new
session = await createUser(registUA)
@@ -32,6 +32,6 @@ export const load: LayoutServerLoad = async ({ cookies, request }) => {
maxAge: 60 * 60 * 24 * 365
})
const user = structuredClone(await login(session))
return { user }
return { user, lang: locals.lang }
}
}
+2
View File
@@ -6,10 +6,12 @@
import "../style/material.scss"
import '@unocss/reset/normalize.css'
import '@unocss/reset/tailwind-v4.css'
import { initI18n } from "$lib/i18n"
import type { LayoutProps } from "./$types"
import { onNavigate } from '$app/navigation'
let { data, children }: LayoutProps = $props()
initI18n(data.lang || 'en')
// This function is called when the user navigates to a new page, it will start the view transition
onNavigate((navigation) => {
+24 -18
View File
@@ -1,14 +1,18 @@
<script lang="ts">
import AppBar from "$lib/ui/appbar/AppBar.svelte";
import TitleHeader from "$lib/ui/TitleHeader.svelte";
import SongInfo from "$lib/ui/listitem/SongInfo.svelte";
import type { PageProps } from "./$types";
import Button from "$lib/ui/Button.svelte";
import { Layer } from "m3-svelte";
import { goto } from "$app/navigation";
import AppBar from "$lib/ui/appbar/AppBar.svelte"
import TitleHeader from "$lib/ui/TitleHeader.svelte"
import SongInfo from "$lib/ui/listitem/SongInfo.svelte"
import type { PageProps } from "./$types"
import Button from "$lib/ui/Button.svelte"
import { Layer } from "m3-svelte"
import { goto } from "$app/navigation"
import { getI18n, setLanguage } from "$lib/i18n"
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
@@ -16,29 +20,31 @@
</script>
<AppBar account={() => goto('/user')} right={[
{icon: "i-material-symbols:settings-rounded", onclick: () => alert('Settings clicked')}
]} />
<AppBar account={() => goto('/user')} moreIcon="i-material-symbols:translate-rounded">
<MenuItem onclick={() => setLanguage('en')}>English</MenuItem>
<MenuItem onclick={() => setLanguage('zh')}>中文</MenuItem>
<MenuItem onclick={() => setLanguage('ja')}>日本語</MenuItem>
</AppBar>
<div class="vbox gap-16px overflow-y-auto flex-1">
{#if data.last}
<a {href}>
<TitleHeader title="从暂停的位置继续"/>
<TitleHeader title={t.titles.continue}/>
<div class="p-content">
<SongInfo info={data.last}></SongInfo>
</div>
</a>
{/if}
<div>
<TitleHeader title="历史数据"/>
<!-- <div>
<TitleHeader title={t.titles.history}/>
<div class="p-content">
TODO
</div>
</div>
</div> -->
<div>
<a href="/playlists/my"><TitleHeader title="我的歌单"/></a>
<a href="/playlists/my"><TitleHeader title={t.titles.myPlaylists}/></a>
<div class="p-content hbox gap-8px w-auto overflow-x-auto py-8px">
{#each data.myPlaylists as playlist}
<a class="vbox flex-shrink-0 gap-4px w-96px relative" href="/playlist/{playlist.id}">
@@ -51,12 +57,12 @@
{/each}
</div>
<div class="p-content">
<a href="/import/netease"><Button icon="i-material-symbols:cloud-download-outline">从网易云导入</Button></a>
<a href="/import/netease"><Button icon="i-material-symbols:cloud-download-outline">{t.btn.importFromNetease}</Button></a>
</div>
</div>
<div>
<a href="/playlists/rec"><TitleHeader title="推荐歌单"/></a>
<a href="/playlists/rec"><TitleHeader title={t.titles.recPlaylists}/></a>
<div class="p-content hbox gap-8px w-auto overflow-x-auto py-8px">
{#each data.recPlaylists as playlist}
<a class="vbox flex-shrink-0 p-8px gap-8px rounded-12px mbg-surface-container-high relative" href="/playlist/{playlist.id}">
@@ -64,7 +70,7 @@
<img src="{playlist.coverImgUrl}" alt="" class="size-116px rounded-8px">
<div>
<div class="m3-font-title-small font-bold truncate">{playlist.name}</div>
<div class="m3-font-body-small truncate">{playlist.creator.nickname} 创建</div>
<div class="m3-font-body-small truncate">{t.text.playlistCreatedBy.sed({u: playlist.creator.nickname})}</div>
</div>
</a>
{/each}
+11 -9
View File
@@ -12,7 +12,9 @@ This page is vibe-coded. It's not a part of the regular UI intended for users an
import { API } from '$lib/client';
import { fade, scale } from 'svelte/transition';
import AppBar from "$lib/ui/appbar/AppBar.svelte";
import { Layer } from "m3-svelte";
import { getI18n } from "$lib/i18n";
const t = getI18n().admin.neteaseLogin;
let status = $state<'loading' | 'waiting_scan' | 'waiting_confirm' | 'success' | 'error'>('loading');
let qrImg = $state('');
@@ -53,20 +55,20 @@ This page is vibe-coded. It's not a part of the regular UI intended for users an
});
</script>
<AppBar title="网易云登录" />
<AppBar title={t.title} />
<div class="vbox flex-1 cbox p-content">
<div class="vbox items-center gap-24px p-32px rounded-24px relative overflow-hidden w-full max-w-400px" in:scale={{ duration: 400, start: 0.95 }}>
<div class="vbox items-center gap-8px z-1">
<h1 class="m3-font-headline-medium mfg-on-surface m-0">扫码登录</h1>
<p class="m3-font-body-medium mfg-on-surface-variant m-0">请使用网易云音乐 APP 扫码</p>
<h1 class="m3-font-headline-medium mfg-on-surface m-0">{t.scanTitle}</h1>
<p class="m3-font-body-medium mfg-on-surface-variant m-0">{t.scanTip}</p>
</div>
<div class="cbox min-h-200px w-full z-1">
{#if status === 'loading'}
<div class="vbox items-center gap-16px" in:fade>
<div class="i-material-symbols:sync animate-spin text-40px mfg-primary"></div>
<p class="m3-font-body-medium mfg-on-surface-variant">正在生成二维码...</p>
<p class="m3-font-body-medium mfg-on-surface-variant">{t.generating}</p>
</div>
{:else if status === 'waiting_scan'}
<div class="p-16px bg-white rounded-16px shadow-sm" in:fade>
@@ -78,8 +80,8 @@ This page is vibe-coded. It's not a part of the regular UI intended for users an
<div class="i-material-symbols:check-circle-outline text-48px"></div>
</div>
<div class="vbox items-center">
<p class="m3-font-title-large mfg-on-surface font-bold">已扫描</p>
<p class="m3-font-body-medium mfg-on-surface-variant">请在手机上确认登录</p>
<p class="m3-font-title-large mfg-on-surface font-bold">{t.scanned}</p>
<p class="m3-font-body-medium mfg-on-surface-variant">{t.confirm}</p>
</div>
</div>
{:else if status === 'success'}
@@ -88,7 +90,7 @@ This page is vibe-coded. It's not a part of the regular UI intended for users an
<div class="i-material-symbols:check text-48px"></div>
</div>
<div class="vbox items-center">
<p class="m3-font-title-large mfg-on-surface font-bold">登录成功</p>
<p class="m3-font-title-large mfg-on-surface font-bold">{t.success}</p>
</div>
</div>
{:else if status === 'error'}
@@ -96,7 +98,7 @@ This page is vibe-coded. It's not a part of the regular UI intended for users an
<div class="size-80px rounded-full cbox mbg-error-container mfg-error">
<div class="i-material-symbols:error-outline text-48px"></div>
</div>
<p class="m3-font-body-medium mfg-error text-center">错误: {errorMessage}</p>
<p class="m3-font-body-medium mfg-error text-center">{t.errorPrefix}{errorMessage}</p>
</div>
{/if}
</div>
+10 -7
View File
@@ -6,6 +6,9 @@
import { API } from "$lib/client"
import ErrorDialog from "$lib/ui/status/ErrorDialog.svelte"
import ProgressList from "$lib/ui/ProgressList.svelte"
import { getI18n } from "$lib/i18n"
const t = getI18n().import.netease
let link = $state('')
@@ -34,8 +37,8 @@
return ''
}
let listTitle = $derived(status === 'idle' ? '' : (status === 'importing' ? '正在导入' : (status === 'success' ? '导入完成' : '导入出错')))
let listSubtitle = $derived(`${progress.done} / ${progress.total} 首歌曲`)
let listTitle = $derived(status === 'idle' ? '' : (status === 'importing' ? t.status.importing : (status === 'success' ? t.status.success : t.status.error)))
let listSubtitle = $derived(`${progress.done} / ${progress.total} ${t.songs}`)
let listPercent = $derived(progress.total ? progress.done / progress.total * 100 : 0)
let listItems = $derived(songs.map(song => ({
title: song.song.name,
@@ -72,26 +75,26 @@
}
</script>
<AppBar title="从网易云导入"/>
<AppBar title={t.title}/>
<ErrorDialog error={error} />
<div class="vbox gap-16px flex-1 min-h-0">
<div class="m3-font-body-medium mfg-on-surface-variant py-12px p-content">
去网易云 APP 找一个你喜欢的日本语歌单,点击分享,再点击复制链接,然后把链接粘贴到这里就可以开始导入了!
{t.tip}
</div>
<div class="vbox p-content">
<TextFieldOutlined label="网易云歌单链接 / ID" bind:value={link} />
<TextFieldOutlined label={t.inputLabel} bind:value={link} />
</div>
<ProgressList title={listTitle} subtitle={listSubtitle} percentage={listPercent} items={listItems} />
<div class="py-16px p-content">
{#if status === 'idle'}
<Button big icon="i-material-symbols:download" onclick={startImport}>开始导入</Button>
<Button big icon="i-material-symbols:download" onclick={startImport}>{t.btnStart}</Button>
{:else if status === 'success'}
<a href="/playlist/{id}">
<Button big icon="i-material-symbols:right-arrow">查看歌单</Button>
<Button big icon="i-material-symbols:right-arrow">{t.btnView}</Button>
</a>
{/if}
</div>
+9 -6
View File
@@ -5,6 +5,9 @@
import Button from "$lib/ui/Button.svelte"
import SongInfo from "$lib/ui/listitem/SongInfo.svelte"
import { API } from "$lib/client"
import { getI18n } from "$lib/i18n"
const t = getI18n().playlist.detail
let { data }: PageProps = $props()
@@ -42,7 +45,7 @@
}
</script>
<AppBar title="歌单详情" right={[
<AppBar title={t.title} right={[
{
icon: isFavorite ? "i-material-symbols:bookmark-rounded" : "i-material-symbols:bookmark-add-outline-rounded",
onclick: toggleFavorite
@@ -55,18 +58,18 @@
<div class="vbox flex-1 py-8px self-stretch min-h-full">
<div class="m3-font-headline-small font-bold">{meta.name}</div>
<div class="flex-1">
<div class="m3-font-body-small text-surface-variant">创建者: {meta.creator.nickname}</div>
<div class="m3-font-body-small text-surface-variant">歌曲数: {meta.trackCount}</div>
<div class="m3-font-body-small text-surface-variant">{t.creator}{meta.creator.nickname}</div>
<div class="m3-font-body-small text-surface-variant">{t.count}{meta.trackCount}</div>
</div>
<div>
<Button onclick={startPractice}>开始练习</Button>
<Button onclick={startPractice}>{t.startPractice}</Button>
</div>
</div>
</div>
<div class="hbox gap-12px items-end! h-48px p-content">
<div class="m3-font-headline-small">歌曲列表</div>
<div class="m3-font-label-small pb-3px">{songs.length} 首歌曲</div>
<div class="m3-font-headline-small">{t.songList}</div>
<div class="m3-font-label-small pb-3px">{songs.length} {t.songs}</div>
</div>
<div class="vbox gap-12px mt-12px pb-12px scroll-here">
{#each songs as song, index}
+6 -3
View File
@@ -4,18 +4,21 @@
import ImageListItem from "$lib/ui/listitem/ImageListItem.svelte"
import MenuItem from "$lib/ui/material3/MenuItem.svelte"
import type { PageProps } from "./$types"
import { getI18n } from "$lib/i18n"
const t = getI18n().playlist.list
let { data }: PageProps = $props()
</script>
<AppBar title={data.isMine ? '我的歌单' : '推荐歌单'}>
<MenuItem icon="i-material-symbols:download" onclick={() => goto("/import/netease")}>从网易云导入</MenuItem>
<AppBar title={data.isMine ? t.mine : t.rec}>
<MenuItem icon="i-material-symbols:download" onclick={() => goto("/import/netease")}>{t.import}</MenuItem>
</AppBar>
<div class="vbox p-content gap-12px scroll-here">
{#each data.playlists as pl}
<a href="/playlist/{pl.id}">
<ImageListItem photoUrl={pl.coverImgUrl} title={pl.name} text={`${pl.creator.nickname} 创建`} right={`${pl.trackCount} 首歌`} />
<ImageListItem photoUrl={pl.coverImgUrl} title={pl.name} text={t.created.sed({u: pl.creator.nickname})} right={t.count.sed({n: pl.trackCount})} />
</a>
{/each}
</div>
+15 -12
View File
@@ -7,6 +7,9 @@
import Chart from "chart.js/auto"
import { API } from "$lib/client"
import { getI18n } from "$lib/i18n"
const t = getI18n().results
import type { NeteaseSong, UserData } from "../../../lib/types"
@@ -19,12 +22,12 @@
let duration = endTime - startTime
let fields = [
{ label: "速度", value: Math.round(totalTyped / (Math.max(1, duration) / 60000)), unit: "CPM" },
{ label: "准确率", value: totalTyped === 0 ? 100 : Math.round((totalRight / totalTyped) * 10000) / 100, unit: "%" },
{ label: "实时率", value: data.result.realTimeFactor.toFixed(2), unit: "x" },
{ label: "字数", value: totalTyped, unit: "字" },
{ label: "用时", value: new Date(duration).toISOString().slice(14, 19), unit: "" },
{ label: "歌曲时长", value: new Date(data.song.dt).toISOString().slice(14, 19), unit: "" }
{ label: t.fields.speed, value: Math.round(totalTyped / (Math.max(1, duration) / 60000)), unit: t.units.cpm },
{ label: t.fields.accuracy, value: totalTyped === 0 ? 100 : Math.round((totalRight / totalTyped) * 10000) / 100, unit: t.units.percent },
{ label: t.fields.realtime, value: data.result.realTimeFactor.toFixed(2), unit: t.units.x },
{ label: t.fields.count, value: totalTyped, unit: t.units.char },
{ label: t.fields.time, value: new Date(duration).toISOString().slice(14, 19), unit: "" },
{ label: t.fields.duration, value: new Date(data.song.dt).toISOString().slice(14, 19), unit: "" }
]
let chartCanvas: HTMLCanvasElement
@@ -38,7 +41,7 @@
labels: statsHistory.map((_: any, i: number) => i),
datasets: [
{
label: "速度 (CPM)",
label: t.chart.speed,
data: statsHistory.map((h: { cpm: number }) => h.cpm),
tension: 0.4,
pointRadius: 0,
@@ -47,7 +50,7 @@
backgroundColor: "rgba(123, 120, 194, 0.1)",
},
{
label: "准确率 (%)",
label: t.chart.accuracy,
data: statsHistory.map((h: { acc: number }) => h.acc),
tension: 0.4,
yAxisID: "y1",
@@ -146,9 +149,9 @@
}
let buttonText = $derived(
nextSongId !== null ? "下一首" :
isPlaylistFinished ? "返回歌单" :
"再来一次"
nextSongId !== null ? t.btn.next :
isPlaylistFinished ? t.btn.back :
t.btn.retry
)
</script>
@@ -156,7 +159,7 @@
<div class="vbox gap-16px p-content scroll-here">
<div class="hbox gap-12px items-end! h-48px">
<div class="m3-font-headline-small">练习结果</div>
<div class="m3-font-headline-small">{t.title}</div>
</div>
<div class="grid grid-cols-2 gap-16px">
+30 -6
View File
@@ -6,8 +6,24 @@
import ProgressList from "$lib/ui/ProgressList.svelte"
import { goto } from "$app/navigation"
import { artistAndAlbum } from "$lib/utils"
import { getI18n } from "$lib/i18n"
const t = getI18n().song.mode
let { data } = $props()
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 loadStatus = $state<"idle" | "loading" | "done">("idle")
let progressItems = $state<any[]>([])
let progressPercentage = $state(0)
@@ -24,6 +40,15 @@
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' :
@@ -50,9 +75,8 @@
<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`)}>打字模式</Button>
<Button big icon="i-material-symbols:music-note-rounded" onclick={() => goto(`/song/${data.song.id}/play?music=true`)}>音乐模式</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>
+4 -2
View File
@@ -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"
@@ -9,6 +8,9 @@
import { MusicControl } from "$lib/ui/player/MusicControl"
import Lyrics from "$lib/ui/player/Lyrics.svelte"
import PlayerAppBar from "$lib/ui/player/PlayerAppBar.svelte"
import { getI18n } from "$lib/i18n"
const t = getI18n().song.karaoke
let { data }: PageProps = $props()
@@ -88,7 +90,7 @@
</div>
{:else}
<div class="text-center text-sm opacity-70">
未检测到人声分离音轨,无法调节人声音量。请先在歌曲详情页进行处理。
{t.noVocals}
</div>
{/if}
</div>
+9 -7
View File
@@ -8,10 +8,12 @@
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"
const t = getI18n().song.play
let { data }: PageProps = $props()
@@ -167,14 +169,14 @@
<!-- Stats -->
<div class="vbox p-content py-12px mfg-on-surface-variant m3-font-body-medium">
<div class="hbox justify-between">
<div>速度: {startTime ? Math.round(totalTyped / (Math.max(1, (now - startTime)) / 60000)) : '-'} cpm</div>
<div>正確率: {totalTyped === 0 ? 100 : Math.round((totalRight / totalTyped) * 100)}%</div>
<div>{t.speed}{startTime ? Math.round(totalTyped / (Math.max(1, (now - startTime)) / 60000)) : '-'} cpm</div>
<div>{t.accuracy}{totalTyped === 0 ? 100 : Math.round((totalRight / totalTyped) * 100)}%</div>
</div>
<div class="hbox justify-between">
<div>正确:{flat.filter(s => s === 'right').length}</div>
<div>模糊:{flat.filter(s => s === 'fuzzy').length}</div>
<div>错误:{flat.filter(s => s === 'wrong').length}</div>
<div>剩余:{flat.filter(s => s === 'unseen').length}</div>
<div>{t.stats.right}{flat.filter(s => s === 'right').length}</div>
<div>{t.stats.fuzzy}{flat.filter(s => s === 'fuzzy').length}</div>
<div>{t.stats.wrong}{flat.filter(s => s === 'wrong').length}</div>
<div>{t.stats.remaining}{flat.filter(s => s === 'unseen').length}</div>
</div>
</div>
+18 -15
View File
@@ -5,6 +5,9 @@
import Dialog from "$lib/ui/status/Dialog.svelte"
import { API } from "$lib/client"
import ErrorDialog from "$lib/ui/status/ErrorDialog.svelte"
import { getI18n } from "$lib/i18n"
const t = getI18n().user
let showCodeOpen = $state(false)
let loginSuccessOpen = $state(false)
@@ -25,34 +28,34 @@
<ErrorDialog error={error}/>
<Dialog title="登录成功" bind:open={loginSuccessOpen} buttons={[{
text: '跳转', onclick: () => location.href = '/'
<Dialog title={t.loginSuccess.title} bind:open={loginSuccessOpen} buttons={[{
text: t.loginSuccess.jump, onclick: () => location.href = '/'
}]}>
登录成功!
{t.loginSuccess.content}
</Dialog>
<Dialog title="生成引继码" bind:open={showCodeOpen} buttons={[{
text: '复制', onclick: () => navigator.clipboard.writeText(generatedCode)
<Dialog title={t.generateCode.title} bind:open={showCodeOpen} buttons={[{
text: t.generateCode.copy, onclick: () => navigator.clipboard.writeText(generatedCode)
}]}>
引继码生成成功!生成的引继码是:{generatedCode}
{t.generateCode.success.sed({code: generatedCode})}
<br><br>
这个引继码将会在使用之后、或者未使用的 7 天后会失效
{t.generateCode.expiry}
</Dialog>
<AppBar title="账号管理"/>
<AppBar title={t.title}/>
<div class="m3-font-body-medium mfg-on-surface-variant py-12px p-content">
这个 App 像日本的手机游戏一样采用引继码系统管理账号,并不需要用邮箱密码注册帐号。
{t.desc.intro}
<br><br>
如果想要在另一个设备上登录账号,请点击「生成引继码」然后在想要登录的设备上点击「登录」
{t.desc.instruction}
{#if loginMode}
<br><br>您当前在「引继登录」页面,请在另一个设备上获取引继码之后输入到下面的输入框内点击「登录」
<br><br>{t.desc.loginMode}
{/if}
</div>
{#if loginMode}
<div class="vbox px-16px py-10px">
<TextFieldOutlined label="输入引继码" bind:value={loginCode}/>
<TextFieldOutlined label={t.input} bind:value={loginCode}/>
</div>
{/if}
@@ -60,9 +63,9 @@
<div class="hbox p-16px gap-16px">
{#if !loginMode}
<Button big secondary icon="i-material-symbols:add" disabled={showCodeOpen} onclick={generateCode}>生成引继码</Button>
<Button big secondary icon="i-material-symbols:login" disabled={showCodeOpen} onclick={() => loginMode = true}>用引继码登录</Button>
<Button big secondary icon="i-material-symbols:add" disabled={showCodeOpen} onclick={generateCode}>{t.btn.generate}</Button>
<Button big secondary icon="i-material-symbols:login" disabled={showCodeOpen} onclick={() => loginMode = true}>{t.btn.loginWithCode}</Button>
{:else}
<Button big secondary icon="i-material-symbols:login" disabled={showCodeOpen} onclick={doLogin}>登录</Button>
<Button big secondary icon="i-material-symbols:login" disabled={showCodeOpen} onclick={doLogin}>{t.btn.login}</Button>
{/if}
</div>