Spaces:
Build error
Build error
Upload 5 files
Browse files- Dockerfile +24 -0
- app/layout.tsx +19 -0
- app/page.tsx +103 -0
- next.config.js +6 -0
- package.json +22 -0
Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 使用官方輕量級的 Node.js 20 Alpine 映像
|
| 2 |
+
FROM node:20-alpine
|
| 3 |
+
|
| 4 |
+
# 設定工作目錄
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# 複製 package.json 和 package-lock.json
|
| 8 |
+
# 使用 * 確保即使 lock 檔案不存在也能運行
|
| 9 |
+
COPY package.json package-lock.json* ./
|
| 10 |
+
|
| 11 |
+
# 安裝專案依賴
|
| 12 |
+
RUN npm install
|
| 13 |
+
|
| 14 |
+
# 複製所有專案檔案到工作目錄
|
| 15 |
+
COPY . .
|
| 16 |
+
|
| 17 |
+
# 執行 Next.js 生產環境建置
|
| 18 |
+
RUN npm run build
|
| 19 |
+
|
| 20 |
+
# Hugging Face Spaces 預設使用 7860 端口
|
| 21 |
+
EXPOSE 7860
|
| 22 |
+
|
| 23 |
+
# 啟動應用程式的命令
|
| 24 |
+
CMD ["npm", "start"]
|
app/layout.tsx
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// 位於 app/layout.tsx
|
| 2 |
+
|
| 3 |
+
import type { Metadata } from "next";
|
| 4 |
+
// 修正:直接只匯入 ReactNode 這個「類型」,而不是整個 React 物件
|
| 5 |
+
import type { ReactNode } from 'react';
|
| 6 |
+
|
| 7 |
+
export const metadata: Metadata = {
|
| 8 |
+
title: "MIDI Player Lite",
|
| 9 |
+
description: "A lightweight MIDI player for Hugging Face Spaces.",
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
// 修正:直接使用 ReactNode,不再需要寫 React.ReactNode
|
| 13 |
+
export default function RootLayout({ children }: { children: ReactNode }) {
|
| 14 |
+
return (
|
| 15 |
+
<html lang="en">
|
| 16 |
+
<body>{children}</body>
|
| 17 |
+
</html>
|
| 18 |
+
);
|
| 19 |
+
}
|
app/page.tsx
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useEffect, useRef, useState } from 'react';
|
| 3 |
+
import MidiPlayer from 'midi-player-js';
|
| 4 |
+
import Soundfont from 'soundfont-player';
|
| 5 |
+
|
| 6 |
+
// 定義 Soundfont Player 的 instrument 類型,以獲得更好的 TypeScript 支援
|
| 7 |
+
interface Instrument {
|
| 8 |
+
play(noteName: string, when?: number, options?: any): void;
|
| 9 |
+
stop(noteName?: string): void;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export default function Home() {
|
| 13 |
+
// 1. 將 useRef 的初始值設為 null,避免在伺服器端存取 window
|
| 14 |
+
const audioCtx = useRef<AudioContext | null>(null);
|
| 15 |
+
const instrument = useRef<Instrument | null>(null);
|
| 16 |
+
const player = useRef<MidiPlayer.Player | null>(null);
|
| 17 |
+
const [isLoaded, setIsLoaded] = useState(false);
|
| 18 |
+
const [nowPlaying, setNowPlaying] = useState("");
|
| 19 |
+
|
| 20 |
+
useEffect(() => {
|
| 21 |
+
// 2. 在 useEffect 中才建立 AudioContext,確保只在瀏覽器環境中執行
|
| 22 |
+
if (!audioCtx.current) {
|
| 23 |
+
audioCtx.current = new (window.AudioContext || (window as any).webkitAudioContext)();
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// 載入 Soundfont 和 MIDI 播放器
|
| 27 |
+
Soundfont.instrument(audioCtx.current, 'acoustic_grand_piano').then(inst => {
|
| 28 |
+
instrument.current = inst;
|
| 29 |
+
player.current = new MidiPlayer.Player(event => {
|
| 30 |
+
if (!instrument.current) return;
|
| 31 |
+
if (event.name === 'Note on' && event.velocity > 0) {
|
| 32 |
+
instrument.current.play(event.noteName);
|
| 33 |
+
}
|
| 34 |
+
if (event.name === 'Note off' || (event.name === 'Note on' && event.velocity === 0)) {
|
| 35 |
+
instrument.current.stop(event.noteName);
|
| 36 |
+
}
|
| 37 |
+
});
|
| 38 |
+
player.current.on('end', () => setNowPlaying(""));
|
| 39 |
+
setIsLoaded(true);
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
// 3. 元件卸載時的清理函數,關閉 AudioContext
|
| 43 |
+
return () => {
|
| 44 |
+
if (audioCtx.current && audioCtx.current.state !== 'closed') {
|
| 45 |
+
audioCtx.current.close();
|
| 46 |
+
}
|
| 47 |
+
};
|
| 48 |
+
}, []); // 空依賴陣列確保此 effect 只執行一次
|
| 49 |
+
|
| 50 |
+
const loadMidiAndPlay = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 51 |
+
const file = e.target.files?.[0];
|
| 52 |
+
if (file && player.current) {
|
| 53 |
+
const reader = new FileReader();
|
| 54 |
+
reader.onload = (event) => {
|
| 55 |
+
if (event.target?.result) {
|
| 56 |
+
player.current?.loadArrayBuffer(event.target.result as ArrayBuffer);
|
| 57 |
+
setNowPlaying(`正在播放: ${file.name}`);
|
| 58 |
+
player.current?.play();
|
| 59 |
+
}
|
| 60 |
+
};
|
| 61 |
+
reader.readAsArrayBuffer(file);
|
| 62 |
+
}
|
| 63 |
+
};
|
| 64 |
+
|
| 65 |
+
const handlePlay = () => {
|
| 66 |
+
if (player.current?.isPlaying()) return;
|
| 67 |
+
player.current?.play();
|
| 68 |
+
};
|
| 69 |
+
|
| 70 |
+
const handleStop = () => {
|
| 71 |
+
player.current?.stop();
|
| 72 |
+
setNowPlaying("");
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
return (
|
| 76 |
+
<main style={{ padding: "2rem", fontFamily: "sans-serif", maxWidth: "600px", margin: "auto", color: "#333" }}>
|
| 77 |
+
<h1 style={{ textAlign: "center", color: "#111" }}>🎵 MIDI Player Lite</h1>
|
| 78 |
+
<div style={{ border: "1px solid #ddd", padding: "2rem", borderRadius: "8px", textAlign: "center", background: "#f9f9f9" }}>
|
| 79 |
+
{isLoaded ? (
|
| 80 |
+
<>
|
| 81 |
+
<p>請選擇一個 .mid 或 .midi 檔案來播放。</p>
|
| 82 |
+
<input
|
| 83 |
+
type="file"
|
| 84 |
+
accept=".mid,.midi"
|
| 85 |
+
onChange={loadMidiAndPlay}
|
| 86 |
+
style={{ display: 'block', margin: '1rem auto' }}
|
| 87 |
+
/>
|
| 88 |
+
<div style={{ marginTop: "1rem", display: 'flex', gap: '10px', justifyContent: 'center' }}>
|
| 89 |
+
<button onClick={handlePlay} disabled={!player.current?.getSong() || player.current?.isPlaying()}>播放</button>
|
| 90 |
+
<button onClick={handleStop} disabled={!player.current?.getSong()}>停止</button>
|
| 91 |
+
</div>
|
| 92 |
+
{nowPlaying && <p style={{marginTop: "1.5rem", color: "#555", fontStyle: 'italic'}}>{nowPlaying}</p>}
|
| 93 |
+
</>
|
| 94 |
+
) : (
|
| 95 |
+
<p>正在載入聲音引擎...</p>
|
| 96 |
+
)}
|
| 97 |
+
</div>
|
| 98 |
+
<footer style={{textAlign: 'center', marginTop: '2rem', color: '#888', fontSize: '0.9em'}}>
|
| 99 |
+
<p>Deployed on Hugging Face Spaces</p>
|
| 100 |
+
</footer>
|
| 101 |
+
</main>
|
| 102 |
+
);
|
| 103 |
+
}
|
next.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('next').NextConfig} */
|
| 2 |
+
const nextConfig = {
|
| 3 |
+
reactStrictMode: true,
|
| 4 |
+
};
|
| 5 |
+
|
| 6 |
+
module.exports = nextConfig;
|
package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "midiplayer-lite-hf",
|
| 3 |
+
"version": "0.1.0",
|
| 4 |
+
"private": true,
|
| 5 |
+
"scripts": {
|
| 6 |
+
"dev": "next dev",
|
| 7 |
+
"build": "next build",
|
| 8 |
+
"start": "next start -p 7860"
|
| 9 |
+
},
|
| 10 |
+
"dependencies": {
|
| 11 |
+
"next": "^14.2.3",
|
| 12 |
+
"react": "^18.2.0",
|
| 13 |
+
"react-dom": "^18.2.0",
|
| 14 |
+
"midi-player-js": "^2.0.5",
|
| 15 |
+
"soundfont-player": "^0.8.5"
|
| 16 |
+
},
|
| 17 |
+
"devDependencies": {
|
| 18 |
+
"@types/node": "20.14.9",
|
| 19 |
+
"@types/react": "18.3.3",
|
| 20 |
+
"typescript": "5.5.2"
|
| 21 |
+
}
|
| 22 |
+
}
|