切換日文版
選單再度切回繁體中文.
以下是程式碼
#!/usr/bin/env python3
from flask import Flask, request, render_template_string, jsonify
import subprocess
import os
import re
import random
from pathlib import Path
import threading
import time
app = Flask(__name__)
# ===== 設定區 =====
MIDI_PORT = "24:0"
MIDI_FOLDER = Path.home() / "midi"
_port_file = Path.home() / "sc8850_port.txt"
if _port_file.exists():
_p = _port_file.read_text().strip()
if _p:
MIDI_PORT = _p
# ==================
DEFAULT_LANG = "zh-Hant"
# ===== 後端訊息多語系(前端會用 X-Lang 標頭告知語系)=====
MSG = {
"zh-Hant": {
"bad_path": "檔案不存在或不在允許目錄",
"added": "✓ 已將「{name}」加入點歌佇列",
"no_selection": "未選擇任何檔案",
"added_batch": "✓ 已成功將 {n} 首歌加入排隊清單",
"skipped": "⏭ 已切歌",
"stopped": "⏹ 已停止並清空佇列",
"bad_format": "檔案格式錯誤",
"uploaded": "✓ 已上傳並設為插播:{name}",
"dir_invalid": "目錄名稱無效",
"dir_exists": "目錄已存在:{name}",
"dir_created": "✓ 已建立 ~/midi/{name}",
"no_midi": "未找到 SC-8850 或其他 MIDI 輸出埠,請確認已接上並開機",
"aplay_fail": "無法執行 aplaymidi:{e}",
"port_found": "✓ 已偵測到 MIDI 埠:{port}",
"queue_shuffled": "🔀 已隨機重排等待清單",
"queue_too_short": "清單中至少要有 2 首歌才能重排",
},
"en": {
"bad_path": "File not found or outside the allowed folder",
"added": "✓ Added \"{name}\" to the queue",
"no_selection": "No files selected",
"added_batch": "✓ Added {n} song(s) to the queue",
"skipped": "⏭ Skipped",
"stopped": "⏹ Stopped and cleared the queue",
"bad_format": "Invalid file format",
"uploaded": "✓ Uploaded and queued next: {name}",
"dir_invalid": "Invalid folder name",
"dir_exists": "Folder already exists: {name}",
"dir_created": "✓ Created ~/midi/{name}",
"no_midi": "No SC-8850 or other MIDI output port found. Check that it is connected and powered on",
"aplay_fail": "Cannot run aplaymidi: {e}",
"port_found": "✓ MIDI port detected: {port}",
"queue_shuffled": "🔀 Waiting queue shuffled",
"queue_too_short": "Need at least 2 songs in the queue to shuffle",
},
"ja": {
"bad_path": "ファイルが存在しないか、許可されたフォルダ外です",
"added": "✓「{name}」をリクエストに追加しました",
"no_selection": "ファイルが選択されていません",
"added_batch": "✓ {n} 曲をリクエストに追加しました",
"skipped": "⏭ 曲をスキップしました",
"stopped": "⏹ 停止してキューを空にしました",
"bad_format": "ファイル形式が正しくありません",
"uploaded": "✓ アップロードして次に再生します:{name}",
"dir_invalid": "フォルダ名が無効です",
"dir_exists": "フォルダは既に存在します:{name}",
"dir_created": "✓ ~/midi/{name} を作成しました",
"no_midi": "SC-8850 などの MIDI 出力ポートが見つかりません。接続と電源を確認してください",
"aplay_fail": "aplaymidi を実行できません:{e}",
"port_found": "✓ MIDI ポートを検出しました:{port}",
"queue_shuffled": "🔀 待機リストをシャッフルしました",
"queue_too_short": "シャッフルするには 2 曲以上必要です",
},
}
def get_lang() -> str:
try:
lang = request.headers.get("X-Lang", DEFAULT_LANG)
except RuntimeError:
lang = DEFAULT_LANG
return lang if lang in MSG else DEFAULT_LANG
def m(key: str, **kw) -> str:
return MSG[get_lang()][key].format(**kw)
current_process = None
current_playing_path = ""
current_playing_name = ""
play_queue = []
shuffle_enabled = False
queue_lock = threading.Lock()
def safe_name(name: str) -> str:
name = os.path.basename(name)
name = re.sub(r'[^\w\u4e00-\u9fff\-_. ()]', '_', name)
return name.strip() or "unnamed"
def get_rel_name(path_str: str) -> str:
try:
return Path(path_str).relative_to(MIDI_FOLDER).as_posix()
except ValueError:
return Path(path_str).name
def is_safe_midi_path(path_str: str) -> bool:
"""僅允許 ~/midi 底下的真實檔案"""
if not path_str or not os.path.isfile(path_str):
return False
try:
Path(path_str).resolve().relative_to(MIDI_FOLDER.resolve())
return True
except ValueError:
return False
def send_midi_panic():
"""產生並快速播放一個包含所有聲道 All Notes Off / Reset 的極短 MIDI 檔,清除 SC-8850 殘留音"""
try:
header = b'MThd\x00\x00\x00\x06\x00\x00\x00\x01\x01\xe0'
track_events = bytearray()
for ch in range(16):
status = 0xB0 | ch
# CC 121: Reset All Controllers
track_events.extend(b'\x00' + bytes([status, 0x79, 0x00]))
# CC 64: Damper Pedal Off (取消延音)
track_events.extend(b'\x00' + bytes([status, 0x40, 0x00]))
# CC 123: All Notes Off (關閉所有發聲中音符)
track_events.extend(b'\x00' + bytes([status, 0x7B, 0x00]))
track_events.extend(b'\x00\xff\x2f\x00')
track_header = b'MTrk' + len(track_events).to_bytes(4, 'big')
midi_data = header + track_header + bytes(track_events)
tmp_path = MIDI_FOLDER / ".panic_reset.mid"
tmp_path.write_bytes(midi_data)
subprocess.run(["aplaymidi", "-p", MIDI_PORT, str(tmp_path)], timeout=1, stderr=subprocess.DEVNULL)
except Exception:
pass
def find_midi_files():
files = []
if not MIDI_FOLDER.exists():
return files
for p in MIDI_FOLDER.rglob("*"):
if p.is_file() and p.suffix.lower() in (".mid", ".midi"):
rel = p.relative_to(MIDI_FOLDER)
files.append({"path": str(p), "name": str(rel)})
files.sort(key=lambda x: x["name"].lower())
return files
def list_dirs():
dirs = ["."]
if not MIDI_FOLDER.exists():
return dirs
for p in MIDI_FOLDER.rglob("*"):
if p.is_dir():
rel = str(p.relative_to(MIDI_FOLDER))
if rel and rel != ".":
dirs.append(rel)
dirs.sort()
return dirs
def detect_midi_port():
"""用 aplaymidi -l 偵測 SC-8850 / Roland,更新 MIDI_PORT"""
global MIDI_PORT
try:
out = subprocess.check_output(
["aplaymidi", "-l"], text=True, stderr=subprocess.DEVNULL
)
except Exception as e:
return False, m("aplay_fail", e=e)
priority, others = [], []
for line in out.splitlines():
line_lower = line.lower()
if "client" in line_lower and "port" in line_lower:
continue
parts = line.split()
if not parts or not re.match(r"^\d+:\d+$", parts[0]):
continue
port = parts[0]
if "through" in line_lower or "system" in line_lower:
continue
if any(k in line_lower for k in ["8850", "sc-8850", "sc8850", "roland", "edirol"]):
priority.append(port)
else:
others.append(port)
candidates = priority + others
if not candidates:
return False, m("no_midi")
port = candidates[0]
MIDI_PORT = port
try:
(Path.home() / "sc8850_port.txt").write_text(port + "\n")
except Exception:
pass
return True, m("port_found", port=port)
def _start_play_unlocked(path: str):
"""在已持有 queue_lock 時呼叫:開始播放指定檔"""
global current_process, current_playing_path, current_playing_name
subprocess.run(["pkill", "-f", "aplaymidi"], stderr=subprocess.DEVNULL)
send_midi_panic() # 🧹 切歌/換歌時自動清除上一首殘留音與雜音
current_playing_path = path
current_playing_name = get_rel_name(path)
current_process = subprocess.Popen(["aplaymidi", "-p", MIDI_PORT, path])
def _queue_or_play_unlocked(path: str):
"""在已持有 queue_lock 時呼叫:沒在播就播,有在播就加入佇列尾"""
global current_process, play_queue
if current_process is None or current_process.poll() is not None:
if os.path.isfile(path):
_start_play_unlocked(path)
else:
if path not in play_queue:
play_queue.append(path)
def monitor_playback():
global current_process, current_playing_path, current_playing_name, play_queue, shuffle_enabled
while True:
time.sleep(1)
with queue_lock:
if current_process is not None:
ret = current_process.poll()
if ret is not None:
current_process = None
current_playing_path = ""
current_playing_name = ""
if play_queue:
next_path = play_queue.pop(0)
if os.path.isfile(next_path):
_start_play_unlocked(next_path)
elif shuffle_enabled:
all_files = find_midi_files()
if all_files:
candidates = [f["path"] for f in all_files if f["path"] != current_playing_path]
if not candidates:
candidates = [f["path"] for f in all_files]
if candidates:
next_path = random.choice(candidates)
if os.path.isfile(next_path):
_start_play_unlocked(next_path)
threading.Thread(target=monitor_playback, daemon=True).start()
def play_next_in_queue_or_start(target_path: str):
"""外部呼叫:加鎖後點歌或排隊"""
if not is_safe_midi_path(target_path):
return False
with queue_lock:
_queue_or_play_unlocked(target_path)
return True
def skip_current():
"""切歌:只停目前這首,保留佇列,由 monitor 播下一首(或隨機曲)"""
global current_process, current_playing_path, current_playing_name, play_queue, shuffle_enabled
with queue_lock:
subprocess.run(["pkill", "-f", "aplaymidi"], stderr=subprocess.DEVNULL)
send_midi_panic() # 🧹 手動切歌時清除雜音
current_process = None
current_playing_path = ""
current_playing_name = ""
if play_queue:
next_path = play_queue.pop(0)
if os.path.isfile(next_path):
_start_play_unlocked(next_path)
elif shuffle_enabled:
all_files = find_midi_files()
if all_files:
next_path = random.choice(all_files)["path"]
if os.path.isfile(next_path):
_start_play_unlocked(next_path)
def stop_midi():
"""停止並清空佇列"""
global current_process, current_playing_path, current_playing_name, play_queue
with queue_lock:
subprocess.run(["pkill", "-f", "aplaymidi"], stderr=subprocess.DEVNULL)
send_midi_panic() # 🧹 停止播放時徹底讓音源機安靜
current_process = None
current_playing_path = ""
current_playing_name = ""
play_queue.clear()
HTML = r"""
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SC-8850 MIDI 點歌系統</title>
<style>
:root {
--bg: #0f1117;
--card: #1a1d27;
--border: #2a2e3a;
--text: #e8eaed;
--muted: #9aa0a6;
--accent: #f43f5e;
--accent-hover: #e11d48;
--success: #34d399;
--danger: #f87171;
--warning: #fbbf24;
--info: #38bdf8;
--purple: #a855f7;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans TC", "Noto Sans JP", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 20px 14px 48px;
line-height: 1.5;
}
.container { max-width: 720px; margin: 0 auto; }
.lang-bar { display: flex; justify-content: flex-end; margin-bottom: 8px; }
.lang-bar select {
background: var(--card); border: 1px solid var(--border); color: var(--text);
border-radius: 8px; padding: 6px 10px; font-size: 0.85rem; outline: none;
}
header { text-align: center; margin-bottom: 16px; }
header h1 {
font-size: 1.7rem; font-weight: 700;
background: linear-gradient(135deg, #fb7185, #38bdf8);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
header p { color: var(--muted); font-size: 0.9rem; margin-top: 4px; }
.now-playing-box {
background: linear-gradient(135deg, rgba(244,63,94,0.15), rgba(56,189,248,0.15));
border: 1px solid var(--accent);
border-radius: 14px;
padding: 14px 18px;
margin-bottom: 16px;
display: none;
align-items: center;
justify-content: space-between;
gap: 12px;
box-shadow: 0 4px 20px rgba(244,63,94,0.15);
}
.np-info { display: flex; align-items: center; gap: 12px; min-width: 0; }
.np-icon { font-size: 1.8rem; animation: bounce 1s infinite alternate; flex-shrink: 0; }
@keyframes bounce { 0% { transform: translateY(0); } 100% { transform: translateY(-4px); } }
.np-title { font-size: 0.8rem; color: var(--muted); letter-spacing: 1px; }
.np-song { font-weight: 700; font-size: 1.05rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--success); }
.tabs {
display: flex; gap: 8px; margin-bottom: 14px;
background: var(--card); padding: 4px; border-radius: 12px; border: 1px solid var(--border);
}
.tab-btn {
flex: 1; padding: 10px; border: none; background: transparent; color: var(--muted);
font-weight: 600; font-size: 0.95rem; border-radius: 8px; cursor: pointer; transition: all 0.2s;
}
.tab-btn.active { background: var(--accent); color: white; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.search-box { margin-bottom: 14px; position: relative; }
.search-input {
width: 100%; background: var(--card); border: 1px solid var(--border);
color: var(--text); border-radius: 12px; padding: 12px 16px 12px 42px;
font-size: 0.95rem; outline: none;
}
.search-input:focus { border-color: var(--accent); }
.search-icon {
position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
font-size: 1.1rem; color: var(--muted); pointer-events: none;
}
.panel {
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
padding: 12px 16px; margin-bottom: 14px; display: flex; flex-wrap: nowrap; gap: 10px;
align-items: center; justify-content: space-between;
}
.panel select {
background: var(--bg); border: 1px solid var(--border); color: var(--text);
border-radius: 8px; padding: 8px 12px; font-size: 0.9rem;
}
.actions, .actions-secondary {
display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px;
}
.btn {
flex: 1; min-width: 90px; padding: 10px 12px;
border: none; border-radius: 10px; font-size: 0.88rem; font-weight: 600; cursor: pointer;
transition: all 0.2s; display: inline-flex; align-items: center; justify-content: center; gap: 6px;
}
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-success { background: var(--success); color: #000; }
.btn-success:hover { background: #2bc387; }
.btn-danger {
background: rgba(248,113,113,0.15); color: var(--danger);
border: 1px solid rgba(248,113,113,0.3);
}
.btn-danger:hover { background: rgba(248,113,113,0.25); }
.btn-warning {
background: rgba(251,191,36,0.15); color: var(--warning);
border: 1px solid rgba(251,191,36,0.3);
}
.btn-warning:hover { background: rgba(251,191,36,0.25); }
.btn-secondary {
background: var(--card); color: var(--text); border: 1px solid var(--border);
}
.btn-secondary:hover { border-color: var(--accent); color: var(--accent); }
.btn-info {
background: rgba(56,189,248,0.15); color: var(--info);
border: 1px solid rgba(56,189,248,0.3);
}
.btn-info:hover { background: rgba(56,189,248,0.25); }
.btn-shuffle {
background: rgba(168,85,247,0.15); color: var(--purple);
border: 1px solid rgba(168,85,247,0.3);
}
.btn-shuffle:hover { background: rgba(168,85,247,0.25); }
.btn-shuffle.active {
background: var(--purple); color: white; border-color: var(--purple);
box-shadow: 0 0 12px rgba(168,85,247,0.4);
}
.status {
display: none; padding: 12px 16px; border-radius: 10px; margin-bottom: 14px; font-size: 0.9rem;
background: rgba(52,211,153,0.12); border: 1px solid rgba(52,211,153,0.3); color: var(--success);
}
.status.error {
background: rgba(248,113,113,0.12); border-color: rgba(248,113,113,0.3); color: var(--danger);
}
.section-header {
display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;
}
.section-header h2 { font-size: 1.05rem; font-weight: 600; }
.section-header span { color: var(--muted); font-size: 0.85rem; }
.file-list { list-style: none; display: flex; flex-direction: column; gap: 8px; }
.file-item {
background: var(--card); border: 1px solid var(--border); border-radius: 12px;
padding: 10px 14px; display: flex; align-items: center; gap: 10px;
}
.file-item:hover { border-color: var(--accent); background: rgba(244,63,94,0.04); }
.file-item.playing { border-color: var(--success); background: rgba(52,211,153,0.08); }
.file-info { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; }
.file-details { min-width: 0; flex: 1; }
.file-name { font-weight: 500; font-size: 0.95rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-path { font-size: 0.78rem; color: var(--muted); margin-top: 2px; }
.ktv-btn {
background: var(--accent); color: white; border: none; border-radius: 8px;
padding: 6px 12px; font-size: 0.82rem; font-weight: 600; cursor: pointer; flex-shrink: 0;
}
.ktv-btn:hover { background: var(--accent-hover); }
.empty {
text-align: center; padding: 36px 20px; color: var(--muted);
background: var(--card); border-radius: 12px; border: 1px solid var(--border);
}
input[type="file"] { display: none; }
input[type="checkbox"] { width: 18px; height: 18px; accent-color: var(--accent); cursor: pointer; }
.modal-bg {
display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 100;
align-items: center; justify-content: center; padding: 16px;
}
.modal-bg.show { display: flex; }
.modal {
background: var(--card); border: 1px solid var(--border); border-radius: 16px;
padding: 24px; width: 100%; max-width: 360px;
}
.modal h3 { margin-bottom: 14px; font-size: 1.1rem; }
.modal input {
width: 100%; background: var(--bg); border: 1px solid var(--border); color: var(--text);
border-radius: 8px; padding: 10px 12px; margin-bottom: 16px; outline: none;
}
.modal input:focus { border-color: var(--accent); }
.modal-actions { display: flex; gap: 10px; }
.modal-actions .btn { flex: 1; }
</style>
</head>
<body>
<div class="container">
<div class="lang-bar">
<select id="langSelect" aria-label="Language">
<option value="zh-Hant">繁體中文</option>
<option value="en">English</option>
<option value="ja">日本語</option>
</select>
</div>
<header>
<h1 data-i18n="appTitle">SC-8850 MIDI 點歌台</h1>
<p><span data-i18n="port">埠</span> <span id="portLabel">-</span> · <span data-i18n="subtitle">點歌排隊 · 重新偵測</span></p>
</header>
<div class="now-playing-box" id="npBox">
<div class="np-info">
<div class="np-icon">🎤</div>
<div>
<div class="np-title" data-i18n="nowPlaying">正在熱唱中</div>
<div class="np-song" id="npSong">-</div>
</div>
</div>
<button class="btn btn-warning" style="flex:0;padding:6px 12px;font-size:0.82rem;white-space:nowrap;" onclick="skipPlay()" data-i18n="skip">⏭ 切歌</button>
</div>
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('songs')" data-i18n="tabSongs">🎵 點歌目錄</button>
<button class="tab-btn" onclick="switchTab('queue')" id="queueTabBtn">📜 已點清單 (0)</button>
</div>
<div class="status" id="status"></div>
<div class="tab-content active" id="tabSongs">
<div class="search-box">
<span class="search-icon">🔍</span>
<input type="text" class="search-input" id="searchInput"
data-i18n-ph="searchPh"
placeholder="輸入關鍵字搜尋曲名或分類..." oninput="filterList()">
</div>
<div class="panel">
<div style="display:flex;align-items:center;gap:8px;width:100%;flex-wrap:nowrap;">
<label style="color:var(--muted);font-size:0.88rem;white-space:nowrap;" data-i18n="uploadTo">上傳至:</label>
<select id="uploadDir" style="flex:1;min-width:0;">
<option value=".">根目錄</option>
</select>
<button class="btn btn-secondary" style="flex:0 0 auto;white-space:nowrap;padding:8px 10px;font-size:0.82rem;" onclick="showNewDir()" data-i18n="newDir">+ 新增資料夾</button>
</div>
</div>
<div class="actions">
<button class="btn btn-success" onclick="queueSelected()" data-i18n="queueSelected">➕ 勾選加入清單</button>
<button class="btn btn-secondary" onclick="toggleSelectAll()" data-i18n="selectAll">☑ 全選/取消</button>
<button class="btn btn-primary" onclick="document.getElementById('fileInput').click()" data-i18n="upload">📤 上傳(插播)</button>
</div>
<div class="actions-secondary">
<button class="btn btn-warning" onclick="skipPlay()" data-i18n="skip">⏭ 切歌</button>
<button class="btn btn-danger" onclick="stopPlay()" data-i18n="stopClear">⏹ 停止並清空</button>
<button class="btn btn-info" onclick="redetectMidi()" data-i18n="redetect">🔌 重新偵測</button>
</div>
<div class="section-header">
<h2 data-i18n="library">全部曲庫</h2>
<span id="count"></span>
</div>
<ul class="file-list" id="fileList"></ul>
</div>
<div class="tab-content" id="tabQueue">
<div class="actions" style="margin-bottom: 14px;">
<button class="btn btn-shuffle" id="shuffleBtn" onclick="toggleShuffle()">🔀 隨機播放:關</button>
<button class="btn btn-shuffle" onclick="shuffleQueue()" data-i18n="shuffleQueue">🎲 重排等待清單</button>
</div>
<div class="actions" style="margin-bottom: 14px;">
<button class="btn btn-danger" onclick="clearQueue()" data-i18n="clearQueue">🗑 清空已點清單</button>
<button class="btn btn-secondary" onclick="pollStatus()" data-i18n="refresh">🔄 重新整理</button>
</div>
<div class="section-header">
<h2 data-i18n="waitQueue">等待播放佇列</h2>
<span id="queueCount">0</span>
</div>
<ul class="file-list" id="queueList"></ul>
</div>
<input type="file" id="fileInput" accept=".mid,.midi,.MID,.MIDI" multiple>
</div>
<div class="modal-bg" id="dirModal">
<div class="modal">
<h3 data-i18n="newDirTitle">新增音樂資料夾</h3>
<input type="text" id="newDirName" data-i18n-ph="newDirPh" placeholder="例如:anime、gs_classics" maxlength="40">
<div class="modal-actions">
<button class="btn btn-secondary" onclick="hideNewDir()" data-i18n="cancel">取消</button>
<button class="btn btn-primary" onclick="createDir()" data-i18n="create">建立</button>
</div>
</div>
</div>
<script>
// ===== 多語系字典 =====
const I18N = {
'zh-Hant': {
pageTitle: 'SC-8850 MIDI 點歌系統',
appTitle: 'SC-8850 MIDI 點歌台',
port: '埠',
subtitle: '點歌排隊 · 重新偵測',
nowPlaying: '正在熱唱中',
skip: '⏭ 切歌',
tabSongs: '🎵 點歌目錄',
tabQueue: '📜 已點清單 ({n})',
searchPh: '輸入關鍵字搜尋曲名或分類...',
uploadTo: '上傳至:',
rootDir: '根目錄 (~/midi)',
newDir: '+ 新增資料夾',
queueSelected: '➕ 勾選加入清單',
selectAll: '☑ 全選/取消',
upload: '📤 上傳(插播)',
stopClear: '⏹ 停止並清空',
redetect: '🔌 重新偵測',
library: '全部曲庫',
countAll: '{n} 首',
countMatch: '符合 {v} / 共 {n} 首',
shuffleOff: '🔀 隨機播放:關',
shuffleOn: '🔀 隨機播放:開',
shuffleQueue: '🎲 重排等待清單',
clearQueue: '🗑 清空已點清單',
refresh: '🔄 重新整理',
waitQueue: '等待播放佇列',
queueCount: '{n} 首',
queueEmpty: '目前沒有人在排隊,趕快去點歌吧!',
jump: '⚡插播',
remove: '退歌',
addSong: '➕ 點歌',
newDirTitle: '新增音樂資料夾',
newDirPh: '例如:anime、gs_classics',
cancel: '取消',
create: '建立',
noMidi: '~/midi 目錄尚無 MIDI 檔',
loadFail: '無法讀取曲庫清單',
pickFirst: '請先勾選要點播的歌曲',
uploadOk: '✓ 成功上傳 {n} 個檔案(插播)',
queueFail: '點歌失敗',
batchFail: '批次點歌失敗',
priorityOk: '⚡ 已設為下一首優先',
confirmClear: '確定要清空所有已點等待清單嗎?',
clearedQueue: '已清空排隊清單',
shuffleOnMsg: '🔀 已開啟背景隨機播放',
shuffleOffMsg: '🔀 已關閉背景隨機播放',
shuffleFail: '切換隨機播放失敗',
skipFail: '切歌失敗',
confirmStop: '停止播放並清空全部佇列?',
stoppedMsg: '已停止並清空佇列',
confirmRedetect: '將停止目前播放並重新偵測 SC-8850 埠號,確定?',
detecting: '正在重新偵測 MIDI…',
detectFail: '偵測失敗',
dirNameEmpty: '請輸入目錄名稱',
mkFail: '建立失敗',
shuffleQueueFail: '重排失敗'
},
'en': {
pageTitle: 'SC-8850 MIDI Karaoke',
appTitle: 'SC-8850 MIDI Karaoke',
port: 'Port',
subtitle: 'Song queue · Re-detect',
nowPlaying: 'NOW PLAYING',
skip: '⏭ Skip',
tabSongs: '🎵 Song Library',
tabQueue: '📜 Queue ({n})',
searchPh: 'Search by song name or folder...',
uploadTo: 'Upload to:',
rootDir: 'Root (~/midi)',
newDir: '+ New folder',
queueSelected: '➕ Add selected',
selectAll: '☑ Select all / none',
upload: '📤 Upload (play next)',
stopClear: '⏹ Stop & clear',
redetect: '🔌 Re-detect',
library: 'All songs',
countAll: '{n} songs',
countMatch: '{v} of {n} songs match',
shuffleOff: '🔀 Shuffle: Off',
shuffleOn: '🔀 Shuffle: On',
shuffleQueue: '🎲 Shuffle queue',
clearQueue: '🗑 Clear queue',
refresh: '🔄 Refresh',
waitQueue: 'Waiting queue',
queueCount: '{n} songs',
queueEmpty: 'The queue is empty. Go pick some songs!',
jump: '⚡ Next',
remove: 'Remove',
addSong: '➕ Add',
newDirTitle: 'New music folder',
newDirPh: 'e.g. anime, gs_classics',
cancel: 'Cancel',
create: 'Create',
noMidi: 'No MIDI files in ~/midi yet',
loadFail: 'Cannot load the song library',
pickFirst: 'Please select songs first',
uploadOk: '✓ Uploaded {n} file(s) (play next)',
queueFail: 'Failed to add song',
batchFail: 'Failed to add songs',
priorityOk: '⚡ Moved to play next',
confirmClear: 'Clear the whole waiting queue?',
clearedQueue: 'Queue cleared',
shuffleOnMsg: '🔀 Background shuffle enabled',
shuffleOffMsg: '🔀 Background shuffle disabled',
shuffleFail: 'Failed to toggle shuffle',
skipFail: 'Failed to skip',
confirmStop: 'Stop playback and clear the whole queue?',
stoppedMsg: 'Stopped and cleared the queue',
confirmRedetect: 'This will stop playback and re-detect the SC-8850 port. Continue?',
detecting: 'Re-detecting MIDI…',
detectFail: 'Detection failed',
dirNameEmpty: 'Please enter a folder name',
mkFail: 'Failed to create folder',
shuffleQueueFail: 'Failed to shuffle'
},
'ja': {
pageTitle: 'SC-8850 MIDI カラオケ',
appTitle: 'SC-8850 MIDI カラオケ',
port: 'ポート',
subtitle: 'リクエスト · 再検出',
nowPlaying: '再生中',
skip: '⏭ スキップ',
tabSongs: '🎵 曲一覧',
tabQueue: '📜 リクエスト ({n})',
searchPh: '曲名やフォルダ名で検索...',
uploadTo: 'アップロード先:',
rootDir: 'ルート (~/midi)',
newDir: '+ フォルダ作成',
queueSelected: '➕ 選択を追加',
selectAll: '☑ 全選択/解除',
upload: '📤 アップロード(割込)',
stopClear: '⏹ 停止&クリア',
redetect: '🔌 再検出',
library: 'すべての曲',
countAll: '{n} 曲',
countMatch: '{n} 曲中 {v} 曲が一致',
shuffleOff: '🔀 シャッフル:オフ',
shuffleOn: '🔀 シャッフル:オン',
shuffleQueue: '🎲 待機リストをシャッフル',
clearQueue: '🗑 リストをクリア',
refresh: '🔄 更新',
waitQueue: '再生待ちキュー',
queueCount: '{n} 曲',
queueEmpty: 'リクエストはまだありません。曲を選んでください!',
jump: '⚡ 割込',
remove: '削除',
addSong: '➕ 追加',
newDirTitle: '音楽フォルダを作成',
newDirPh: '例:anime、gs_classics',
cancel: 'キャンセル',
create: '作成',
noMidi: '~/midi に MIDI ファイルがありません',
loadFail: '曲一覧を読み込めません',
pickFirst: '先に曲を選択してください',
uploadOk: '✓ {n} 個のファイルをアップロードしました(割込)',
queueFail: '追加に失敗しました',
batchFail: '一括追加に失敗しました',
priorityOk: '⚡ 次の曲に設定しました',
confirmClear: '再生待ちリストをすべてクリアしますか?',
clearedQueue: 'リストをクリアしました',
shuffleOnMsg: '🔀 バックグラウンドのシャッフルを有効にしました',
shuffleOffMsg: '🔀 バックグラウンドのシャッフルを無効にしました',
shuffleFail: 'シャッフルの切り替えに失敗しました',
skipFail: 'スキップに失敗しました',
confirmStop: '再生を停止してキューをすべてクリアしますか?',
stoppedMsg: '停止してキューをクリアしました',
confirmRedetect: '再生を停止して SC-8850 のポートを再検出します。よろしいですか?',
detecting: 'MIDI を再検出中…',
detectFail: '検出に失敗しました',
dirNameEmpty: 'フォルダ名を入力してください',
mkFail: '作成に失敗しました',
shuffleQueueFail: 'シャッフルに失敗しました'
}
};
const DEFAULT_LANG = 'zh-Hant';
let lang = DEFAULT_LANG;
try {
const saved = localStorage.getItem('kmidi_lang');
if (saved && I18N[saved]) lang = saved;
} catch (e) {}
function t(key, vars) {
let s = (I18N[lang] && I18N[lang][key]) || I18N[DEFAULT_LANG][key] || key;
if (vars) {
for (const k in vars) s = s.split('{' + k + '}').join(vars[k]);
}
return s;
}
// 所有 API 請求都帶上目前語系,後端訊息才會跟著翻譯
function api(url, opts) {
opts = opts || {};
opts.headers = Object.assign({ 'X-Lang': lang }, opts.headers || {});
return fetch(url, opts);
}
let allFiles = [];
let currentQueue = [];
let currentPlaying = "";
let isShuffleOn = false;
const statusEl = document.getElementById('status');
const fileList = document.getElementById('fileList');
const queueList = document.getElementById('queueList');
const countEl = document.getElementById('count');
const queueCount = document.getElementById('queueCount');
const queueTabBtn = document.getElementById('queueTabBtn');
const uploadDir = document.getElementById('uploadDir');
const npBox = document.getElementById('npBox');
const npSong = document.getElementById('npSong');
const searchInput = document.getElementById('searchInput');
const portLabel = document.getElementById('portLabel');
const fileInput = document.getElementById('fileInput');
const shuffleBtn = document.getElementById('shuffleBtn');
const langSelect = document.getElementById('langSelect');
function applyStaticI18n() {
document.documentElement.lang = lang;
document.title = t('pageTitle');
document.querySelectorAll('[data-i18n]').forEach(el => {
el.textContent = t(el.dataset.i18n);
});
document.querySelectorAll('[data-i18n-ph]').forEach(el => {
el.placeholder = t(el.dataset.i18nPh);
});
updateShuffleUI();
queueTabBtn.textContent = t('tabQueue', { n: currentQueue.length });
queueCount.textContent = t('queueCount', { n: currentQueue.length });
}
langSelect.value = lang;
langSelect.addEventListener('change', async () => {
lang = langSelect.value;
try { localStorage.setItem('kmidi_lang', lang); } catch (e) {}
applyStaticI18n();
const keepDir = uploadDir.value;
await loadDirs();
if (keepDir) uploadDir.value = keepDir;
loadList();
pollStatus();
});
function showStatus(msg, isError) {
statusEl.style.display = 'block';
statusEl.textContent = msg;
statusEl.className = 'status' + (isError ? ' error' : '');
clearTimeout(window._st);
window._st = setTimeout(() => { statusEl.style.display = 'none'; }, 3500);
}
function switchTab(tabName) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
if (tabName === 'songs') {
document.querySelectorAll('.tab-btn')[0].classList.add('active');
document.getElementById('tabSongs').classList.add('active');
} else {
document.querySelectorAll('.tab-btn')[1].classList.add('active');
document.getElementById('tabQueue').classList.add('active');
}
}
fileInput.addEventListener('change', () => {
if (fileInput.files.length) uploadFiles(fileInput.files);
fileInput.value = '';
});
async function uploadFiles(fileListObj) {
const dir = uploadDir.value || '.';
let ok = 0;
for (const file of fileListObj) {
const fd = new FormData();
fd.append('midi', file);
fd.append('dir', dir);
try {
const res = await api('/upload', { method: 'POST', body: fd });
if (res.ok) ok++;
} catch (e) {}
}
loadList();
pollStatus();
if (ok) showStatus(t('uploadOk', { n: ok }));
}
async function queueSingle(path) {
try {
const res = await api('/queue/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path })
});
showStatus(await res.text(), !res.ok);
pollStatus();
} catch (e) { showStatus(t('queueFail'), true); }
}
async function queueSelected() {
const checkboxes = document.querySelectorAll('#fileList .file-checkbox:checked');
if (!checkboxes.length) {
showStatus(t('pickFirst'), true);
return;
}
const paths = Array.from(checkboxes).map(cb => cb.dataset.path);
try {
const res = await api('/queue/add_batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ paths })
});
showStatus(await res.text(), !res.ok);
checkboxes.forEach(cb => { cb.checked = false; });
pollStatus();
} catch (e) { showStatus(t('batchFail'), true); }
}
async function priorityQueue(index) {
try {
await api('/queue/priority', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ index })
});
showStatus(t('priorityOk'));
pollStatus();
} catch (e) {}
}
async function removeQueue(index) {
try {
await api('/queue/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ index })
});
pollStatus();
} catch (e) {}
}
async function clearQueue() {
if (!confirm(t('confirmClear'))) return;
try {
await api('/queue/clear', { method: 'POST' });
showStatus(t('clearedQueue'));
pollStatus();
} catch (e) {}
}
// 隨機重排「等待清單」的順序
async function shuffleQueue() {
try {
const res = await api('/queue/shuffle', { method: 'POST' });
showStatus(await res.text(), !res.ok);
pollStatus();
} catch (e) { showStatus(t('shuffleQueueFail'), true); }
}
async function toggleShuffle() {
try {
const res = await api('/toggle_shuffle', { method: 'POST' });
const data = await res.json();
isShuffleOn = data.shuffle;
updateShuffleUI();
showStatus(isShuffleOn ? t('shuffleOnMsg') : t('shuffleOffMsg'));
pollStatus();
} catch (e) { showStatus(t('shuffleFail'), true); }
}
function updateShuffleUI() {
if (isShuffleOn) {
shuffleBtn.classList.add('active');
shuffleBtn.textContent = t('shuffleOn');
} else {
shuffleBtn.classList.remove('active');
shuffleBtn.textContent = t('shuffleOff');
}
}
async function skipPlay() {
try {
const res = await api('/skip', { method: 'POST' });
showStatus(await res.text());
pollStatus();
} catch (e) { showStatus(t('skipFail'), true); }
}
async function stopPlay() {
if (!confirm(t('confirmStop'))) return;
try {
const res = await api('/stop', { method: 'POST' });
showStatus(await res.text());
pollStatus();
} catch (e) {}
}
async function redetectMidi() {
if (!confirm(t('confirmRedetect'))) return;
showStatus(t('detecting'));
try {
const res = await api('/redetect_midi', { method: 'POST' });
showStatus(await res.text(), !res.ok);
pollStatus();
} catch (e) { showStatus(t('detectFail'), true); }
}
function toggleSelectAll() {
const boxes = document.querySelectorAll('#fileList .file-item:not([style*="display: none"]) .file-checkbox');
if (!boxes.length) return;
const allChecked = Array.from(boxes).every(cb => cb.checked);
boxes.forEach(cb => { cb.checked = !allChecked; });
}
function filterList() {
const keyword = searchInput.value.toLowerCase().trim();
const items = document.querySelectorAll('#fileList .file-item');
let visible = 0;
items.forEach(item => {
const name = (item.dataset.name || '').toLowerCase();
if (!keyword || name.includes(keyword)) {
item.style.display = '';
visible++;
} else {
item.style.display = 'none';
}
});
countEl.textContent = keyword
? t('countMatch', { v: visible, n: allFiles.length })
: (allFiles.length ? t('countAll', { n: allFiles.length }) : '');
}
async function pollStatus() {
try {
const res = await api('/status');
const data = await res.json();
currentPlaying = data.playing_name || '';
currentQueue = data.queue || [];
isShuffleOn = data.shuffle;
updateShuffleUI();
if (data.midi_port) portLabel.textContent = data.midi_port;
if (currentPlaying) {
npBox.style.display = 'flex';
npSong.textContent = currentPlaying;
} else {
npBox.style.display = 'none';
}
queueTabBtn.textContent = t('tabQueue', { n: currentQueue.length });
queueCount.textContent = t('queueCount', { n: currentQueue.length });
if (!currentQueue.length) {
queueList.innerHTML = `<li class="empty">${t('queueEmpty')}</li>`;
} else {
queueList.innerHTML = '';
currentQueue.forEach((qItem, idx) => {
const li = document.createElement('li');
li.className = 'file-item';
li.innerHTML = `
<div style="font-weight:700;color:var(--accent);min-width:24px;">#${idx + 1}</div>
<div class="file-info">
<div class="file-details">
<div class="file-name">${qItem.name}</div>
<div class="file-path">${qItem.folder || ''}</div>
</div>
</div>
<button class="btn btn-warning" style="flex:0;padding:6px 10px;font-size:0.78rem;white-space:nowrap;" data-i="${idx}">${t('jump')}</button>
<button class="btn btn-danger" style="flex:0;padding:6px 10px;font-size:0.78rem;white-space:nowrap;" data-r="${idx}">${t('remove')}</button>
`;
li.querySelector('[data-i]').onclick = () => priorityQueue(idx);
li.querySelector('[data-r]').onclick = () => removeQueue(idx);
queueList.appendChild(li);
});
}
document.querySelectorAll('#fileList .file-item').forEach(item => {
if (item.dataset.name === currentPlaying) item.classList.add('playing');
else item.classList.remove('playing');
});
} catch (e) {}
}
async function loadDirs() {
const res = await api('/dirs');
const dirs = await res.json();
uploadDir.innerHTML = '';
dirs.forEach(d => {
const opt = document.createElement('option');
opt.value = d;
opt.textContent = d === '.' ? t('rootDir') : d;
uploadDir.appendChild(opt);
});
}
async function loadList() {
try {
const res = await api('/list');
allFiles = await res.json();
fileList.innerHTML = '';
countEl.textContent = allFiles.length ? t('countAll', { n: allFiles.length }) : '';
if (!allFiles.length) {
fileList.innerHTML = `<li class="empty">${t('noMidi')}</li>`;
return;
}
allFiles.forEach(f => {
const parts = f.name.split('/');
const name = parts.pop();
const folder = parts.length ? parts.join('/') + '/' : '';
const li = document.createElement('li');
li.className = 'file-item';
li.dataset.name = f.name;
li.innerHTML = `
<div class="file-info">
<input type="checkbox" class="file-checkbox">
<div class="file-details">
<div class="file-name">${name}</div>
${folder ? `<div class="file-path">${folder}</div>` : ''}
</div>
</div>
<button class="ktv-btn" type="button">${t('addSong')}</button>
`;
li.querySelector('.file-checkbox').dataset.path = f.path;
li.querySelector('.ktv-btn').onclick = () => queueSingle(f.path);
fileList.appendChild(li);
});
filterList();
pollStatus();
} catch (e) {
fileList.innerHTML = `<li class="empty">${t('loadFail')}</li>`;
}
}
function showNewDir() {
document.getElementById('dirModal').classList.add('show');
document.getElementById('newDirName').value = '';
document.getElementById('newDirName').focus();
}
function hideNewDir() {
document.getElementById('dirModal').classList.remove('show');
}
async function createDir() {
const name = document.getElementById('newDirName').value.trim();
if (!name) { showStatus(t('dirNameEmpty'), true); return; }
try {
const res = await api('/mkdir', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
});
const text = await res.text();
showStatus(text, !res.ok);
if (res.ok) {
hideNewDir();
await loadDirs();
uploadDir.value = name;
}
} catch (e) { showStatus(t('mkFail'), true); }
}
applyStaticI18n();
loadDirs();
loadList();
setInterval(pollStatus, 2000);
</script>
</body>
</html>
"""
@app.route("/")
def index():
return render_template_string(HTML)
@app.route("/list")
def list_files():
return jsonify(find_midi_files())
@app.route("/dirs")
def dirs():
return jsonify(list_dirs())
@app.route("/status")
def status():
with queue_lock:
queue_data = []
for p in play_queue:
queue_data.append({
"path": p,
"name": Path(p).name,
"folder": get_rel_name(p),
})
return jsonify({
"playing_name": current_playing_name,
"queue": queue_data,
"midi_port": MIDI_PORT,
"shuffle": shuffle_enabled,
})
@app.route("/toggle_shuffle", methods=["POST"])
def toggle_shuffle():
"""切換背景隨機播放;若剛開啟時目前沒在播,就立即開始播放"""
global shuffle_enabled
with queue_lock:
shuffle_enabled = not shuffle_enabled
idle = current_process is None or current_process.poll() is not None
if shuffle_enabled and idle:
if play_queue:
next_path = play_queue.pop(0)
if os.path.isfile(next_path):
_start_play_unlocked(next_path)
else:
all_files = find_midi_files()
if all_files:
_start_play_unlocked(random.choice(all_files)["path"])
return jsonify({"shuffle": shuffle_enabled})
@app.route("/queue/shuffle", methods=["POST"])
def queue_shuffle():
"""隨機重排等待清單順序"""
with queue_lock:
if len(play_queue) < 2:
return m("queue_too_short"), 400
random.shuffle(play_queue)
return m("queue_shuffled")
@app.route("/queue/add", methods=["POST"])
def queue_add():
data = request.get_json() or {}
path = data.get("path", "")
if not is_safe_midi_path(path):
return m("bad_path"), 400
play_next_in_queue_or_start(path)
return m("added", name=Path(path).name)
@app.route("/queue/add_batch", methods=["POST"])
def queue_add_batch():
data = request.get_json() or {}
paths = data.get("paths", [])
if not paths:
return m("no_selection"), 400
count = 0
with queue_lock:
for p in paths:
if not is_safe_midi_path(p):
continue
_queue_or_play_unlocked(p)
count += 1
return m("added_batch", n=count)
@app.route("/queue/priority", methods=["POST"])
def queue_priority():
data = request.get_json() or {}
idx = data.get("index")
if idx is None:
return "Invalid index", 400
with queue_lock:
if 0 <= idx < len(play_queue):
item = play_queue.pop(idx)
play_queue.insert(0, item)
return "OK"
@app.route("/queue/remove", methods=["POST"])
def queue_remove():
data = request.get_json() or {}
idx = data.get("index")
if idx is None:
return "Invalid index", 400
with queue_lock:
if 0 <= idx < len(play_queue):
play_queue.pop(idx)
return "OK"
@app.route("/queue/clear", methods=["POST"])
def queue_clear():
with queue_lock:
play_queue.clear()
return "OK"
@app.route("/skip", methods=["POST"])
def skip():
skip_current()
return m("skipped")
@app.route("/stop", methods=["POST"])
def stop():
stop_midi()
return m("stopped")
@app.route("/redetect_midi", methods=["POST"])
def redetect_midi():
stop_midi()
time.sleep(0.3)
ok, msg = detect_midi_port()
return msg, (200 if ok else 400)
@app.route("/upload", methods=["POST"])
def upload():
f = request.files.get("midi")
if not f or not f.filename or not f.filename.lower().endswith((".mid", ".midi")):
return m("bad_format"), 400
sub = request.form.get("dir", ".").strip() or "."
sub = sub.replace("..", "").strip("/")
target_dir = MIDI_FOLDER if sub in ("", ".") else (MIDI_FOLDER / sub)
try:
target_dir.resolve().relative_to(MIDI_FOLDER.resolve())
except ValueError:
target_dir = MIDI_FOLDER
target_dir.mkdir(parents=True, exist_ok=True)
# 一律存成小寫 .mid:去掉原本的 .mid/.MID/.midi/.MIDI,再補上 .mid
cleaned = safe_name(f.filename)
stem = re.sub(r'\.(mid|midi)$', '', cleaned, flags=re.IGNORECASE).strip() or "unnamed"
fname = f"{stem}.mid"
save_path = target_dir / fname
if save_path.exists():
n = 1
while save_path.exists():
save_path = target_dir / f"{stem}_{n}.mid"
n += 1
f.save(str(save_path))
path_str = str(save_path)
with queue_lock:
if current_process is None or current_process.poll() is not None:
_start_play_unlocked(path_str)
else:
if path_str in play_queue:
play_queue.remove(path_str)
play_queue.insert(0, path_str)
return m("uploaded", name=save_path.name)
@app.route("/mkdir", methods=["POST"])
def mkdir():
data = request.get_json() or {}
name = safe_name(data.get("name", ""))
if not name or name in (".", ".."):
return m("dir_invalid"), 400
path = MIDI_FOLDER / name
if path.exists():
return m("dir_exists", name=name), 400
path.mkdir(parents=True, exist_ok=False)
return m("dir_created", name=name)
if __name__ == "__main__":
MIDI_FOLDER.mkdir(parents=True, exist_ok=True)
ok, msg = detect_midi_port()
print(msg)
print(f"MIDI 資料夾:{MIDI_FOLDER}")
print(f"使用 MIDI 埠:{MIDI_PORT}")
print("MIDI 點歌台 → http://172.24.1.1:8080")
app.run(host="0.0.0.0", port=8080, debug=False)



