2026年10月4日 星期日

MIDI 點歌系統(Roland SC-8850) 中英日多語言版本



驗證成功 英文版
切換日文版
選單再度切回繁體中文.
以下是程式碼


#!/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)


建立網頁版 MIDI 點歌系統(Roland SC-8850)

 我成功了




這套系統是以 Patchbox OS 作為 Raspberry Pi 的 Linux 作業系統,搭配 Roland SC-8850 音源模組,利用 Python Flask 建立一個可以透過瀏覽器操作的 MIDI KTV 點歌系統。

Patchbox OS 原本就是針對 Raspberry Pi 音樂應用所設計的 Linux 系統,因此非常適合拿來當作這套 MIDI 播放系統的基礎。

本教學以以下環境為基準:

  • 硬體: Raspberry Pi
  • 作業系統: Patchbox OS
  • 音源: Roland SC-8850
  • MIDI 播放: ALSA aplaymidi
  • Web Framework: Python Flask
  • 曲庫: ~/midi
  • 網路檔案分享: Samba
  • Web 介面: Flask,使用 8080 port

整體架構如下:

手機 / 平板 / 電腦
        │
        │ Web Browser
        ▼
┌──────────────────────┐
│      Patchbox OS     │
│      Raspberry Pi    │
│                      │
│      KMIDI.py        │
│          │           │
│       Flask          │
│          │           │
│     aplaymidi        │
│          │           │
│      ~/midi          │
└──────────┬───────────┘
           │
       USB / MIDI
           │
           ▼
┌──────────────────────┐
│     Roland SC-8850   │
│       音源模組        │
└──────────────────────┘

一、為什麼使用 Patchbox OS?

這套系統並不是以一般桌面 Linux 為主要目標,而是使用 Patchbox OS 作為 Raspberry Pi 的基礎環境。

Patchbox OS 啟動後就是一個適合音樂設備使用的 Linux 環境,因此 Raspberry Pi 不需要連接螢幕或鍵盤,也可以作為一台獨立的 MIDI 播放主機。

在這個專案裡,Raspberry Pi 的工作非常單純:

管理 MIDI 曲庫 → 接收網頁點歌 → 呼叫 aplaymidi → 將 MIDI 傳給 SC-8850。

真正負責聲音合成的是 SC-8850,而不是 Raspberry Pi。


二、Patchbox OS 環境

安裝完成 Patchbox OS 並啟動 Raspberry Pi 後,可以透過 SSH 登入系統。

例如:

ssh patchbox@樹莓派IP

登入後,後續的安裝與設定都可以直接在 Patchbox OS 的終端機完成。

本教學以下指令皆以 Patchbox OS 環境為基準。


三、安裝 Python 3 與 Flask

Patchbox OS 本身已經是 Linux 環境,因此可以使用 Debian 系列的 apt 套件管理工具。

先更新套件來源:

sudo apt update

安裝 Python 3、pip:

sudo apt install python3 python3-pip -y

接著安裝 Flask:

sudo apt install python3-flask -y

四、安裝 ALSA MIDI 工具

這套系統使用 Linux 的:

aplaymidi

將 MIDI 檔案送到 SC-8850。

因此安裝:

sudo apt install alsa-utils -y

完成後,可以先確認 Patchbox OS 是否看得到 SC-8850:

aplaymidi -l

如果 SC-8850 已經透過 USB MIDI 或 MIDI 介面連接 Raspberry Pi,應該可以看到相應的 MIDI 輸出埠。

KMIDI.py 本身也會自動執行 aplaymidi -l,尋找 SC-8850、Roland 或 Edirol 等 MIDI 裝置。


五、建立 MIDI 曲庫

KMIDI.py 預設使用:

~/midi

作為 MIDI 曲庫。

建立資料夾:

mkdir -p ~/midi

在 Patchbox OS 中,如果登入帳號是:

patch

那麼實際路徑就是:

/home/patch/midi

MIDI 檔案可以直接放在這裡,也可以建立子資料夾分類。

例如:

/home/patch/midi/
├── anime/
├── game/
├── classic/
├── japanese/
└── pop/

程式會自動搜尋子目錄中的 .mid 與 .midi 檔案。


六、安裝 Samba,從 Windows 管理 MIDI 曲庫

如果希望使用一般 Windows 電腦管理 MIDI 曲庫,可以在 Patchbox OS 上安裝 Samba:

