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:
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user