[O] Decouple
This commit is contained in:
@@ -110,8 +110,8 @@ export const getSongUrl = async (id: number | string) => {
|
||||
if (await fs.exists(filePath)) {
|
||||
return {
|
||||
url: publicUrl,
|
||||
vocalsUrl: (await fs.exists(vocalsPath)) ? `/audio/${id}/vocals.opus` : null,
|
||||
instrumentalUrl: (await fs.exists(instrumentalPath)) ? `/audio/${id}/instrumental.opus` : null
|
||||
vocalsUrl: (await fs.exists(vocalsPath)) ? `/audio/${id}/vocals.opus` : undefined,
|
||||
instrumentalUrl: (await fs.exists(instrumentalPath)) ? `/audio/${id}/instrumental.opus` : undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,8 @@ export const typingSettingsDefault = {
|
||||
hideRepeated: false,
|
||||
};
|
||||
|
||||
export type TypingSettings = typeof typingSettingsDefault;
|
||||
|
||||
export interface UserData {
|
||||
myPlaylists?: number[];
|
||||
playHistory?: GameStats[];
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte"
|
||||
import { isKana, isKanji, toKatakana, toRomaji } from "wanakana"
|
||||
import type { ProcLrcLine, ProcLrcSeg } from "./IMEHelper"
|
||||
import type { TypingSettings } from "$lib/types"
|
||||
import { animateCaret } from "./animation"
|
||||
|
||||
interface Props {
|
||||
lines: ProcLrcLine[]
|
||||
currentLineIndex: number
|
||||
currentWordIndex?: number
|
||||
states?: string[][] // [lineIndex][charIndex] -> state
|
||||
settings: TypingSettings
|
||||
showCaret?: boolean
|
||||
onLineClick?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
lines,
|
||||
currentLineIndex,
|
||||
currentWordIndex = 0,
|
||||
states = [],
|
||||
settings,
|
||||
showCaret = false,
|
||||
onLineClick
|
||||
}: Props = $props()
|
||||
|
||||
let lrcWrapper: HTMLDivElement
|
||||
let caret: HTMLDivElement
|
||||
|
||||
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) => {
|
||||
let sts = allStates(l, seg)
|
||||
if (sts.every(s => s === 'right')) return 'right'
|
||||
if (sts.some(s => s === 'wrong')) return 'wrong'
|
||||
return 'typing'
|
||||
}
|
||||
|
||||
// Auto scroll
|
||||
$effect(() => {
|
||||
currentLineIndex
|
||||
if (!lrcWrapper) return
|
||||
tick().then(() => {
|
||||
const activeEl = lrcWrapper.querySelector('.active') as HTMLElement
|
||||
if (activeEl) {
|
||||
lrcWrapper.scrollTo({
|
||||
top: activeEl.offsetTop - lrcWrapper.clientHeight / 2 + activeEl.clientHeight / 2,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Caret animation
|
||||
$effect(() => {
|
||||
if (showCaret && caret) {
|
||||
currentLineIndex; currentWordIndex;
|
||||
animateCaret(caret)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<svelte:window onresize={() => { if (showCaret && caret) animateCaret(caret) }} />
|
||||
|
||||
<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}
|
||||
{#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}
|
||||
{#if !seg.kanji}
|
||||
{#each seg.kana as char, c}
|
||||
<span class="{states[l]?.[seg.swi + c] ?? ''}" class:here={l === currentLineIndex && currentWordIndex === seg.swi + c}
|
||||
class:punctuation={!isKana(char) && !isKanji(char)}>
|
||||
{@html preprocessKana(char, states[l]?.[seg.swi + c])}
|
||||
</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<ruby>
|
||||
<span class="{getKanjiState(l, seg)}">{seg.kanji}</span>{#if settings.isFuri}<rt>
|
||||
{#each seg.kana as char, c}
|
||||
<span class="{states[l]?.[seg.swi + c] ?? ''}" class:here={l === currentLineIndex && currentWordIndex === seg.swi + c}>{@html preprocessKana(char, states[l]?.[seg.swi + c])}</span>
|
||||
{/each}
|
||||
</rt>{/if}
|
||||
</ruby>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="h-30vh"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="sass">
|
||||
.lrc-wrapper
|
||||
*
|
||||
transition: font-size 0.2s ease-in-out
|
||||
|
||||
.lrc
|
||||
color: #8b8b8b
|
||||
font-weight: 500
|
||||
font-size: 20px
|
||||
opacity: 0.6
|
||||
transition: all 0.2s ease-in-out
|
||||
|
||||
&.active
|
||||
opacity: 1
|
||||
font-size: 24px
|
||||
color: rgb(var(--m3-scheme-on-surface))
|
||||
//background-color: rgba(var(--m3-scheme-secondary-container) / 0.5)
|
||||
|
||||
.wrong
|
||||
color: #e55757
|
||||
background-color: rgba(229, 87, 87, 0.1)
|
||||
.fuzzy
|
||||
color: #e5a657
|
||||
background-color: rgba(229, 166, 87, 0.1)
|
||||
.right
|
||||
color: #7b78c2
|
||||
.punctuation
|
||||
opacity: 0.5
|
||||
</style>
|
||||
@@ -3,16 +3,22 @@ import type { LyricLine } from '$lib/types'
|
||||
|
||||
export class MusicControl {
|
||||
player: Tone.Player
|
||||
vocalsPlayer?: Tone.Player
|
||||
lyrics: LyricLine[] = []
|
||||
currentLineIndex: number = 0
|
||||
checkInterval: any
|
||||
isLoaded = false
|
||||
|
||||
audioUrl: string
|
||||
vocalsUrl?: string
|
||||
|
||||
constructor(audioUrl: string) {
|
||||
constructor(audioUrl: string, vocalsUrl?: string) {
|
||||
this.audioUrl = audioUrl
|
||||
this.vocalsUrl = vocalsUrl
|
||||
this.player = new Tone.Player(audioUrl).toDestination()
|
||||
if (vocalsUrl) {
|
||||
this.vocalsPlayer = new Tone.Player(vocalsUrl).toDestination()
|
||||
}
|
||||
}
|
||||
|
||||
log(msg: string) {
|
||||
@@ -42,15 +48,22 @@ export class MusicControl {
|
||||
this.log('start() called')
|
||||
await this.ready().catch(e => this.log(`Tone.start() failed: ${e}`))
|
||||
|
||||
const promises = []
|
||||
if (!this.player.loaded) {
|
||||
this.log('Loading audio...')
|
||||
await this.player.load(this.audioUrl)
|
||||
this.log('Audio loaded')
|
||||
promises.push(this.player.load(this.audioUrl))
|
||||
}
|
||||
if (this.vocalsPlayer && !this.vocalsPlayer.loaded) {
|
||||
this.log('Loading vocals...')
|
||||
promises.push(this.vocalsPlayer.load(this.vocalsUrl!))
|
||||
}
|
||||
await Promise.all(promises)
|
||||
this.log('Audio loaded')
|
||||
|
||||
// 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)
|
||||
this.vocalsPlayer?.sync().start(0)
|
||||
|
||||
if (Tone.getTransport().state !== 'started') {
|
||||
this.log('Starting Transport')
|
||||
@@ -59,6 +72,14 @@ export class MusicControl {
|
||||
this.startCheckLoop()
|
||||
}
|
||||
|
||||
setVocalsVolume(db: number) {
|
||||
if (this.vocalsPlayer) this.vocalsPlayer.volume.value = db
|
||||
}
|
||||
|
||||
getTime() {
|
||||
return Tone.getTransport().seconds
|
||||
}
|
||||
|
||||
startCheckLoop() {
|
||||
if (this.checkInterval) return
|
||||
this.checkInterval = setInterval(() => this.check(), 50)
|
||||
@@ -66,6 +87,10 @@ export class MusicControl {
|
||||
|
||||
check() {
|
||||
if (Tone.getTransport().state !== 'started') return
|
||||
|
||||
// In karaoke mode (dual tracks), we don't pause for typing
|
||||
if (this.vocalsPlayer) return
|
||||
|
||||
const ct = Tone.getTransport().seconds
|
||||
const ni = this.currentLineIndex + 1
|
||||
if (ni >= this.lyrics.length) return
|
||||
@@ -92,6 +117,7 @@ export class MusicControl {
|
||||
dispose() {
|
||||
if (this.checkInterval) clearInterval(this.checkInterval)
|
||||
this.player.dispose()
|
||||
this.vocalsPlayer?.dispose()
|
||||
Tone.getTransport().stop()
|
||||
Tone.getTransport().cancel()
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
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"
|
||||
|
||||
interface Props {
|
||||
song: NeteaseSong
|
||||
settings: TypingSettings
|
||||
loc?: UserData['loc']
|
||||
showRomajiOnError?: boolean
|
||||
disableHideRepeated?: boolean
|
||||
isKaraoke?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
song,
|
||||
settings = $bindable(),
|
||||
loc = $bindable(),
|
||||
showRomajiOnError = true,
|
||||
disableHideRepeated = false,
|
||||
isKaraoke = false
|
||||
}: Props = $props()
|
||||
|
||||
let isHideRepeated = $derived(settings.hideRepeated && !disableHideRepeated)
|
||||
</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>
|
||||
|
||||
{#if showRomajiOnError}
|
||||
<MenuItem icon="i-material-symbols:error-circle-rounded" onclick={() => settings.showRomajiOnError = !settings.showRomajiOnError}>{settings.showRomajiOnError ? "不在错误时显示罗马音" : "错误时显示罗马音"}</MenuItem>
|
||||
{/if}
|
||||
|
||||
<MenuItem icon="i-material-symbols:compress-rounded"
|
||||
disabled={disableHideRepeated}
|
||||
sub={disableHideRepeated ? "音乐模式下不可用" : ""}
|
||||
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>
|
||||
{/if}
|
||||
</AppBar>
|
||||
@@ -1,18 +1,17 @@
|
||||
<script lang="ts">
|
||||
import AppBar from "$lib/ui/appbar/AppBar.svelte"
|
||||
import type { PageProps } from "./$types"
|
||||
import { LinearProgress } from "m3-svelte"
|
||||
import { onMount, tick } from "svelte"
|
||||
import { typingSettingsDefault, type LyricSegment } from "$lib/types.ts"
|
||||
import { isKana, isKanji, toHiragana, toKatakana, toRomaji } from "wanakana"
|
||||
import { composeList, fuzzyEquals, processLrcLine, dedupLines, type ProcLrcLine, type ProcLrcSeg } from "./IMEHelper.ts"
|
||||
import MenuItem from "$lib/ui/material3/MenuItem.svelte"
|
||||
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 "$lib/ext.ts"
|
||||
import { API } from "$lib/client.ts"
|
||||
import { animateCaret } from "./animation.ts"
|
||||
import { goto } from '$app/navigation'
|
||||
import { artistAndAlbum } from "$lib/utils.ts"
|
||||
import { MusicControl } from "./MusicControl.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"
|
||||
|
||||
let { data }: PageProps = $props()
|
||||
|
||||
@@ -31,9 +30,6 @@
|
||||
let loc = $state(data.user.data.loc)
|
||||
$effect(() => { API.saveUserData({ loc }) })
|
||||
|
||||
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)
|
||||
|
||||
// Process each line into segments with swi (start word index) and kanji/kana
|
||||
const isHideRepeated = $derived(settings.hideRepeated && !data.audioUrl)
|
||||
let deduplicatedLyrics = $derived(dedupLines(data.lrc, isHideRepeated))
|
||||
@@ -47,21 +43,9 @@
|
||||
// Reset when processedLrc changes (settings changed)
|
||||
$effect(() => {
|
||||
states = processedLrc.map(line => new Array(line.totalLen).fill('unseen'))
|
||||
li = 0
|
||||
wi = 0
|
||||
inp = ""
|
||||
startTime = 0
|
||||
statsHistory = []
|
||||
li = 0; wi = 0; inp = ""; startTime = 0; statsHistory = []
|
||||
})
|
||||
|
||||
const allStates = (l: number, seg: ProcLrcSeg) => states[l]?.slice(seg.swi, seg.swi + seg.kana.length) ?? []
|
||||
const getKanjiState = (l: number, seg: ProcLrcSeg) => {
|
||||
let sts = allStates(l, seg)
|
||||
if (sts.every(s => s === 'right')) return 'right'
|
||||
if (sts.some(s => s === 'wrong')) return 'wrong'
|
||||
return 'typing'
|
||||
}
|
||||
|
||||
// For computing stats
|
||||
let startTime = $state(0)
|
||||
let now = $state(Date.now())
|
||||
@@ -150,26 +134,6 @@
|
||||
}
|
||||
$effect(() => inputChanged(inp, false))
|
||||
|
||||
// Caret: Typing indicator
|
||||
let caret: HTMLDivElement
|
||||
$effect(() => { li; wi; animateCaret(caret) })
|
||||
|
||||
// Auto scroll to active line
|
||||
let lrcWrapper: HTMLDivElement
|
||||
$effect(() => {
|
||||
li
|
||||
if (!lrcWrapper) return
|
||||
tick().then(() => {
|
||||
const activeEl = lrcWrapper.querySelector('.active') as HTMLElement
|
||||
if (activeEl) {
|
||||
lrcWrapper.scrollTo({
|
||||
top: activeEl.offsetTop - lrcWrapper.clientHeight / 2 + activeEl.clientHeight / 2,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Result is stored on the server and is fetched from a separate results page
|
||||
async function submitResult() {
|
||||
const res = await API.saveResult({
|
||||
@@ -189,24 +153,11 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Window events -->
|
||||
<svelte:window onresize={() => caret && animateCaret(caret)} onclick={() => musicControl?.ready()} onkeydown={() => musicControl?.ready()}/>
|
||||
<svelte:window onclick={() => musicControl?.ready()} onkeydown={() => musicControl?.ready()}/>
|
||||
|
||||
<PlayerAppBar song={data.song} bind:settings bind:loc disableHideRepeated={!!data.audioUrl} />
|
||||
|
||||
<AppBar title={data.song.name} sub={artistAndAlbum(data.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 icon="i-material-symbols:error-circle-rounded" onclick={() => settings.showRomajiOnError = !settings.showRomajiOnError}>{settings.showRomajiOnError ? "不在错误时显示罗马音" : "错误时显示罗马音"}</MenuItem>
|
||||
<MenuItem icon="i-material-symbols:compress-rounded"
|
||||
disabled={!!data.audioUrl}
|
||||
sub={data.audioUrl ? "音乐模式下不可用" : ""}
|
||||
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>
|
||||
{/if}
|
||||
</AppBar>
|
||||
<LinearProgress percent={progress} />
|
||||
|
||||
<LinearProgress percent={progress} />
|
||||
|
||||
@@ -228,62 +179,4 @@
|
||||
</div>
|
||||
|
||||
<!-- Lines -->
|
||||
<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">
|
||||
<div bind:this={caret} class="absolute bg-amber w-2px h-24px transition-all duration-75 z-10"></div>
|
||||
{#each processedLrc as line, l}
|
||||
<div class="lrc p-content text-center m3-font-body-large" class:active={l === li} role="button" tabindex="0"
|
||||
onclick={() => hiddenInput.focus()}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); hiddenInput.focus() } }}>
|
||||
{#each line.parts as seg}
|
||||
{#if !seg.kanji}
|
||||
{#each seg.kana as char, c}
|
||||
<span class="{states[l]?.[seg.swi + c] ?? ''}" class:here={l === li && wi === seg.swi + c}
|
||||
class:punctuation={!isKana(char) && !isKanji(char)}>
|
||||
{@html preprocessKana(char, states[l]?.[seg.swi + c])}
|
||||
</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<ruby>
|
||||
<span class="{getKanjiState(l, seg)}">{seg.kanji}</span>{#if settings.isFuri}<rt>
|
||||
{#each seg.kana as char, c}
|
||||
<span class="{states[l]?.[seg.swi + c] ?? ''}" class:here={l === li && wi === seg.swi + c}>{@html preprocessKana(char, states[l]?.[seg.swi + c])}</span>
|
||||
{/each}
|
||||
</rt>{/if}
|
||||
</ruby>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
<div class="h-30vh"></div>
|
||||
</div>
|
||||
</div>
|
||||
<style lang="sass">
|
||||
.lrc-wrapper
|
||||
*
|
||||
transition: font-size 0.2s ease-in-out
|
||||
|
||||
.lrc
|
||||
color: #8b8b8b
|
||||
font-weight: 500
|
||||
font-size: 20px
|
||||
opacity: 0.6
|
||||
transition: all 0.2s ease-in-out
|
||||
|
||||
&.active
|
||||
opacity: 1
|
||||
font-size: 24px
|
||||
color: rgb(var(--m3-scheme-on-surface))
|
||||
//background-color: rgba(var(--m3-scheme-secondary-container) / 0.5)
|
||||
|
||||
.wrong
|
||||
color: #e55757
|
||||
background-color: rgba(229, 87, 87, 0.1)
|
||||
.fuzzy
|
||||
color: #e5a657
|
||||
background-color: rgba(229, 166, 87, 0.1)
|
||||
.right
|
||||
color: #7b78c2
|
||||
.punctuation
|
||||
opacity: 0.5
|
||||
</style>
|
||||
<Lyrics lines={processedLrc} currentLineIndex={li} currentWordIndex={wi} {states} {settings} showCaret={true} onLineClick={() => hiddenInput.focus()} />
|
||||
Reference in New Issue
Block a user