sudo apt update
sudo apt install samba samba-common-bin -y

建立 MIDI 資料夾:

mkdir -p /home/patch/midi

然後編輯:

sudo nano /etc/samba/smb.conf

加入:

[midi]
   path = /home/patch/midi
   browseable = yes
   writeable = yes
   create mask = 0777
   directory mask = 0777
   valid users = patch

設定 Samba 密碼:

sudo smbpasswd -a patch

重新啟動 Samba:

sudo systemctl restart smbd
sudo systemctl enable smbd

之後 Windows 就可以透過:

\\樹莓派IP\midi

or
\\172.24.1.1\midi => PatchOS 熱點預設的IP

直接管理 Patchbox OS 上的 MIDI 曲庫。


七、啟動 KMIDI

將 KMIDI.py 放在 Patchbox OS 中後,執行:

python3 KMIDI.py

程式會自動:

  1. 建立 MIDI 曲庫目錄。
  2. 偵測 MIDI 輸出埠。
  3. 尋找 SC-8850。
  4. 啟動 Flask Web Server。
  5. 在 8080 port 等待瀏覽器連線。

程式最後會提供:

MIDI點歌台 → http://<樹莓派IP>:8080

or http://
172.24.1.1:8080

KMIDI.py 的程式碼本身也明確設定 Flask 監聽所有網路介面 0.0.0.0,並使用 8080 port。


八、從手機或電腦開始點歌

在同一個區域網路中的手機、平板或電腦開啟瀏覽器:

http://樹莓派IP:8080

例如:

http://192.168.1.100:8080

即可進入:

SC-8850 KTV 點歌台

之後就不需要再直接操作 Raspberry Pi。


九、Patchbox OS 在這套系統中的角色

這套系統可以把各元件的工作分得非常清楚:

元件工作
Patchbox OSRaspberry Pi 的 Linux 作業系統
Raspberry Pi執行整套控制程式
Python執行 KMIDI.py
Flask提供 Web 點歌介面
ALSA / aplaymidi傳送 MIDI
Samba管理網路 MIDI 曲庫
~/midi儲存 MIDI 檔案
Roland SC-8850MIDI 音源與實際發聲

因此這不是把 Raspberry Pi 當成「軟體音源」使用。

它比較接近一台:

以 Patchbox OS 為核心的網路 MIDI 點歌主機。

而 SC-8850 才是真正的硬體音源。


十、完整使用流程

第一次安裝:

sudo apt update
sudo apt install python3 python3-pip python3-flask alsa-utils -y

建立曲庫:

mkdir -p ~/midi

確認 SC-8850:

aplaymidi -l

啟動點歌系統:

python3 KMIDI.py

最後使用手機或電腦開啟:

http://樹莓派IP:8080

即可開始使用。

要讓樹莓派(Raspberry Pi)在開機時自動執行 KMIDI.py 網頁點歌系統,最推薦也最穩定標準的做法是使用 systemd 建立系統服務。

以下是設定開機自動啟動的完整步驟:

步驟一:建立 Service 設定檔

請打開終端機,輸入以下指令來建立並編輯一個新的服務檔案:

sudo nano /etc/systemd/system/kmidi.service

步驟二:貼上以下設定內容


[Unit]
Description=KMIDI Flask KTV Server
After=network.target sound.target

[Service]
User=patch
WorkingDirectory=/home/patch/midi
ExecStart=/usr/bin/python3 /home/patch/midi/KMIDI.py
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target


步驟三:啟用並啟動服務

sudo systemctl daemon-reload
sudo systemctl enable kmidi.service
sudo systemctl start kmidi.service

PS.檢查運行狀態

sudo systemctl restart kmidi.service
sudo systemctl stop kmidi.service


結語

這套系統的核心並不是把 SC-8850「數位化」,而是讓一台 Raspberry Pi 成為 SC-8850 的網路控制器。

Patchbox OS 負責提供穩定、精簡的 Linux 音樂環境;Python Flask 負責網頁操作;ALSA 負責 MIDI 傳輸;Samba 負責曲庫管理;最後由經典的 Roland SC-8850 負責真正的聲音。

如此一來,一台 Raspberry Pi 加上一台 SC-8850,就可以變成一套完全獨立的:

網路 MIDI KTV 點歌系統。


KMIDI.py的程式碼如下

