karaoke(app): port single-device playlist from partiverse + tests

点歌单本地管理 — 添加/上移/下移/置顶/删除 + 10 秒撤销倒计时 + YouTube 一键
搜,无 room / 无 ws。删掉了 partiverse 那套 yopu 和弦抓取 / LLM 聊天点歌 /
QR 码(依赖后端,对单机无意义)。logic 全 immutable,21 个 vitest 覆盖
边界(首位上移 noop / 末位下移 noop / 缺失 id / 不变性)。
This commit is contained in:
Fam Zheng
2026-05-14 15:32:22 +01:00
parent 78f84d4225
commit fbd6e3cb9c
8 changed files with 3414 additions and 0 deletions
@@ -0,0 +1,49 @@
// Playlist 不可变操作。所有函数纯,返回新数组。
export interface Song {
id: number
singer: string
title: string
}
export type Direction = 'up' | 'down' | 'first'
export function nextId(playlist: Song[]): number {
let max = 0
for (const s of playlist) if (s.id > max) max = s.id
return max + 1
}
export function addSong(playlist: Song[], singer: string, title: string): Song[] {
const s = singer.trim()
const t = title.trim()
if (!s || !t) return playlist
return [...playlist, { id: nextId(playlist), singer: s, title: t }]
}
export function deleteSong(playlist: Song[], songId: number): Song[] {
return playlist.filter((s) => s.id !== songId)
}
export function moveSong(playlist: Song[], songId: number, direction: Direction): Song[] {
const idx = playlist.findIndex((s) => s.id === songId)
if (idx === -1) return playlist
const next = [...playlist]
const [song] = next.splice(idx, 1)
if (direction === 'first') {
next.unshift(song)
} else if (direction === 'up') {
next.splice(Math.max(0, idx - 1), 0, song)
} else {
// 'down': insert at idx + 1 of original. After splice, original idx + 1
// becomes position idx in `next`. So inserting at idx puts the song before
// the element that *was* at idx + 1 — we want *after* it, hence idx + 1.
next.splice(Math.min(next.length, idx + 1), 0, song)
}
return next
}
export function youtubeSearchUrl(song: Song): string {
const q = encodeURIComponent(`${song.singer} ${song.title}`)
return `https://www.youtube.com/results?search_query=${q}`
}
@@ -0,0 +1,32 @@
import type { Song } from './playlist'
interface PersistedState {
playlist: Song[]
}
const KEY = 'karaoke:v1'
function isBrowser(): boolean {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
}
export function loadState(): PersistedState {
if (!isBrowser()) return { playlist: [] }
try {
const raw = window.localStorage.getItem(KEY)
if (!raw) return { playlist: [] }
const parsed = JSON.parse(raw) as Partial<PersistedState>
return { playlist: parsed.playlist ?? [] }
} catch {
return { playlist: [] }
}
}
export function saveState(state: PersistedState): void {
if (!isBrowser()) return
try {
window.localStorage.setItem(KEY, JSON.stringify(state))
} catch {
// ignore
}
}