完成Web ASM
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>NCM Dump</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<script src="/wasm_exec.js"></script>
|
||||
</head>
|
||||
<body class="bg-slate-950 text-slate-100 font-sans antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "ncmdump-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^14.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"postcss": "^8.4.39",
|
||||
"tailwindcss": "^3.4.6",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import Header from './components/Header'
|
||||
import FolderPicker from './components/FolderPicker'
|
||||
import FileList from './components/FileList'
|
||||
import StatusBar from './components/StatusBar'
|
||||
import { initWasm, convertNcm, embedMetadata, isWasmSupported } from './wasm'
|
||||
import type { FileItem } from './types'
|
||||
|
||||
export default function App() {
|
||||
const [wasmReady, setWasmReady] = useState(false)
|
||||
const [wasmError, setWasmError] = useState<string | null>(null)
|
||||
const [inputFiles, setInputFiles] = useState<Map<string, { file: File; name: string }>>(new Map())
|
||||
const [outputHandle, setOutputHandle] = useState<any>(null)
|
||||
const [fileItems, setFileItems] = useState<FileItem[]>([])
|
||||
const [converting, setConverting] = useState(false)
|
||||
const [progress, setProgress] = useState({ done: 0, total: 0 })
|
||||
const abortRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isWasmSupported()) {
|
||||
initWasm()
|
||||
.then(() => setWasmReady(true))
|
||||
.catch((e) => setWasmError(`WASM 加载失败: ${e.message}`))
|
||||
} else {
|
||||
setWasmError('浏览器不支持 WebAssembly')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleInputFolder = useCallback(async (dirHandle: any) => {
|
||||
const files = new Map<string, { file: File; name: string }>()
|
||||
for await (const entry of dirHandle.values()) {
|
||||
if (entry.kind === 'file' && entry.name.toLowerCase().endsWith('.ncm')) {
|
||||
const file = await entry.getFile()
|
||||
files.set(entry.name, { file, name: entry.name })
|
||||
}
|
||||
}
|
||||
setInputFiles(files)
|
||||
setFileItems(
|
||||
Array.from(files.values()).map((f) => ({
|
||||
name: f.name,
|
||||
status: 'pending' as const,
|
||||
}))
|
||||
)
|
||||
}, [])
|
||||
|
||||
const handleOutputFolder = useCallback((handle: any) => {
|
||||
setOutputHandle(handle)
|
||||
}, [])
|
||||
|
||||
const handleConvert = useCallback(async () => {
|
||||
if (!outputHandle || inputFiles.size === 0 || !wasmReady) return
|
||||
|
||||
abortRef.current = false
|
||||
setConverting(true)
|
||||
setProgress({ done: 0, total: inputFiles.size })
|
||||
|
||||
const entries = Array.from(inputFiles.values())
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
if (abortRef.current) break
|
||||
|
||||
const entry = entries[i]
|
||||
|
||||
const setStatus = (status: FileItem['status'], extra?: Partial<FileItem>) =>
|
||||
setFileItems((prev) => prev.map((item, idx) =>
|
||||
idx === i ? { ...item, status, ...extra } : item
|
||||
))
|
||||
|
||||
setStatus('converting')
|
||||
|
||||
try {
|
||||
const buf = await entry.file.arrayBuffer()
|
||||
const result = await convertNcm(buf)
|
||||
let audio = result.data
|
||||
|
||||
// Fetch cover image if available
|
||||
let coverData: Uint8Array | undefined
|
||||
if (result.coverUrl) {
|
||||
setStatus('fetching_cover')
|
||||
try {
|
||||
const resp = await fetch(result.coverUrl)
|
||||
if (resp.ok) {
|
||||
const coverBuf = await resp.arrayBuffer()
|
||||
coverData = new Uint8Array(coverBuf)
|
||||
}
|
||||
} catch {
|
||||
// cover fetch failed, proceed without it
|
||||
}
|
||||
}
|
||||
|
||||
// Embed metadata + cover
|
||||
if (result.title || result.artist || result.album || coverData) {
|
||||
setStatus('writing')
|
||||
audio = await embedMetadata(
|
||||
audio,
|
||||
result.format,
|
||||
{ title: result.title, artist: result.artist, album: result.album },
|
||||
coverData
|
||||
)
|
||||
} else {
|
||||
setStatus('writing')
|
||||
}
|
||||
|
||||
const base = entry.name.replace(/\.ncm$/i, '')
|
||||
const outName = `${base}.${result.format}`
|
||||
|
||||
const fileHandle = await outputHandle.getFileHandle(outName, { create: true })
|
||||
const writable = await fileHandle.createWritable()
|
||||
await writable.write(audio)
|
||||
await writable.close()
|
||||
|
||||
setStatus('done', { format: result.format })
|
||||
} catch (e: any) {
|
||||
setStatus('error', { error: e.message })
|
||||
}
|
||||
|
||||
setProgress({ done: i + 1, total: entries.length })
|
||||
}
|
||||
|
||||
setConverting(false)
|
||||
}, [inputFiles, outputHandle, wasmReady])
|
||||
|
||||
const handleAbort = useCallback(() => {
|
||||
abortRef.current = true
|
||||
setConverting(false)
|
||||
}, [])
|
||||
|
||||
const canConvert = inputFiles.size > 0 && outputHandle !== null && wasmReady && !converting
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-grid">
|
||||
<div className="mx-auto max-w-2xl px-4 py-8 sm:py-12">
|
||||
<Header />
|
||||
|
||||
{wasmError && (
|
||||
<div className="mt-4 animate-fade-in rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||
{wasmError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!wasmError && !wasmReady && (
|
||||
<div className="mt-4 animate-fade-in rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-3 text-center text-sm text-slate-400">
|
||||
<svg className="mx-auto mb-2 h-5 w-5 animate-spin text-indigo-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
正在加载 WASM 解密模块...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{wasmReady && (
|
||||
<>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 animate-slide-up">
|
||||
<FolderPicker
|
||||
label="输入文件夹"
|
||||
description="选择包含 .ncm 文件的文件夹"
|
||||
onSelect={handleInputFolder}
|
||||
disabled={converting}
|
||||
/>
|
||||
<FolderPicker
|
||||
label="输出文件夹"
|
||||
description="选择转换后文件的保存位置"
|
||||
onSelect={handleOutputFolder}
|
||||
disabled={converting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{inputFiles.size > 0 && (
|
||||
<div className="mt-3 animate-fade-in flex items-center gap-3">
|
||||
<StatusBar progress={progress} />
|
||||
<button
|
||||
onClick={handleConvert}
|
||||
disabled={!canConvert}
|
||||
className="shrink-0 rounded-lg bg-indigo-600 px-5 py-2.5 text-sm font-medium text-white
|
||||
hover:bg-indigo-500 active:bg-indigo-700 transition-colors
|
||||
disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
开始转换
|
||||
</button>
|
||||
{converting && (
|
||||
<button
|
||||
onClick={handleAbort}
|
||||
className="shrink-0 rounded-lg bg-slate-700 px-4 py-2.5 text-sm font-medium text-slate-300
|
||||
hover:bg-slate-600 transition-colors"
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileItems.length > 0 && (
|
||||
<FileList items={fileItems} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { FileItem as FileItemType } from '../types'
|
||||
import FileRow from './FileRow'
|
||||
|
||||
interface Props {
|
||||
items: FileItemType[]
|
||||
}
|
||||
|
||||
export default function FileList({ items }: Props) {
|
||||
const done = items.filter((f) => f.status === 'done').length
|
||||
const error = items.filter((f) => f.status === 'error').length
|
||||
const total = items.length
|
||||
|
||||
return (
|
||||
<div className="mt-6 animate-fade-in">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-300">文件列表</h3>
|
||||
<span className="rounded-full bg-slate-800 px-2 py-0.5 text-xs text-slate-400">
|
||||
{done + error}/{total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{items.map((item, idx) => (
|
||||
<FileRow key={idx} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { FileItem } from '../types'
|
||||
|
||||
interface Props {
|
||||
item: FileItem
|
||||
}
|
||||
|
||||
export default function FileRow({ item }: Props) {
|
||||
const ext = item.name.replace(/\.ncm$/i, item.format ? `.${item.format}` : '')
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/60 px-4 py-3 transition-colors hover:border-slate-700">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-slate-200">
|
||||
{item.name}
|
||||
</p>
|
||||
{item.status === 'done' && item.format && (
|
||||
<p className="mt-0.5 truncate text-xs text-slate-500">
|
||||
{'→ '}{ext}
|
||||
</p>
|
||||
)}
|
||||
{item.status === 'error' && item.error && (
|
||||
<p className="mt-0.5 truncate text-xs text-red-400">
|
||||
{item.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{item.status === 'pending' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-slate-600" />
|
||||
等待中
|
||||
</span>
|
||||
)}
|
||||
{item.status === 'converting' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-indigo-400">
|
||||
<svg className="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
解密中
|
||||
</span>
|
||||
)}
|
||||
{item.status === 'fetching_cover' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-cyan-400">
|
||||
<svg className="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
获取封面
|
||||
</span>
|
||||
)}
|
||||
{item.status === 'writing' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-amber-400">
|
||||
<svg className="h-3.5 w-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
写入元数据
|
||||
</span>
|
||||
)}
|
||||
{item.status === 'done' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-emerald-400">
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
完成
|
||||
</span>
|
||||
)}
|
||||
{item.status === 'error' && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-red-400">
|
||||
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
失败
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
description: string
|
||||
onSelect: (handle: any) => void
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
export default function FolderPicker({ label, description, onSelect, disabled }: Props) {
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const pick = useCallback(async () => {
|
||||
setError(null)
|
||||
try {
|
||||
if (typeof (window as any).showDirectoryPicker !== 'function') {
|
||||
setError('浏览器不支持此功能,请使用 Chrome/Edge 并通过 localhost 或 HTTPS 访问')
|
||||
return
|
||||
}
|
||||
const handle = await (window as any).showDirectoryPicker()
|
||||
setSelected(handle.name)
|
||||
onSelect(handle)
|
||||
} catch (e: any) {
|
||||
if (e.name !== 'AbortError') {
|
||||
setError(`选择文件夹失败: ${e.message || '请通过 localhost 或 HTTPS 访问'}`)
|
||||
}
|
||||
}
|
||||
}, [onSelect])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={pick}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
flex flex-col items-center gap-2 rounded-2xl border-2 border-dashed p-6 text-center w-full
|
||||
transition-all duration-200
|
||||
${selected
|
||||
? 'border-indigo-500/50 bg-indigo-500/10'
|
||||
: 'border-slate-700 bg-slate-900/50 hover:border-slate-600 hover:bg-slate-900/80'
|
||||
}
|
||||
disabled:opacity-40 disabled:cursor-not-allowed
|
||||
`}
|
||||
>
|
||||
{selected ? (
|
||||
<svg className="h-7 w-7 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-7 w-7 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 10.5v6m3-3H9m4.06-7.19l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" />
|
||||
</svg>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-300">{label}</p>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
{selected ? selected : description}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{error && (
|
||||
<p className="mt-2 text-xs text-amber-400 text-center">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export default function Header() {
|
||||
return (
|
||||
<header className="mb-8 text-center animate-fade-in">
|
||||
<div className="inline-flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-2xl bg-indigo-500/20 ring-1 ring-indigo-500/30">
|
||||
<svg className="h-5 w-5 text-indigo-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-white">
|
||||
NCM Dump
|
||||
</h1>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-400">
|
||||
本地解密网易云 .ncm 文件,无需上传
|
||||
</p>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
interface Props {
|
||||
progress: { done: number; total: number }
|
||||
}
|
||||
|
||||
export default function StatusBar({ progress }: Props) {
|
||||
const pct = progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400 mb-1">
|
||||
<span>进度</span>
|
||||
<span>{progress.done}/{progress.total} ({pct}%)</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-slate-800 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-indigo-500 transition-all duration-300"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-slate-950 text-slate-200;
|
||||
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(rgba(99, 102, 241, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(99, 102, 241, 0.03) 1px, transparent 1px);
|
||||
background-size: 64px 64px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
@apply bg-slate-700 rounded-full;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface ConvertResult {
|
||||
data: Uint8Array
|
||||
format: 'mp3' | 'flac'
|
||||
title: string
|
||||
artist: string
|
||||
album: string
|
||||
coverUrl: string
|
||||
}
|
||||
|
||||
export type FileStatus = 'pending' | 'converting' | 'fetching_cover' | 'writing' | 'done' | 'error'
|
||||
|
||||
export interface FileItem {
|
||||
name: string
|
||||
status: FileStatus
|
||||
error?: string
|
||||
format?: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
ncmdumpConvert?: (data: Uint8Array) => {
|
||||
data?: Uint8Array; format?: string; error?: string
|
||||
title?: string; artist?: string; album?: string; coverUrl?: string
|
||||
}
|
||||
ncmdumpEmbed?: (audio: Uint8Array, format: string, opts: {
|
||||
title?: string; artist?: string; album?: string; coverData?: Uint8Array
|
||||
}) => { data?: Uint8Array; error?: string }
|
||||
ncmdumpOnReady?: () => void
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ConvertResult } from './types'
|
||||
|
||||
let ready = false
|
||||
let initPromise: Promise<void> | null = null
|
||||
|
||||
export async function initWasm(): Promise<void> {
|
||||
if (ready) return
|
||||
if (initPromise) return initPromise
|
||||
|
||||
initPromise = new Promise<void>((resolve) => {
|
||||
window.ncmdumpOnReady = () => {
|
||||
ready = true
|
||||
resolve()
|
||||
}
|
||||
|
||||
const go = new (window as any).Go()
|
||||
WebAssembly.instantiateStreaming(fetch('/ncmdump.wasm'), go.importObject).then(
|
||||
(result) => {
|
||||
go.run(result.instance)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
return initPromise
|
||||
}
|
||||
|
||||
export async function convertNcm(data: ArrayBuffer): Promise<ConvertResult> {
|
||||
if (!ready) throw new Error('WASM 未就绪')
|
||||
|
||||
const input = new Uint8Array(data)
|
||||
const result = window.ncmdumpConvert!(input)
|
||||
|
||||
if (result.error) throw new Error(result.error)
|
||||
|
||||
return {
|
||||
data: new Uint8Array(result.data!),
|
||||
format: result.format as 'mp3' | 'flac',
|
||||
title: result.title || '',
|
||||
artist: result.artist || '',
|
||||
album: result.album || '',
|
||||
coverUrl: result.coverUrl || '',
|
||||
}
|
||||
}
|
||||
|
||||
export async function embedMetadata(
|
||||
audio: Uint8Array,
|
||||
format: string,
|
||||
meta: { title: string; artist: string; album: string },
|
||||
coverData?: Uint8Array
|
||||
): Promise<Uint8Array> {
|
||||
if (!ready) throw new Error('WASM 未就绪')
|
||||
|
||||
const opts: any = { title: meta.title, artist: meta.artist, album: meta.album }
|
||||
if (coverData) {
|
||||
opts.coverData = coverData
|
||||
}
|
||||
|
||||
const result = window.ncmdumpEmbed!(audio, format, opts)
|
||||
if (result.error) throw new Error(result.error)
|
||||
|
||||
return new Uint8Array(result.data!)
|
||||
}
|
||||
|
||||
export function isWasmSupported(): boolean {
|
||||
return typeof WebAssembly === 'object' && typeof (window as any).Go !== 'undefined'
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.3s ease-out',
|
||||
'slide-up': 'slideUp 0.4s ease-out',
|
||||
'pulse-ring': 'pulseRing 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0', transform: 'translateY(4px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(12px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
pulseRing: {
|
||||
'0%, 100%': { boxShadow: '0 0 0 0 rgba(99, 102, 241, 0)' },
|
||||
'50%': { boxShadow: '0 0 0 8px rgba(99, 102, 241, 0.15)' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/types.ts","./src/wasm.ts","./src/components/FileList.tsx","./src/components/FileRow.tsx","./src/components/FolderPicker.tsx","./src/components/Header.tsx","./src/components/StatusBar.tsx"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
build: {
|
||||
outDir: '../cmd/server/static/dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user