#!/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
# ==================

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, f"無法執行 aplaymidi:{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, "未找到 SC-8850 或其他 MIDI 輸出埠,請確認已接上並開機"

    port = candidates[0]
    MIDI_PORT = port
    try:
        (Path.home() / "sc8850_port.txt").write_text(port + "\n")
    except Exception:
        pass
    return True, f"✓ 已偵測到 MIDI 埠:{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", 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; }
    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">
    <header>
      <h1>SC-8850 MIDI 點歌台</h1>
      <p>埠 <span id="portLabel">-</span> · 點歌排隊 · 隨機播放 · 重新偵測</p>
    </header>

    <div class="now-playing-box" id="npBox">
      <div class="np-info">
        <div class="np-icon">🎤</div>
        <div>
          <div class="np-title">正在熱唱中</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;" onclick="skipPlay()">⏭ 切歌</button>
    </div>

    <div class="tabs">
      <button class="tab-btn active" onclick="switchTab('songs')">🎵 點歌目錄</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"
               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;">上傳至:</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()">+ 新增資料夾</button>
        </div>
      </div>

      <div class="actions">
        <button class="btn btn-success" onclick="queueSelected()">➕ 勾選加入清單</button>
        <button class="btn btn-secondary" onclick="toggleSelectAll()">☑ 全選/取消</button>
        <button class="btn btn-primary" onclick="document.getElementById('fileInput').click()">📤 上傳(插播)</button>
      </div>

      <div class="actions-secondary">
        <button class="btn btn-warning" onclick="skipPlay()">⏭ 切歌</button>
        <button class="btn btn-shuffle" id="shuffleBtn" onclick="toggleShuffle()">🔀 隨機播放:關</button>
        <button class="btn btn-danger" onclick="stopPlay()">⏹ 停止並清空</button>
        <button class="btn btn-info" onclick="redetectMidi()">🔌 重新偵測</button>
      </div>

      <div class="section-header">
        <h2>全部曲庫</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-danger" onclick="clearQueue()">🗑 清空已點清單</button>
        <button class="btn btn-secondary" onclick="pollStatus()">🔄 重新整理</button>
      </div>
      <div class="section-header">
        <h2>等待播放佇列</h2>
        <span id="queueCount">0 首</span>
      </div>
      <ul class="file-list" id="queueList"></ul>
    </div>

    <input type="file" id="fileInput" accept=".mid,.midi" multiple>
  </div>

  <div class="modal-bg" id="dirModal">
    <div class="modal">
      <h3>新增音樂資料夾</h3>
      <input type="text" id="newDirName" placeholder="例如:anime、gs_classics" maxlength="40">
      <div class="modal-actions">
        <button class="btn btn-secondary" onclick="hideNewDir()">取消</button>
        <button class="btn btn-primary" onclick="createDir()">建立</button>
      </div>
    </div>
  </div>

  <script>
    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');

    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 fetch('/upload', { method: 'POST', body: fd });
          if (res.ok) ok++;
        } catch (e) {}
      }
      loadList();
      pollStatus();
      if (ok) showStatus(`✓ 成功上傳 ${ok} 個檔案(插播)`);
    }

    async function queueSingle(path) {
      try {
        const res = await fetch('/queue/add', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ path })
        });
        showStatus(await res.text(), !res.ok);
        pollStatus();
      } catch (e) { showStatus('點歌失敗', true); }
    }

    async function queueSelected() {
      const checkboxes = document.querySelectorAll('#fileList .file-checkbox:checked');
      if (!checkboxes.length) {
        showStatus('請先勾選要點播的歌曲', true);
        return;
      }
      const paths = Array.from(checkboxes).map(cb => cb.dataset.path);
      try {
        const res = await fetch('/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('批次點歌失敗', true); }
    }

    async function priorityQueue(index) {
      try {
        await fetch('/queue/priority', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ index })
        });
        showStatus('⚡ 已設為下一首優先');
        pollStatus();
      } catch (e) {}
    }

    async function removeQueue(index) {
      try {
        await fetch('/queue/remove', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ index })
        });
        pollStatus();
      } catch (e) {}
    }

    async function clearQueue() {
      if (!confirm('確定要清空所有已點等待清單嗎?')) return;
      try {
        await fetch('/queue/clear', { method: 'POST' });
        showStatus('已清空排隊清單');
        pollStatus();
      } catch (e) {}
    }

    async function toggleShuffle() {
      try {
        const res = await fetch('/toggle_shuffle', { method: 'POST' });
        const data = await res.json();
        isShuffleOn = data.shuffle;
        updateShuffleUI();
        showStatus(isShuffleOn ? '🔀 已開啟背景隨機播放' : '🔀 已關閉背景隨機播放');
      } catch (e) { showStatus('切換隨機播放失敗', true); }
    }

    function updateShuffleUI() {
      if (isShuffleOn) {
        shuffleBtn.classList.add('active');
        shuffleBtn.textContent = '🔀 隨機播放:開';
      } else {
        shuffleBtn.classList.remove('active');
        shuffleBtn.textContent = '🔀 隨機播放:關';
      }
    }

    async function skipPlay() {
      try {
        const res = await fetch('/skip', { method: 'POST' });
        showStatus(await res.text());
        pollStatus();
      } catch (e) { showStatus('切歌失敗', true); }
    }

    async function stopPlay() {
      if (!confirm('停止播放並清空全部佇列?')) return;
      try {
        await fetch('/stop', { method: 'POST' });
        showStatus('已停止並清空佇列');
        pollStatus();
      } catch (e) {}
    }

    async function redetectMidi() {
      if (!confirm('將停止目前播放並重新偵測 SC-8850 埠號,確定?')) return;
      showStatus('正在重新偵測 MIDI…');
      try {
        const res = await fetch('/redetect_midi', { method: 'POST' });
        showStatus(await res.text(), !res.ok);
        pollStatus();
      } catch (e) { showStatus('偵測失敗', 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
        ? `符合 ${visible} / 共 ${allFiles.length} 首`
        : (allFiles.length ? `${allFiles.length} 首` : '');
    }

    async function pollStatus() {
      try {
        const res = await fetch('/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 = `📜 已點清單 (${currentQueue.length})`;
        queueCount.textContent = `${currentQueue.length} 首`;

        if (!currentQueue.length) {
          queueList.innerHTML = `<li class="empty">目前沒有人在排隊,趕快去點歌吧!</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;" data-i="${idx}">⚡插播</button>
              <button class="btn btn-danger" style="flex:0;padding:6px 10px;font-size:0.78rem;" data-r="${idx}">退歌</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 fetch('/dirs');
      const dirs = await res.json();
      uploadDir.innerHTML = '';
      dirs.forEach(d => {
        const opt = document.createElement('option');
        opt.value = d;
        opt.textContent = d === '.' ? '根目錄 (~/midi)' : d;
        uploadDir.appendChild(opt);
      });
    }

    async function loadList() {
      try {
        const res = await fetch('/list');
        allFiles = await res.json();
        fileList.innerHTML = '';
        countEl.textContent = allFiles.length ? `${allFiles.length} 首` : '';
        if (!allFiles.length) {
          fileList.innerHTML = `<li class="empty">~/midi 目錄尚無 MIDI 檔</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">➕ 點歌</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">無法讀取曲庫清單</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('請輸入目錄名稱', true); return; }
      try {
        const res = await fetch('/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('建立失敗', true); }
    }

    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
    return jsonify({"shuffle": shuffle_enabled})

@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 "檔案不存在或不在允許目錄", 400
    play_next_in_queue_or_start(path)
    return f"✓ 已將「{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 "未選擇任何檔案", 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 f"✓ 已成功將 {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 "⏭ 已切歌"

@app.route("/stop", methods=["POST"])
def stop():
    stop_midi()
    return "⏹ 已停止並清空佇列"

@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 "檔案格式錯誤", 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)
    fname = safe_name(f.filename)
    save_path = target_dir / fname
    if save_path.exists():
        stem, suf = save_path.stem, save_path.suffix
        n = 1
        while save_path.exists():
            save_path = target_dir / f"{stem}_{n}{suf}"
            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 f"✓ 已上傳並設為插播:{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 "目錄名稱無效", 400
    path = MIDI_FOLDER / name
    if path.exists():
        return f"目錄已存在:{name}", 400
    path.mkdir(parents=True, exist_ok=False)
    return f"✓ 已建立 ~/midi/{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)

MIDI 點歌系統(Roland SC-8850) 中英日多語言版本

驗證成功 英文版 切換日文版 選單再度切回繁體中文. 以下是程式碼 #!/usr/bin/env python3 from flask import Flask, request, render_template_string, jsonify import subprocess...