[O] Decouple
This commit is contained in:
@@ -110,8 +110,8 @@ export const getSongUrl = async (id: number | string) => {
|
|||||||
if (await fs.exists(filePath)) {
|
if (await fs.exists(filePath)) {
|
||||||
return {
|
return {
|
||||||
url: publicUrl,
|
url: publicUrl,
|
||||||
vocalsUrl: (await fs.exists(vocalsPath)) ? `/audio/${id}/vocals.opus` : null,
|
vocalsUrl: (await fs.exists(vocalsPath)) ? `/audio/${id}/vocals.opus` : undefined,
|
||||||
instrumentalUrl: (await fs.exists(instrumentalPath)) ? `/audio/${id}/instrumental.opus` : null
|
instrumentalUrl: (await fs.exists(instrumentalPath)) ? `/audio/${id}/instrumental.opus` : undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ export const typingSettingsDefault = {
|
|||||||
hideRepeated: false,
|
hideRepeated: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type TypingSettings = typeof typingSettingsDefault;
|
||||||
|
|
||||||
export interface UserData {
|
export interface UserData {
|
||||||
myPlaylists?: number[];
|
myPlaylists?: number[];
|
||||||
playHistory?: GameStats[];
|
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 {
|
export class MusicControl {
|
||||||
player: Tone.Player
|
player: Tone.Player
|
||||||
|
vocalsPlayer?: Tone.Player
|
||||||
lyrics: LyricLine[] = []
|
lyrics: LyricLine[] = []
|
||||||
currentLineIndex: number = 0
|
currentLineIndex: number = 0
|
||||||
checkInterval: any
|
checkInterval: any
|
||||||
isLoaded = false
|
isLoaded = false
|
||||||
|
|
||||||
audioUrl: string
|
audioUrl: string
|
||||||
|
vocalsUrl?: string
|
||||||
|
|
||||||
constructor(audioUrl: string) {
|
constructor(audioUrl: string, vocalsUrl?: string) {
|
||||||
this.audioUrl = audioUrl
|
this.audioUrl = audioUrl
|
||||||
|
this.vocalsUrl = vocalsUrl
|
||||||
this.player = new Tone.Player(audioUrl).toDestination()
|
this.player = new Tone.Player(audioUrl).toDestination()
|
||||||
|
if (vocalsUrl) {
|
||||||
|
this.vocalsPlayer = new Tone.Player(vocalsUrl).toDestination()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log(msg: string) {
|
log(msg: string) {
|
||||||
@@ -42,15 +48,22 @@ export class MusicControl {
|
|||||||
this.log('start() called')
|
this.log('start() called')
|
||||||
await this.ready().catch(e => this.log(`Tone.start() failed: ${e}`))
|
await this.ready().catch(e => this.log(`Tone.start() failed: ${e}`))
|
||||||
|
|
||||||
|
const promises = []
|
||||||
if (!this.player.loaded) {
|
if (!this.player.loaded) {
|
||||||
this.log('Loading audio...')
|
this.log('Loading audio...')
|
||||||
await this.player.load(this.audioUrl)
|
promises.push(this.player.load(this.audioUrl))
|
||||||
this.log('Audio loaded')
|
|
||||||
}
|
}
|
||||||
|
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
|
// Sync player to transport and schedule start at 0
|
||||||
// We do this regardless of transport state to ensure it's scheduled
|
// We do this regardless of transport state to ensure it's scheduled
|
||||||
this.player.sync().start(0)
|
this.player.sync().start(0)
|
||||||
|
this.vocalsPlayer?.sync().start(0)
|
||||||
|
|
||||||
if (Tone.getTransport().state !== 'started') {
|
if (Tone.getTransport().state !== 'started') {
|
||||||
this.log('Starting Transport')
|
this.log('Starting Transport')
|
||||||
@@ -59,6 +72,14 @@ export class MusicControl {
|
|||||||
this.startCheckLoop()
|
this.startCheckLoop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setVocalsVolume(db: number) {
|
||||||
|
if (this.vocalsPlayer) this.vocalsPlayer.volume.value = db
|
||||||
|
}
|
||||||
|
|
||||||
|
getTime() {
|
||||||
|
return Tone.getTransport().seconds
|
||||||
|
}
|
||||||
|
|
||||||
startCheckLoop() {
|
startCheckLoop() {
|
||||||
if (this.checkInterval) return
|
if (this.checkInterval) return
|
||||||
this.checkInterval = setInterval(() => this.check(), 50)
|
this.checkInterval = setInterval(() => this.check(), 50)
|
||||||
@@ -66,6 +87,10 @@ export class MusicControl {
|
|||||||
|
|
||||||
check() {
|
check() {
|
||||||
if (Tone.getTransport().state !== 'started') return
|
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 ct = Tone.getTransport().seconds
|
||||||
const ni = this.currentLineIndex + 1
|
const ni = this.currentLineIndex + 1
|
||||||
if (ni >= this.lyrics.length) return
|
if (ni >= this.lyrics.length) return
|
||||||
@@ -92,6 +117,7 @@ export class MusicControl {
|
|||||||
dispose() {
|
dispose() {
|
||||||
if (this.checkInterval) clearInterval(this.checkInterval)
|
if (this.checkInterval) clearInterval(this.checkInterval)
|
||||||
this.player.dispose()
|
this.player.dispose()
|
||||||
|
this.vocalsPlayer?.dispose()
|
||||||
Tone.getTransport().stop()
|
Tone.getTransport().stop()
|
||||||
Tone.getTransport().cancel()
|
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">
|
<script lang="ts">
|
||||||
import AppBar from "$lib/ui/appbar/AppBar.svelte"
|
|
||||||
import type { PageProps } from "./$types"
|
import type { PageProps } from "./$types"
|
||||||
import { LinearProgress } from "m3-svelte"
|
import { LinearProgress } from "m3-svelte"
|
||||||
import { onMount, tick } from "svelte"
|
import { onMount } from "svelte"
|
||||||
import { typingSettingsDefault, type LyricSegment } from "$lib/types.ts"
|
import { typingSettingsDefault } from "$lib/types.ts"
|
||||||
import { isKana, isKanji, toHiragana, toKatakana, toRomaji } from "wanakana"
|
import { isKana, isKanji, toHiragana } from "wanakana"
|
||||||
import { composeList, fuzzyEquals, processLrcLine, dedupLines, type ProcLrcLine, type ProcLrcSeg } from "./IMEHelper.ts"
|
import { composeList, fuzzyEquals, processLrcLine, dedupLines, type ProcLrcLine } from "$lib/ui/player/IMEHelper.ts"
|
||||||
import MenuItem from "$lib/ui/material3/MenuItem.svelte"
|
|
||||||
import "$lib/ext.ts"
|
import "$lib/ext.ts"
|
||||||
import { API } from "$lib/client.ts"
|
import { API } from "$lib/client.ts"
|
||||||
import { animateCaret } from "./animation.ts"
|
|
||||||
import { goto } from '$app/navigation'
|
import { goto } from '$app/navigation'
|
||||||
import { artistAndAlbum } from "$lib/utils.ts"
|
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()
|
let { data }: PageProps = $props()
|
||||||
|
|
||||||
@@ -31,9 +30,6 @@
|
|||||||
let loc = $state(data.user.data.loc)
|
let loc = $state(data.user.data.loc)
|
||||||
$effect(() => { API.saveUserData({ 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
|
// Process each line into segments with swi (start word index) and kanji/kana
|
||||||
const isHideRepeated = $derived(settings.hideRepeated && !data.audioUrl)
|
const isHideRepeated = $derived(settings.hideRepeated && !data.audioUrl)
|
||||||
let deduplicatedLyrics = $derived(dedupLines(data.lrc, isHideRepeated))
|
let deduplicatedLyrics = $derived(dedupLines(data.lrc, isHideRepeated))
|
||||||
@@ -47,21 +43,9 @@
|
|||||||
// Reset when processedLrc changes (settings changed)
|
// Reset when processedLrc changes (settings changed)
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
states = processedLrc.map(line => new Array(line.totalLen).fill('unseen'))
|
states = processedLrc.map(line => new Array(line.totalLen).fill('unseen'))
|
||||||
li = 0
|
li = 0; wi = 0; inp = ""; startTime = 0; statsHistory = []
|
||||||
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
|
// For computing stats
|
||||||
let startTime = $state(0)
|
let startTime = $state(0)
|
||||||
let now = $state(Date.now())
|
let now = $state(Date.now())
|
||||||
@@ -150,26 +134,6 @@
|
|||||||
}
|
}
|
||||||
$effect(() => inputChanged(inp, false))
|
$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
|
// Result is stored on the server and is fetched from a separate results page
|
||||||
async function submitResult() {
|
async function submitResult() {
|
||||||
const res = await API.saveResult({
|
const res = await API.saveResult({
|
||||||
@@ -189,24 +153,11 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Window events -->
|
<svelte:window onclick={() => musicControl?.ready()} onkeydown={() => musicControl?.ready()}/>
|
||||||
<svelte:window onresize={() => caret && animateCaret(caret)} 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)}>
|
<LinearProgress percent={progress} />
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Lines -->
|
<!-- Lines -->
|
||||||
<div bind:this={lrcWrapper} class="lrc-wrapper scroll-here" lang="ja-JP">
|
<Lyrics lines={processedLrc} currentLineIndex={li} currentWordIndex={wi} {states} {settings} showCaret={true} onLineClick={() => hiddenInput.focus()} />
|
||||||
<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>
|
|
||||||
Reference in New Issue
Block a user