2026年10月4日 星期日

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

 #!/usr/bin/env python3

from flask import Flask, request, render_template_string, jsonify

import subprocess

import os

import re

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 = []

queue_lock = threading.Lock()


# ----- 多國語言字串 -----

I18N = {

    "zh": {

        "title": "SC-8850 KTV 點歌台",

        "subtitle": "埠 {port} · 點歌排隊 · 分類選歌",

        "now_title": "正在熱唱中",

        "skip": "切歌",

        "tab_songs": "點歌目錄",

        "tab_queue": "已點清單",

        "search_ph": "搜尋曲名或分類…",

        "upload_to": "上傳至:",

        "root": "根目錄 (~/midi)",

        "new_folder": "新增資料夾",

        "add_selected": "勾選加入清單",

        "select_all": "全選/取消",

        "upload": "上傳(插播)",

        "stop_clear": "停止並清空",

        "redetect": "重新偵測 MIDI",

        "lib_title": "全部曲庫",

        "cat_all": "全部分類",

        "cat_label": "分類:",

        "queue_title": "等待播放佇列",

        "clear_queue": "清空已點清單",

        "refresh": "重新整理",

        "empty_lib": "~/midi 尚無 MIDI 檔",

        "empty_queue": "目前沒有排隊,趕快去點歌吧!",

        "request": "點歌",

        "priority": "插播",

        "remove": "退歌",

        "modal_title": "新增音樂資料夾",

        "modal_ph": "例如:anime、gs",

        "cancel": "取消",

        "create": "建立",

        "confirm_skip": None,

        "confirm_stop": "停止播放並清空全部佇列?",

        "confirm_clear": "確定清空所有已點清單?",

        "confirm_redetect": "將停止播放並重新偵測 SC-8850,確定?",

        "need_select": "請先勾選要點播的歌曲",

        "ok_queue": "已加入點歌佇列",

        "ok_batch": "已加入排隊清單",

        "ok_upload": "已上傳(插播)",

        "ok_skip": "已切歌",

        "ok_stop": "已停止並清空佇列",

        "ok_clear": "已清空排隊清單",

        "ok_priority": "已設為下一首優先",

        "fail": "操作失敗",

        "songs_count": "{n} 首",

        "match_count": "符合 {v} / 共 {t} 首",

        "queue_count": "{n} 首",

    },

    "ja": {

        "title": "SC-8850 KTV 選曲システム",

        "subtitle": "ポート {port} · 順番待ち · カテゴリ選曲",

        "now_title": "演奏中",

        "skip": "スキップ",

        "tab_songs": "曲一覧",

        "tab_queue": "予約リスト",

        "search_ph": "曲名やカテゴリを検索…",

        "upload_to": "アップロード先:",

        "root": "ルート (~/midi)",

        "new_folder": "フォルダ追加",

        "add_selected": "選択を追加",

        "select_all": "全選択/解除",

        "upload": "アップロード(割込)",

        "stop_clear": "停止してクリア",

        "redetect": "MIDI再検出",

        "lib_title": "全曲ライブラリ",

        "cat_all": "すべてのカテゴリ",

        "cat_label": "カテゴリ:",

        "queue_title": "再生待ちキュー",

        "clear_queue": "予約をクリア",

        "refresh": "更新",

        "empty_lib": "~/midi に MIDI がありません",

        "empty_queue": "予約はありません。曲を選んでください!",

        "request": "予約",

        "priority": "優先",

        "remove": "削除",

        "modal_title": "フォルダを追加",

        "modal_ph": "例:anime、gs",

        "cancel": "キャンセル",

        "create": "作成",

        "confirm_stop": "再生を停止し、キューをすべてクリアしますか?",

        "confirm_clear": "予約リストをすべてクリアしますか?",

        "confirm_redetect": "再生を停止し SC-8850 を再検出します。よろしいですか?",

        "need_select": "先に曲を選択してください",

        "ok_queue": "予約キューに追加しました",

        "ok_batch": "キューに追加しました",

        "ok_upload": "アップロード完了(割込)",

        "ok_skip": "スキップしました",

        "ok_stop": "停止してクリアしました",

        "ok_clear": "キューをクリアしました",

        "ok_priority": "次曲を優先に設定しました",

        "fail": "失敗しました",

        "songs_count": "{n} 曲",

        "match_count": "一致 {v} / 全 {t} 曲",

        "queue_count": "{n} 曲",

    },

    "en": {

        "title": "SC-8850 KTV Song Request",

        "subtitle": "Port {port} · Queue · Browse by category",

        "now_title": "Now Playing",

        "skip": "Skip",

        "tab_songs": "Library",

        "tab_queue": "Queue",

        "search_ph": "Search title or category…",

        "upload_to": "Upload to:",

        "root": "Root (~/midi)",

        "new_folder": "New folder",

        "add_selected": "Add selected",

        "select_all": "Select all/none",

        "upload": "Upload (insert)",

        "stop_clear": "Stop & clear",

        "redetect": "Re-detect MIDI",

        "lib_title": "Song library",

        "cat_all": "All categories",

        "cat_label": "Category:",

        "queue_title": "Play queue",

        "clear_queue": "Clear queue",

        "refresh": "Refresh",

        "empty_lib": "No MIDI files in ~/midi",

        "empty_queue": "Queue is empty. Request a song!",

        "request": "Request",

        "priority": "Priority",

        "remove": "Remove",

        "modal_title": "New folder",

        "modal_ph": "e.g. anime, gs",

        "cancel": "Cancel",

        "create": "Create",

        "confirm_stop": "Stop playback and clear the entire queue?",

        "confirm_clear": "Clear all queued songs?",

        "confirm_redetect": "Stop playback and re-detect SC-8850?",

        "need_select": "Please select songs first",

        "ok_queue": "Added to queue",

        "ok_batch": "Added to queue",

        "ok_upload": "Uploaded (insert next)",

        "ok_skip": "Skipped",

        "ok_stop": "Stopped and cleared",

        "ok_clear": "Queue cleared",

        "ok_priority": "Set as next priority",

        "fail": "Failed",

        "songs_count": "{n} songs",

        "match_count": "{v} / {t} songs",

        "queue_count": "{n} songs",

    },

}


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:

    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 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)

            # category = 第一層資料夾,根目錄為 ""

            parts = rel.parts

            category = parts[0] if len(parts) > 1 else ""

            files.append({

                "path": str(p),

                "name": str(rel),

                "category": category,

            })

    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 list_categories():

    """第一層子目錄作為分類"""

    cats = []

    if not MIDI_FOLDER.exists():

        return cats

    for p in MIDI_FOLDER.iterdir():

        if p.is_dir():

            cats.append(p.name)

    cats.sort(key=str.lower)

    return cats


