[+] Location state
This commit is contained in:
@@ -5,7 +5,8 @@ import { isKanji } from 'wanakana'
|
||||
// Please put OPENAI_API_KEY in your environment variables.
|
||||
const client = new OpenAI()
|
||||
const req = {
|
||||
model: "gpt-5.1-chat-latest",
|
||||
// model: "gpt-5.1-chat-latest",
|
||||
model: "gpt-4.1",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from "./$types"
|
||||
import { goto } from "$app/navigation";
|
||||
import AppBar from "../../../components/appbar/AppBar.svelte";
|
||||
import Button from "../../../components/Button.svelte";
|
||||
import SongInfo from "../../../components/listitem/SongInfo.svelte";
|
||||
@@ -20,6 +21,25 @@
|
||||
await API.saveUserData(data.user.data)
|
||||
isFavorite = !isFavorite
|
||||
}
|
||||
|
||||
async function startPractice() {
|
||||
if (songs.length === 0) return;
|
||||
|
||||
const firstIndex = 0;
|
||||
const firstSong = songs[firstIndex];
|
||||
|
||||
data.user.data.loc = {
|
||||
currentPlaylistId: meta.id,
|
||||
currentSongIndex: firstIndex,
|
||||
playedSongIds: [firstSong.id],
|
||||
playMode: 'sequential',
|
||||
isFinished: false,
|
||||
lastResultId: null
|
||||
};
|
||||
|
||||
await API.saveUserData({ loc: data.user.data.loc });
|
||||
goto(`/song/${firstSong.id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppBar title="歌单详情" right={[
|
||||
@@ -39,7 +59,7 @@
|
||||
<div class="m3-font-body-small text-surface-variant">歌曲数: {meta.trackCount}</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button>开始练习</Button>
|
||||
<Button onclick={startPractice}>开始练习</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { error } from '@sveltejs/kit'
|
||||
import { getResult } from '$lib/server/result'
|
||||
import type { PageServerLoad } from './$types'
|
||||
import { getLyricsProcessed, getSongRaw } from "$lib/server/songs.ts";
|
||||
import { getLyricsProcessed, getSongRaw, getPlaylist } from "$lib/server/songs.ts";
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
const { user } = await parent()
|
||||
const result = await getResult(params.id)
|
||||
if (!result) throw error(404, 'Result not found')
|
||||
|
||||
@@ -12,6 +13,7 @@ export const load: PageServerLoad = async ({ params }) => {
|
||||
return {
|
||||
result: structuredClone(result),
|
||||
lrc: await getLyricsProcessed(result.songId),
|
||||
song
|
||||
song,
|
||||
playlist: await user.data?.loc?.currentPlaylistId?.let(getPlaylist)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,34 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from "./$types";
|
||||
import { goto } from "$app/navigation";
|
||||
import AppBar from "../../../components/appbar/AppBar.svelte";
|
||||
import { artistAndAlbum } from "../../../shared/tools";
|
||||
import Button from "../../../components/Button.svelte";
|
||||
import type { PageProps } from "./$types"
|
||||
import { goto } from "$app/navigation"
|
||||
import AppBar from "../../../components/appbar/AppBar.svelte"
|
||||
import { artistAndAlbum } from "../../../shared/tools"
|
||||
import Button from "../../../components/Button.svelte"
|
||||
|
||||
import Chart from "chart.js/auto";
|
||||
import Chart from "chart.js/auto"
|
||||
import { API } from "$lib/client"
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let { result, lrc } = data;
|
||||
import type { NeteaseSong, UserData } from "../../../shared/types"
|
||||
|
||||
let { data }: PageProps = $props()
|
||||
|
||||
// Destructure result for easier access
|
||||
let { totalTyped, startTime, endTime, totalRight, statsHistory, songId } = result;
|
||||
let { totalTyped, startTime, endTime, totalRight, statsHistory, songId } = data.result
|
||||
|
||||
// Calculate duration for display
|
||||
let duration = endTime - startTime;
|
||||
let duration = endTime - startTime
|
||||
|
||||
let fields = [
|
||||
{
|
||||
label: "速度",
|
||||
value: Math.round(totalTyped / (Math.max(1, duration) / 60000)),
|
||||
},
|
||||
{
|
||||
label: "准确率",
|
||||
value:
|
||||
totalTyped === 0
|
||||
? 100
|
||||
: Math.round((totalRight / totalTyped) * 10000) / 100,
|
||||
},
|
||||
{ label: "速度", value: Math.round(totalTyped / (Math.max(1, duration) / 60000)) },
|
||||
{ label: "准确率", value: totalTyped === 0 ? 100 : Math.round((totalRight / totalTyped) * 10000) / 100 },
|
||||
{ label: "实时率", value: data.result.realTimeFactor.toFixed(2) + "x" },
|
||||
{ label: "字数", value: totalTyped },
|
||||
];
|
||||
{ label: "用时", value: new Date(duration).toISOString().slice(14, 19) },
|
||||
{ label: "歌曲时长", value: new Date(data.song.dt).toISOString().slice(14, 19) }
|
||||
]
|
||||
|
||||
let chartCanvas: HTMLCanvasElement;
|
||||
let chart: Chart;
|
||||
let chartCanvas: HTMLCanvasElement
|
||||
let chart: Chart
|
||||
|
||||
$effect(() => {
|
||||
if (chartCanvas && statsHistory.length > 0) {
|
||||
@@ -44,7 +39,7 @@
|
||||
datasets: [
|
||||
{
|
||||
label: "速度 (CPM)",
|
||||
data: statsHistory.map((h: any) => h.cpm),
|
||||
data: statsHistory.map((h: { cpm: number }) => h.cpm),
|
||||
tension: 0.4,
|
||||
pointRadius: 0,
|
||||
fill: true,
|
||||
@@ -53,7 +48,7 @@
|
||||
},
|
||||
{
|
||||
label: "准确率 (%)",
|
||||
data: statsHistory.map((h: any) => h.acc),
|
||||
data: statsHistory.map((h: { acc: number }) => h.acc),
|
||||
tension: 0.4,
|
||||
yAxisID: "y1",
|
||||
pointRadius: 0,
|
||||
@@ -88,13 +83,73 @@
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (chart) chart.destroy();
|
||||
};
|
||||
});
|
||||
if (chart) chart.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
// Playlist Navigation Logic
|
||||
let nextSongId = $state<number | null>(null)
|
||||
let isPlaylistFinished = $state(false)
|
||||
|
||||
const loc = data.user.data.loc
|
||||
const playlist = data.playlist
|
||||
// Check if this is the latest result for the current playlist session
|
||||
const isCurrentResult = loc?.lastResultId === data.result._id
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
data.user.data.loc = newLoc
|
||||
await API.saveUserData({ loc: newLoc })
|
||||
goto(`/song/${nextSongId}`, { replaceState: true })
|
||||
} else if (isPlaylistFinished) {
|
||||
// Clear playlist state
|
||||
await API.saveUserData({ loc: null as any })
|
||||
goto(`/playlist/${data.playlist!.id}`)
|
||||
} else {
|
||||
// Restart
|
||||
goto(`/song/${songId}`, { replaceState: true })
|
||||
}
|
||||
}
|
||||
|
||||
let buttonText = $derived(
|
||||
nextSongId !== null ? "下一首" :
|
||||
isPlaylistFinished ? "返回歌单" :
|
||||
"再来一次"
|
||||
)
|
||||
</script>
|
||||
|
||||
<AppBar title={data.song.name} sub={artistAndAlbum(data.song)} />
|
||||
@@ -125,6 +180,6 @@
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<div class="hbox justify-end pt-8px pb-16px w-full">
|
||||
<Button w-full onclick={() => goto(`/song/${songId}`)}>下一首</Button>
|
||||
<Button big w-full onclick={handleNext}>{buttonText}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -148,10 +148,17 @@
|
||||
const res = await API.saveResult({
|
||||
songId: data.song.id,
|
||||
endTime: Date.now(),
|
||||
realTimeFactor: (Date.now() - startTime) / (data.song.dt / 1000),
|
||||
realTimeFactor: data.song.dt / (Date.now() - startTime),
|
||||
totalTyped, totalRight, startTime, statsHistory
|
||||
})
|
||||
goto(`/results/${res.id}`)
|
||||
|
||||
if (data.user.data.loc?.currentPlaylistId) {
|
||||
data.user.data.loc.isFinished = true;
|
||||
data.user.data.loc.lastResultId = res.id;
|
||||
await API.saveUserData({ loc: data.user.data.loc });
|
||||
}
|
||||
|
||||
goto(`/results/${res.id}`, { replaceState: true })
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
+71
-61
@@ -1,96 +1,106 @@
|
||||
export type LyricSegment = string | string[]
|
||||
export type LyricSegment = string | string[];
|
||||
|
||||
export interface LyricLine {
|
||||
time: string
|
||||
lyric: LyricSegment[]
|
||||
time: string;
|
||||
lyric: LyricSegment[];
|
||||
}
|
||||
|
||||
export interface Song {
|
||||
title: string
|
||||
lyrics: LyricLine[]
|
||||
title: string;
|
||||
lyrics: LyricLine[];
|
||||
}
|
||||
|
||||
export interface NeteaseSong {
|
||||
id: number
|
||||
name: string
|
||||
al: {
|
||||
id: number
|
||||
name: string
|
||||
picUrl: string
|
||||
}
|
||||
ar: { id: number, name: string }[]
|
||||
dt: number
|
||||
export interface NeteaseSong {
|
||||
id: number;
|
||||
name: string;
|
||||
al: {
|
||||
id: number;
|
||||
name: string;
|
||||
picUrl: string;
|
||||
};
|
||||
ar: { id: number; name: string }[];
|
||||
dt: number;
|
||||
}
|
||||
|
||||
export interface NeteasePlaylist {
|
||||
id: number
|
||||
name: string
|
||||
coverImgUrl: string
|
||||
creator: {
|
||||
nickname: string
|
||||
}
|
||||
tracks: NeteaseSong[]
|
||||
id: number;
|
||||
name: string;
|
||||
coverImgUrl: string;
|
||||
creator: {
|
||||
nickname: string;
|
||||
};
|
||||
tracks: NeteaseSong[];
|
||||
}
|
||||
|
||||
export interface ProcessedLyricLine {
|
||||
jp: LyricSegment[]
|
||||
kanji: string
|
||||
hiragana: string
|
||||
cleanHiragana: string
|
||||
romaji: string
|
||||
cleanRomaji: string
|
||||
jp: LyricSegment[];
|
||||
kanji: string;
|
||||
hiragana: string;
|
||||
cleanHiragana: string;
|
||||
romaji: string;
|
||||
cleanRomaji: string;
|
||||
}
|
||||
|
||||
export interface DisplayCharacter {
|
||||
char: string
|
||||
state: 'correct' | 'incorrect' | 'untyped' | 'ignored'
|
||||
originalSegment?: LyricSegment
|
||||
segmentHiragana?: string
|
||||
char: string;
|
||||
state: "correct" | "incorrect" | "untyped" | "ignored";
|
||||
originalSegment?: LyricSegment;
|
||||
segmentHiragana?: string;
|
||||
}
|
||||
|
||||
export interface GameStats {
|
||||
wpm: number
|
||||
accuracy: number
|
||||
totalTyped: number
|
||||
totalCorrect: number
|
||||
startTime: number
|
||||
totalTime: number
|
||||
wpm: number;
|
||||
accuracy: number;
|
||||
totalTyped: number;
|
||||
totalCorrect: number;
|
||||
startTime: number;
|
||||
totalTime: number;
|
||||
}
|
||||
|
||||
export interface UserDocument {
|
||||
_id?: any
|
||||
registUA: string
|
||||
createdAt: Date
|
||||
sessions: string[]
|
||||
syncCode?: string
|
||||
syncCodeCreated?: Date
|
||||
_id?: any;
|
||||
registUA: string;
|
||||
createdAt: Date;
|
||||
sessions: string[];
|
||||
syncCode?: string;
|
||||
syncCodeCreated?: Date;
|
||||
|
||||
// User data
|
||||
data: UserData
|
||||
data: UserData;
|
||||
}
|
||||
|
||||
export interface ResultDocument {
|
||||
_id?: any
|
||||
userId?: any // Optional, if we want to link to user
|
||||
songId: number
|
||||
totalTyped: number
|
||||
totalRight: number
|
||||
startTime: number
|
||||
endTime: number
|
||||
realTimeFactor: number
|
||||
statsHistory: { t: number, cpm: number, acc: number }[]
|
||||
createdAt: Date
|
||||
_id?: any;
|
||||
userId?: any; // Optional, if we want to link to user
|
||||
songId: number;
|
||||
totalTyped: number;
|
||||
totalRight: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
realTimeFactor: number;
|
||||
statsHistory: { t: number; cpm: number; acc: number }[];
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export const typingSettingsDefault = {
|
||||
isFuri: true,
|
||||
allKata: false,
|
||||
showRomaji: true,
|
||||
showRomajiOnError: true
|
||||
}
|
||||
showRomajiOnError: true,
|
||||
};
|
||||
|
||||
export interface UserData {
|
||||
myPlaylists?: number[]
|
||||
playHistory?: GameStats[]
|
||||
typingSettings?: typeof typingSettingsDefault
|
||||
}
|
||||
myPlaylists?: number[];
|
||||
playHistory?: GameStats[];
|
||||
typingSettings?: typeof typingSettingsDefault;
|
||||
|
||||
// Playlist state
|
||||
loc?: {
|
||||
currentPlaylistId: number;
|
||||
currentSongIndex: number;
|
||||
playedSongIds: number[];
|
||||
playMode: "random" | "sequential";
|
||||
isFinished: boolean;
|
||||
lastResultId: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user