def detect_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 error: {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 not found"


    port = candidates[0]

    MIDI_PORT = port

    try:

        (Path.home() / "sc8850_port.txt").write_text(port + "\n")

    except Exception:

        pass

    return True, f"MIDI port: {port}"


def _start_play_unlocked(path: str):

    global current_process, current_playing_path, current_playing_name

    subprocess.run(["pkill", "-f", "aplaymidi"], stderr=subprocess.DEVNULL)

    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):

    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

    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)


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():

    global current_process, current_playing_path, current_playing_name

    with queue_lock:

        subprocess.run(["pkill", "-f", "aplaymidi"], stderr=subprocess.DEVNULL)

        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)


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)

        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 KTV</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;

    }

    * { 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; }

    header { text-align: center; margin-bottom: 12px; }

    header h1 {

      font-size: 1.6rem; 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.88rem; margin-top: 4px; }


    .lang-bar {

      display: flex; justify-content: center; gap: 6px; margin-bottom: 14px;

    }

    .lang-btn {

      padding: 6px 12px; border-radius: 8px; border: 1px solid var(--border);

      background: var(--card); color: var(--muted); font-size: 0.82rem;

      font-weight: 600; cursor: pointer;

    }

    .lang-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }


    .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: 14px;

      display: none; align-items: center; justify-content: space-between; gap: 12px;

    }

    .np-info { display: flex; align-items: center; gap: 12px; min-width: 0; }

    .np-icon { font-size: 1.7rem; flex-shrink: 0; }

    .np-title { font-size: 0.78rem; color: var(--muted); }

    .np-song { font-weight: 700; font-size: 1.02rem; 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.92rem; border-radius: 8px; cursor: pointer;

    }

    .tab-btn.active { background: var(--accent); color: white; }

    .tab-content { display: none; }

    .tab-content.active { display: block; }


    .search-box { margin-bottom: 12px; position: relative; }

    .search-input {

      width: 100%; background: var(--card); border: 1px solid var(--border);

      color: var(--text); border-radius: 12px; padding: 12px 16px 12px 40px;

      font-size: 0.95rem; outline: none;

    }

    .search-input:focus { border-color: var(--accent); }

    .search-icon {

      position: absolute; left: 12px; top: 50%; transform: translateY(-50%);

      color: var(--muted); pointer-events: none;

    }


    .panel {

      background: var(--card); border: 1px solid var(--border); border-radius: 12px;

      padding: 12px 14px; margin-bottom: 12px; display: flex; flex-wrap: wrap;

      gap: 10px; align-items: center;

    }

    .panel select {

      background: var(--bg); border: 1px solid var(--border); color: var(--text);

      border-radius: 8px; padding: 8px 10px; font-size: 0.88rem;

    }

    .panel label { color: var(--muted); font-size: 0.85rem; }


    .actions, .actions-secondary {

      display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px;

    }

    .btn {

      flex: 1; min-width: 88px; padding: 10px 12px; border: none; border-radius: 10px;

      font-size: 0.85rem; font-weight: 600; cursor: pointer;

      display: inline-flex; align-items: center; justify-content: center; gap: 4px;

    }

    .btn-primary { background: var(--accent); color: white; }

    .btn-success { background: var(--success); color: #000; }

    .btn-danger { background: rgba(248,113,113,0.15); color: var(--danger); border: 1px solid rgba(248,113,113,0.3); }

    .btn-warning { background: rgba(251,191,36,0.15); color: var(--warning); border: 1px solid rgba(251,191,36,0.3); }

    .btn-secondary { background: var(--card); color: var(--text); border: 1px solid var(--border); }

    .btn-info { background: rgba(56,189,248,0.15); color: var(--info); border: 1px solid rgba(56,189,248,0.3); }


    .status {

      display: none; padding: 12px 14px; border-radius: 10px; margin-bottom: 12px; 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.02rem; 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.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.94rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

    .file-path { font-size: 0.76rem; color: var(--muted); margin-top: 2px; }

    .ktv-btn {

      background: var(--accent); color: white; border: none; border-radius: 8px;

      padding: 6px 12px; font-size: 0.8rem; font-weight: 600; cursor: pointer; flex-shrink: 0;

    }

    .empty {

      text-align: center; padding: 32px 16px; 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: 22px; width: 100%; max-width: 360px;

    }

    .modal h3 { margin-bottom: 12px; font-size: 1.05rem; }

    .modal input {

      width: 100%; background: var(--bg); border: 1px solid var(--border); color: var(--text);

      border-radius: 8px; padding: 10px 12px; margin-bottom: 14px; outline: none;

    }

    .modal-actions { display: flex; gap: 10px; }

    .modal-actions .btn { flex: 1; }

  </style>

</head>

<body>

  <div class="container">

    <header>

      <h1 id="uiTitle">SC-8850 KTV</h1>

      <p id="uiSubtitle">—</p>

    </header>


    <div class="lang-bar">

      <button type="button" class="lang-btn active" data-lang="zh" onclick="setLang('zh')">繁中</button>

      <button type="button" class="lang-btn" data-lang="ja" onclick="setLang('ja')">日本語</button>

      <button type="button" class="lang-btn" data-lang="en" onclick="setLang('en')">English</button>

    </div>


    <div class="now-playing-box" id="npBox">

      <div class="np-info">

        <div class="np-icon">🎤</div>

        <div>

          <div class="np-title" id="npTitle">Now</div>

          <div class="np-song" id="npSong">-</div>

        </div>

      </div>

      <button class="btn btn-warning" style="flex:0;padding:6px 12px;font-size:0.8rem;" id="btnSkipTop" onclick="skipPlay()">Skip</button>

    </div>


    <div class="tabs">

      <button class="tab-btn active" id="tabBtnSongs" onclick="switchTab('songs')">Library</button>

      <button class="tab-btn" id="tabBtnQueue" onclick="switchTab('queue')">Queue (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" oninput="filterList()">

      </div>


      <div class="panel">

        <label id="lblCat">Category:</label>

        <select id="catFilter" onchange="filterList()" style="flex:1;min-width:120px;"></select>

      </div>


      <div class="panel">

        <label id="lblUpload">Upload to:</label>

        <select id="uploadDir" style="flex:1;min-width:110px;"></select>

        <button class="btn btn-secondary" style="flex:0;min-width:auto;padding:8px 10px;font-size:0.8rem;" id="btnNewDir" onclick="showNewDir()">+</button>

      </div>


      <div class="actions">

        <button class="btn btn-success" id="btnAddSel" onclick="queueSelected()">Add</button>

        <button class="btn btn-secondary" id="btnSelAll" onclick="toggleSelectAll()">Select</button>

        <button class="btn btn-primary" id="btnUpload" onclick="document.getElementById('fileInput').click()">Upload</button>

      </div>


      <div class="actions-secondary">

        <button class="btn btn-warning" id="btnSkip" onclick="skipPlay()">Skip</button>

        <button class="btn btn-danger" id="btnStop" onclick="stopPlay()">Stop</button>

        <button class="btn btn-info" id="btnRedetect" onclick="redetectMidi()">MIDI</button>

      </div>


      <div class="section-header">

        <h2 id="libTitle">Library</h2>

        <span id="count"></span>

      </div>

      <ul class="file-list" id="fileList"></ul>

    </div>


    <div class="tab-content" id="tabQueue">

      <div class="actions">

        <button class="btn btn-danger" id="btnClearQ" onclick="clearQueue()">Clear</button>

        <button class="btn btn-secondary" id="btnRefresh" onclick="pollStatus()">Refresh</button>

      </div>

      <div class="section-header">

        <h2 id="queueTitle">Queue</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 id="modalTitle">New folder</h3>

      <input type="text" id="newDirName" maxlength="40">

      <div class="modal-actions">

        <button class="btn btn-secondary" id="btnCancel" onclick="hideNewDir()">Cancel</button>

        <button class="btn btn-primary" id="btnCreate" onclick="createDir()">Create</button>

      </div>

    </div>

  </div>


  <script>

    const I18N = __I18N_JSON__;

    let lang = localStorage.getItem('kmidi_lang') || 'zh';

    let allFiles = [];

    let categories = [];

    let currentPlaying = "";

    let currentQueue = [];

    let midiPort = "-";


    const $ = id => document.getElementById(id);


    function t(key, vars) {

      let s = (I18N[lang] && I18N[lang][key]) || (I18N.en[key]) || key;

      if (vars) {

        Object.keys(vars).forEach(k => { s = s.replace('{' + k + '}', vars[k]); });

      }

      return s;

    }


    function setLang(code) {

      lang = code;

      localStorage.setItem('kmidi_lang', code);

      document.querySelectorAll('.lang-btn').forEach(b => {

        b.classList.toggle('active', b.dataset.lang === code);

      });

      applyI18n();

      filterList();

      pollStatus();

    }


    function applyI18n() {

      document.title = t('title');

      $('uiTitle').textContent = t('title');

      $('uiSubtitle').textContent = t('subtitle', { port: midiPort });

      $('npTitle').textContent = t('now_title');

      $('btnSkipTop').textContent = '⏭ ' + t('skip');

      $('tabBtnSongs').textContent = '🎵 ' + t('tab_songs');

      $('searchInput').placeholder = t('search_ph');

      $('lblCat').textContent = t('cat_label');

      $('lblUpload').textContent = t('upload_to');

      $('btnNewDir').textContent = '+ ' + t('new_folder');

      $('btnAddSel').textContent = '➕ ' + t('add_selected');

      $('btnSelAll').textContent = '☑ ' + t('select_all');

      $('btnUpload').textContent = '📤 ' + t('upload');

      $('btnSkip').textContent = '⏭ ' + t('skip');

      $('btnStop').textContent = '⏹ ' + t('stop_clear');

      $('btnRedetect').textContent = '🔌 ' + t('redetect');

      $('libTitle').textContent = t('lib_title');

      $('btnClearQ').textContent = '🗑 ' + t('clear_queue');

      $('btnRefresh').textContent = '🔄 ' + t('refresh');

      $('queueTitle').textContent = t('queue_title');

      $('modalTitle').textContent = t('modal_title');

      $('newDirName').placeholder = t('modal_ph');

      $('btnCancel').textContent = t('cancel');

      $('btnCreate').textContent = t('create');

      fillCatSelect();

      fillUploadDirs();

      renderFileList();

    }


    function showStatus(msg, isError) {

      const el = $('status');

      el.style.display = 'block';

      el.textContent = msg;

      el.className = 'status' + (isError ? ' error' : '');

      clearTimeout(window._st);

      window._st = setTimeout(() => { el.style.display = 'none'; }, 3500);

    }


    function switchTab(name) {

      document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));

      document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));

      if (name === 'songs') {

        $('tabBtnSongs').classList.add('active');

        $('tabSongs').classList.add('active');

      } else {

        $('tabBtnQueue').classList.add('active');

        $('tabQueue').classList.add('active');

      }

    }


    function fillCatSelect() {

      const sel = $('catFilter');

      const cur = sel.value;

      sel.innerHTML = '';

      const optAll = document.createElement('option');

      optAll.value = '';

      optAll.textContent = t('cat_all');

      sel.appendChild(optAll);

      categories.forEach(c => {

        const o = document.createElement('option');

        o.value = c;

        o.textContent = c;

        sel.appendChild(o);

      });

      if ([...sel.options].some(o => o.value === cur)) sel.value = cur;

    }


    function fillUploadDirs() {

      fetch('/dirs').then(r => r.json()).then(dirs => {

        const sel = $('uploadDir');

        const cur = sel.value;

        sel.innerHTML = '';

        dirs.forEach(d => {

          const o = document.createElement('option');

          o.value = d;

          o.textContent = d === '.' ? t('root') : d;

          sel.appendChild(o);

        });

        if ([...sel.options].some(o => o.value === cur)) sel.value = cur;

      });

    }


    $('fileInput').addEventListener('change', () => {

      if ($('fileInput').files.length) uploadFiles($('fileInput').files);

      $('fileInput').value = '';

    });


    async function uploadFiles(list) {

      const dir = $('uploadDir').value || '.';

      let ok = 0;

      for (const file of list) {

        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) {}

      }

      await loadList();

      pollStatus();

      if (ok) showStatus(t('ok_upload') + ` (${ok})`);

    }


    async function queueSingle(path) {

      try {

        const res = await fetch('/queue/add', {

          method: 'POST',

          headers: { 'Content-Type': 'application/json' },

          body: JSON.stringify({ path })

        });

        showStatus(res.ok ? t('ok_queue') : await res.text(), !res.ok);

        pollStatus();

      } catch (e) { showStatus(t('fail'), true); }

    }


    async function queueSelected() {

      const boxes = document.querySelectorAll('#fileList .file-checkbox:checked');

      if (!boxes.length) { showStatus(t('need_select'), true); return; }

      const paths = Array.from(boxes).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(res.ok ? t('ok_batch') : await res.text(), !res.ok);

        boxes.forEach(cb => { cb.checked = false; });

        pollStatus();

      } catch (e) { showStatus(t('fail'), true); }

    }


    async function priorityQueue(index) {

      await fetch('/queue/priority', {

        method: 'POST',

        headers: { 'Content-Type': 'application/json' },

        body: JSON.stringify({ index })

      });

      showStatus(t('ok_priority'));

      pollStatus();

    }


    async function removeQueue(index) {

      await fetch('/queue/remove', {

        method: 'POST',

        headers: { 'Content-Type': 'application/json' },

        body: JSON.stringify({ index })

      });

      pollStatus();

    }


    async function clearQueue() {

      if (!confirm(t('confirm_clear'))) return;

      await fetch('/queue/clear', { method: 'POST' });

      showStatus(t('ok_clear'));

      pollStatus();

    }


    async function skipPlay() {

      const res = await fetch('/skip', { method: 'POST' });

      showStatus(res.ok ? t('ok_skip') : t('fail'), !res.ok);

      pollStatus();

    }


    async function stopPlay() {

      if (!confirm(t('confirm_stop'))) return;

      await fetch('/stop', { method: 'POST' });

      showStatus(t('ok_stop'));

      pollStatus();

    }


    async function redetectMidi() {

      if (!confirm(t('confirm_redetect'))) return;

      const res = await fetch('/redetect_midi', { method: 'POST' });

      const text = await res.text();

      showStatus(text, !res.ok);

      pollStatus();

    }


    function toggleSelectAll() {

      const boxes = document.querySelectorAll('#fileList .file-item:not([style*="display: none"]) .file-checkbox');

      if (!boxes.length) return;

      const all = Array.from(boxes).every(cb => cb.checked);

      boxes.forEach(cb => { cb.checked = !all; });

    }


    function filterList() {

      const keyword = $('searchInput').value.toLowerCase().trim();

      const cat = $('catFilter').value;

      const items = document.querySelectorAll('#fileList .file-item');

      let visible = 0;

      items.forEach(item => {

        const name = (item.dataset.name || '').toLowerCase();

        const itemCat = item.dataset.category || '';

        const okCat = !cat || itemCat === cat;

        const okKey = !keyword || name.includes(keyword);

        if (okCat && okKey) {

          item.style.display = '';

          visible++;

        } else {

          item.style.display = 'none';

        }

      });

      if (keyword || cat) {

        $('count').textContent = t('match_count', { v: visible, t: allFiles.length });

      } else {

        $('count').textContent = allFiles.length ? t('songs_count', { n: allFiles.length }) : '';

      }

    }


    function renderFileList() {

      const ul = $('fileList');

      ul.innerHTML = '';

      if (!allFiles.length) {

        ul.innerHTML = `<li class="empty">${t('empty_lib')}</li>`;

        $('count').textContent = '';

        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' + (f.name === currentPlaying ? ' playing' : '');

        li.dataset.name = f.name;

        li.dataset.category = f.category || '';

        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('request')}</button>

        `;

        li.querySelector('.file-checkbox').dataset.path = f.path;

        li.querySelector('.ktv-btn').onclick = () => queueSingle(f.path);

        ul.appendChild(li);

      });

      filterList();

    }


    async function pollStatus() {

      try {

        const res = await fetch('/status');

        const data = await res.json();

        currentPlaying = data.playing_name || '';

        currentQueue = data.queue || [];

        midiPort = data.midi_port || '-';

        $('uiSubtitle').textContent = t('subtitle', { port: midiPort });


        if (currentPlaying) {

          $('npBox').style.display = 'flex';

          $('npSong').textContent = currentPlaying;

        } else {

          $('npBox').style.display = 'none';

        }


        $('tabBtnQueue').textContent = `📜 ${t('tab_queue')} (${currentQueue.length})`;

        $('queueCount').textContent = t('queue_count', { n: currentQueue.length });


        const ql = $('queueList');

        if (!currentQueue.length) {

          ql.innerHTML = `<li class="empty">${t('empty_queue')}</li>`;

        } else {

          ql.innerHTML = '';

          currentQueue.forEach((q, 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">${q.name}</div>

                  <div class="file-path">${q.folder || ''}</div>

                </div>

              </div>

              <button class="btn btn-warning" style="flex:0;padding:6px 10px;font-size:0.76rem;" type="button">${t('priority')}</button>

              <button class="btn btn-danger" style="flex:0;padding:6px 10px;font-size:0.76rem;" type="button">${t('remove')}</button>

            `;

            const btns = li.querySelectorAll('button');

            btns[0].onclick = () => priorityQueue(idx);

            btns[1].onclick = () => removeQueue(idx);

            ql.appendChild(li);

          });

        }


        document.querySelectorAll('#fileList .file-item').forEach(item => {

          item.classList.toggle('playing', item.dataset.name === currentPlaying);

        });

      } catch (e) {}

    }


    async function loadList() {

      const res = await fetch('/list');

      allFiles = await res.json();

      const catRes = await fetch('/categories');

      categories = await catRes.json();

      fillCatSelect();

      renderFileList();

      pollStatus();

    }


    function showNewDir() {

      $('dirModal').classList.add('show');

      $('newDirName').value = '';

      $('newDirName').focus();

    }

    function hideNewDir() { $('dirModal').classList.remove('show'); }


    async function createDir() {

      const name = $('newDirName').value.trim();

      if (!name) return;

      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 loadList();

        fillUploadDirs();

        $('uploadDir').value = name;

      }

    }


    setLang(lang);

    loadList();

    fillUploadDirs();

    setInterval(pollStatus, 2000);

  </script>

</body>

</html>

"""


# 注入多國語言 JSON 到前端

HTML = HTML.replace("__I18N_JSON__", json_dumps_i18n())


def json_dumps_i18n():

    import json

    return json.dumps(I18N, ensure_ascii=False)



# 修正:上面 replace 在定義前會錯,改為函式產生 HTML

def get_html():

    import json

    return HTML.replace("__I18N_JSON__", "null") if False else render_html()


def render_html():

    import json

    raw = open_html_template()

    return raw


# 簡化:直接在路由用 replace

_HTML_TEMPLATE = None


def build_html():

    import json

    # 重新取得含占位符的模板(模組載入時 HTML 已含 __I18N_JSON__)

    return HTML.replace("__I18N_JSON__", json.dumps(I18N, ensure_ascii=False))


@app.route("/")

def index():

    return build_html()


@app.route("/list")

def list_files():

    return jsonify(find_midi_files())


@app.route("/dirs")

def dirs():

    return jsonify(list_dirs())


@app.route("/categories")

def categories():

    return jsonify(list_categories())


@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,

        })


@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 "Invalid path", 400

    play_next_in_queue_or_start(path)

    return "OK"


@app.route("/queue/add_batch", methods=["POST"])

def queue_add_batch():

    data = request.get_json() or {}

    paths = data.get("paths", [])

    if not paths:

        return "No files", 400

    count = 0

    with queue_lock:

        for p in paths:

            if is_safe_midi_path(p):

                _queue_or_play_unlocked(p)

                count += 1

    return f"OK {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", 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", 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 "OK"


@app.route("/stop", methods=["POST"])

def stop():

    stop_midi()

    return "OK"


@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 "Bad file", 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 "OK"


@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 "Invalid name", 400

    path = MIDI_FOLDER / name

    if path.exists():

        return "Exists", 400

    path.mkdir(parents=True, exist_ok=False)

    return "OK"


if __name__ == "__main__":

    MIDI_FOLDER.mkdir(parents=True, exist_ok=True)

    ok, msg = detect_midi_port()

    print(msg)

    print(f"MIDI folder: {MIDI_FOLDER}")

    print(f"Port: {MIDI_PORT}")

    print("KTV → http://<Pi-IP>:8080")

    app.run(host="0.0.0.0", port=8080, debug=False)

沒有留言:

張貼留言

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

 #!/usr/bin/env python3 from flask import Flask, request, render_template_string, jsonify import subprocess import os import re from pathlib...