Files
CC-StationBroadcast/CloudServer/public/generator.html
T

1503 lines
64 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Station Broadcast Generator & Manager</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg-body: #09090b;
--bg-panel: #121214;
--bg-hover: #27272a;
--bg-border: #27272a;
--bg-input: #09090b;
--text-primary: #f4f4f5;
--text-muted: #a1a1aa;
--accent: #ffffff;
--danger: #ef4444;
--success: #10b981;
--warning: #f59e0b;
--radius: 8px;
--font-ui: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
--ring-color: rgba(255, 255, 255, 0.2);
}
body {
font-family: var(--font-ui);
background: radial-gradient(circle at 50% 0%, #18181b 0%, var(--bg-body) 100%);
color: var(--text-primary);
margin: 0;
padding: 30px 20px;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
}
.layout-wrapper {
width: 100%;
max-width: 1200px;
display: flex;
flex-direction: column;
gap: 24px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 16px;
border-bottom: 1px solid var(--bg-border);
}
.header h2 {
margin: 0;
font-weight: 600;
letter-spacing: -0.025em;
background: linear-gradient(to right, #fff, #a1a1aa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
align-items: start;
}
@media (max-width: 900px) {
.container {
grid-template-columns: 1fr;
}
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: var(--radius);
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.panel:hover {
box-shadow: 0 10px 40px -10px rgba(0,0,0,0.7);
}
.panel-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 4px;
letter-spacing: -0.015em;
display: flex;
align-items: center;
gap: 8px;
}
.panel-subtitle {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 16px;
border-bottom: 1px solid var(--bg-border);
padding-bottom: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
label {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
input[type="text"], textarea, select {
background: var(--bg-input);
border: 1px solid var(--bg-border);
color: var(--text-primary);
padding: 10px 14px;
border-radius: 6px;
font-family: var(--font-ui);
font-size: 0.95rem;
outline: none;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
input[type="text"]:focus, textarea:focus, select:focus {
border-color: #52525b;
box-shadow: 0 0 0 3px var(--ring-color);
}
/* Custom Select Styles */
.custom-select-container {
position: relative;
width: 100%;
font-family: var(--font-ui);
font-size: 0.95rem;
user-select: none;
}
.custom-select-trigger {
background: var(--bg-input);
border: 1px solid var(--bg-border);
color: var(--text-primary);
padding: 10px 14px;
border-radius: 6px;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: all 0.2s;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.custom-select-trigger:hover {
border-color: #52525b;
}
.custom-select-trigger.active {
border-color: #52525b;
box-shadow: 0 0 0 3px var(--ring-color);
}
.custom-select-trigger svg {
transition: transform 0.2s;
}
.custom-select-trigger.active svg {
transform: rotate(180deg);
}
.custom-select-options {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: 6px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
z-index: 150;
max-height: 200px;
overflow-y: auto;
display: none;
flex-direction: column;
padding: 4px;
}
.custom-select-options.active {
display: flex;
}
.custom-select-option {
padding: 8px 12px;
cursor: pointer;
border-radius: 4px;
color: var(--text-primary);
transition: background 0.1s;
}
.custom-select-option:hover {
background: var(--bg-hover);
}
.custom-select-option.selected {
background: var(--bg-hover);
color: var(--accent);
font-weight: 500;
}
input[type="text"]::placeholder {
color: #52525b;
}
.btn {
background: transparent;
border: 1px solid var(--bg-border);
color: var(--text-primary);
padding: 10px 18px;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-weight: 500;
user-select: none;
}
.btn:hover:not(:disabled) {
background: var(--bg-hover);
}
.btn:active:not(:disabled) {
transform: scale(0.98);
}
.btn-primary {
background: var(--text-primary);
color: var(--bg-body);
border-color: var(--text-primary);
font-weight: 600;
}
.btn-primary:hover:not(:disabled) {
background: #d4d4d8;
border-color: #d4d4d8;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.audio-preview {
display: flex;
align-items: center;
gap: 12px;
background: var(--bg-input);
padding: 12px;
border-radius: 6px;
border: 1px solid var(--bg-border);
}
audio {
flex: 1;
height: 36px;
outline: none;
}
audio::-webkit-media-controls-panel {
background-color: var(--text-primary);
}
.status {
font-size: 0.85rem;
color: var(--text-muted);
text-align: center;
min-height: 20px;
}
.list-manager {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
}
.list-item {
display: grid;
grid-template-columns: 80px 1fr auto;
gap: 16px;
align-items: center;
background: var(--bg-input);
padding: 12px 16px;
border: 1px solid var(--bg-border);
border-radius: 6px;
transition: all 0.2s;
}
.list-item:hover {
border-color: #52525b;
}
.list-item input {
padding: 8px 12px;
font-size: 0.9rem;
font-family: var(--font-mono);
background: var(--bg-panel);
}
.btn-icon {
padding: 8px;
border: none;
background: transparent;
color: var(--text-muted);
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.btn-icon.danger:hover {
color: var(--danger);
background: rgba(239, 68, 68, 0.1);
}
.toast-container {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 200;
display: flex;
flex-direction: column;
gap: 12px;
}
.toast {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
padding: 16px 24px;
border-radius: 8px;
box-shadow: 0 10px 30px -10px rgba(0,0,0,0.5);
display: flex;
align-items: center;
gap: 12px;
min-width: 280px;
animation: slideIn 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes slideIn {
from { transform: translateX(120%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* Modal Styles */
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
backdrop-filter: blur(8px);
}
.modal-overlay.active {
opacity: 1;
pointer-events: auto;
}
.modal {
background: var(--bg-panel);
border: 1px solid var(--bg-border);
border-radius: 12px;
width: 90%;
max-width: 500px;
display: flex;
flex-direction: column;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
transform: scale(0.95);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.modal-overlay.active .modal {
transform: scale(1);
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--bg-border);
display: flex;
justify-content: space-between;
align-items: center;
font-weight: 600;
font-size: 1.15rem;
}
.modal-body {
padding: 24px;
display: flex;
flex-direction: column;
gap: 20px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--bg-border);
display: flex;
justify-content: flex-end;
gap: 12px;
background: rgba(0,0,0,0.2);
border-bottom-left-radius: 12px;
border-bottom-right-radius: 12px;
}
/* Editable File List */
.edit-files-list {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--bg-input);
border: 1px solid var(--bg-border);
border-radius: 6px;
padding: 12px;
min-height: 120px;
max-height: 250px;
overflow-y: auto;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-input);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: #52525b;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #71717a;
}
/* For Firefox */
* {
scrollbar-width: thin;
scrollbar-color: #52525b var(--bg-input);
}
.edit-file-item {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-panel);
padding: 10px 14px;
border-radius: 6px;
border: 1px solid var(--bg-border);
transition: all 0.2s;
}
.edit-file-item:hover {
border-color: #52525b;
}
.edit-file-name {
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--text-primary);
}
.edit-file-actions {
display: flex;
gap: 6px;
}
.file-adder {
display: flex;
gap: 12px;
}
.file-adder select {
flex: 1;
}
.info-box {
font-size: 0.85rem;
color: var(--text-muted);
padding: 12px 16px;
background: rgba(255,255,255,0.03);
border-radius: 6px;
border-left: 3px solid #52525b;
}
.info-box.warning {
color: var(--warning);
background: rgba(245, 158, 11, 0.05);
border-left-color: var(--warning);
}
</style>
</head>
<body>
<div class="layout-wrapper">
<div class="header">
<h2>Broadcast Generator & Manager</h2>
<div style="display: flex; gap: 12px; align-items: center;">
<div style="display: flex; background: var(--bg-input); border: 1px solid var(--bg-border); border-radius: 6px; padding: 4px;">
<button class="btn btn-primary" id="mode-station" onclick="switchMode('station')" style="border: none; padding: 6px 16px;">Station</button>
<button class="btn" id="mode-line" onclick="switchMode('line')" style="border: none; padding: 6px 16px;">Line</button>
</div>
<a href="/" class="btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Back to Console
</a>
</div>
</div>
<div class="container">
<!-- Panel 1: TTS Generator -->
<div class="panel">
<div class="panel-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" y1="19" x2="12" y2="22"></line></svg>
Generate Audio
</div>
<div class="panel-subtitle">Use GPT-SoVITS to generate station announcements.</div>
<!-- Tab Navigation -->
<div style="display: flex; gap: 10px; margin-bottom: 16px; border-bottom: 1px solid var(--bg-border); padding-bottom: 10px;">
<button class="btn btn-primary" id="tab-single" onclick="switchTab('single')" style="padding: 6px 12px; font-size: 0.85rem;">Single</button>
<button class="btn" id="tab-batch" onclick="switchTab('batch')" style="padding: 6px 12px; font-size: 0.85rem;">Batch</button>
</div>
<div class="info-box">
<strong>Notice:</strong> Using server-side proxy to generate audio. Make sure your API balance is sufficient.
</div>
<!-- Single Generation View -->
<div id="view-single" style="display: flex; flex-direction: column; gap: 20px;">
<div class="form-group">
<label id="lbl-zh-name">Chinese Station Name (e.g., 北京)</label>
<input type="text" id="zh-text" placeholder="输入中文站名">
</div>
<div class="form-group">
<label id="lbl-en-name">Japanese Station Name (e.g., ちば)</label>
<input type="text" id="en-text" placeholder="输入日文站名">
</div>
<button class="btn btn-primary" id="btn-generate" onclick="generateAudio()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
Generate Audio
</button>
<div class="status" id="gen-status"></div>
<div style="margin-top: 10px; display: none; flex-direction: column; gap: 16px;" id="preview-section">
<div class="form-group">
<label>Chinese Preview</label>
<div class="audio-preview">
<audio id="zh-audio" controls></audio>
</div>
</div>
<div class="form-group">
<label>Japanese Preview</label>
<div class="audio-preview">
<audio id="en-audio" controls></audio>
</div>
</div>
<div class="form-group" style="margin-top:12px; padding-top:20px; border-top:1px solid var(--bg-border)">
<label id="lbl-station-code">Station Code (e.g., 01-01)</label>
<input type="text" id="station-code" placeholder="01-01">
</div>
<button class="btn btn-primary" id="btn-import" onclick="importAudio()" style="margin-top:4px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
Import & Add to List
</button>
</div>
</div>
<!-- Batch Generation View -->
<div id="view-batch" style="display: none; flex-direction: column; gap: 20px;">
<div class="form-group" id="batch-input-group">
<label>Paste Data (Format: ID &lt;Tab&gt; Chinese &lt;Tab&gt; Japanese)</label>
<textarea id="batch-input" rows="8" placeholder="01-01&#9;千叶&#9;ちば&#10;CK-01&#9;沼津&#9;ぬまづ" style="resize: vertical; font-family: var(--font-mono); white-space: pre;"></textarea>
<button class="btn btn-primary" id="btn-batch-preview" onclick="previewBatch()" style="margin-top: 10px;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
Preview Tasks
</button>
</div>
<!-- Batch Preview Area -->
<div id="batch-preview-area" style="display: none; flex-direction: column; gap: 12px;">
<div style="font-weight: 500; display: flex; justify-content: space-between; align-items: center;">
<span>Tasks Preview</span>
<button class="btn-icon" id="btn-batch-cancel" onclick="cancelBatchPreview()" title="Edit Data">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
</button>
</div>
<div id="batch-preview-list" class="edit-files-list" style="max-height: 400px;"></div>
<div style="display: flex; gap: 10px;">
<button class="btn btn-primary" id="btn-batch-generate" onclick="startBatchGeneration()" style="flex: 1;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
Start Generation
</button>
<button class="btn btn-primary" id="btn-batch-import" onclick="importBatchAudio()" style="flex: 1; display: none; background: var(--success); border-color: var(--success);">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
Import All to List
</button>
</div>
</div>
<div class="status" id="batch-status" style="text-align: left; white-space: pre-wrap; font-family: var(--font-mono); font-size: 0.8rem; background: var(--bg-input); padding: 10px; border-radius: 6px; border: 1px solid var(--bg-border); max-height: 250px; overflow-y: auto; display: none;"></div>
</div>
</div>
<!-- Panel 2: list.txt Manager (Graphical) -->
<div class="panel">
<div class="panel-title">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"></line><line x1="8" y1="12" x2="21" y2="12"></line><line x1="8" y1="18" x2="21" y2="18"></line><line x1="3" y1="6" x2="3.01" y2="6"></line><line x1="3" y1="12" x2="3.01" y2="12"></line><line x1="3" y1="18" x2="3.01" y2="18"></line></svg>
<span id="manage-title">Manage list.txt</span>
</div>
<div style="display:flex; justify-content:space-between; align-items: flex-end; margin-bottom: 8px;">
<div class="panel-subtitle" id="manage-subtitle" style="margin:0; border:none; padding:0;">Manage station audio sequences visually. (Supports multiple IDs separated by /)</div>
<div style="display:flex; gap:10px; flex-shrink: 0;">
<button class="btn" id="btn-add-station" onclick="openAddModal()" style="white-space: nowrap;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
Add Station
</button>
<button class="btn btn-primary" id="btn-save-list" onclick="saveList()" style="white-space: nowrap;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
Save to Server
</button>
</div>
</div>
<div class="list-manager" id="list-container" style="max-height: 500px; overflow-y: auto; padding-right: 4px;">
<div style="text-align:center; padding: 40px; color:var(--text-muted)">Loading...</div>
</div>
<div class="info-box warning" style="margin-top:auto;">
<strong>Note:</strong> After generating audio and saving the list, don't forget to go back to the main console and <strong>Publish a New Version</strong> to sync changes!
</div>
</div>
</div>
</div>
<!-- Edit Modal -->
<div class="modal-overlay" id="edit-modal">
<div class="modal">
<div class="modal-header">
<span id="modal-title">Edit Station</span>
<button class="btn-icon" onclick="closeModal()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label id="lbl-edit-code">Station Code</label>
<input type="text" id="edit-code" placeholder="e.g. 01-01 / 01-01/01-02">
</div>
<div class="form-group">
<label>Playback Sequence (Plays in order from top to bottom)</label>
<div class="edit-files-list" id="edit-files-list">
<!-- Files populated here -->
</div>
</div>
<div class="form-group">
<label>Add Audio File</label>
<div class="file-adder">
<!-- Custom Select Component -->
<div class="custom-select-container" id="custom-file-select" tabindex="0">
<div class="custom-select-trigger" onclick="toggleCustomSelect()">
<span id="custom-select-label">-- Select an audio file --</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#a1a1aa" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
</div>
<div class="custom-select-options" id="custom-select-options">
<!-- Options will be populated here by JS -->
</div>
</div>
<button class="btn" onclick="addFileToSequence()">Add</button>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="confirmEdit()">Confirm</button>
</div>
</div>
</div>
<div class="toast-container" id="toast-container"></div>
<script>
const TTS_MODEL = "s2.1-pro-free";
const ZH_REFERENCE_ID = "0aee9f0f5683480d987c9e3c5d2de04c";
const JA_REFERENCE_ID = "38985e545cff41b79c11c32b0196c9a0";
let currentMode = 'station'; // 'station' or 'line'
let zhTempFile = null;
let jaTempFile = null;
let listData = [];
let availableFiles = [];
let editingIndex = -1; // -1 means adding new
let currentSequence = [];
let selectedFileValue = '';
async function switchMode(mode) {
currentMode = mode;
const btnStation = document.getElementById('mode-station');
const btnLine = document.getElementById('mode-line');
if (mode === 'station') {
btnStation.className = 'btn btn-primary';
btnLine.className = 'btn';
document.getElementById('lbl-zh-name').textContent = 'Chinese Station Name (e.g., 北京)';
document.getElementById('lbl-en-name').textContent = 'Japanese Station Name (e.g., ちば)';
document.getElementById('lbl-station-code').textContent = 'Station Code (e.g., 01-01)';
document.getElementById('lbl-edit-code').textContent = 'Station Code';
document.getElementById('manage-title').textContent = 'Manage list.txt';
document.getElementById('manage-subtitle').textContent = 'Manage station audio sequences visually. (Supports multiple IDs separated by /)';
document.getElementById('btn-add-station').innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg> Add Station`;
} else {
btnStation.className = 'btn';
btnLine.className = 'btn btn-primary';
document.getElementById('lbl-zh-name').textContent = 'Chinese Line Name (e.g., 一号线)';
document.getElementById('lbl-en-name').textContent = 'Japanese Line Name (e.g., 1ごうせん)';
document.getElementById('lbl-station-code').textContent = 'Line Code (e.g., line1)';
document.getElementById('lbl-edit-code').textContent = 'Line Code';
document.getElementById('manage-title').textContent = 'Manage line.txt';
document.getElementById('manage-subtitle').textContent = 'Manage line audio sequences visually.';
document.getElementById('btn-add-station').innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg> Add Line`;
}
document.getElementById('list-container').innerHTML = '<div style="text-align:center; padding: 40px; color:var(--text-muted)">Loading...</div>';
await loadList();
}
function toggleCustomSelect() {
const trigger = document.querySelector('#custom-file-select .custom-select-trigger');
const options = document.getElementById('custom-select-options');
if (!trigger || !options) return;
const next = !options.classList.contains('active');
trigger.classList.toggle('active', next);
options.classList.toggle('active', next);
}
function closeCustomSelect() {
const trigger = document.querySelector('#custom-file-select .custom-select-trigger');
const options = document.getElementById('custom-select-options');
if (!trigger || !options) return;
trigger.classList.remove('active');
options.classList.remove('active');
}
function selectCustomOption(value, label) {
selectedFileValue = value || '';
const labelEl = document.getElementById('custom-select-label');
if (labelEl) labelEl.textContent = label || '-- Select an audio file --';
const optionsContainer = document.getElementById('custom-select-options');
if (optionsContainer) {
Array.from(optionsContainer.querySelectorAll('.custom-select-option')).forEach(el => {
el.classList.toggle('selected', (el.getAttribute('data-value') || '') === selectedFileValue);
});
}
closeCustomSelect();
}
document.addEventListener('click', (e) => {
const container = document.getElementById('custom-file-select');
if (!container) return;
if (!container.contains(e.target)) closeCustomSelect();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeCustomSelect();
});
// Icons
const ICONS = {
up: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"></polyline></svg>`,
down: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"></polyline></svg>`,
trash: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/></svg>`,
edit: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>`
};
function showToast(title, message) {
const container = document.getElementById('toast-container');
const toast = document.createElement('div');
toast.className = 'toast';
toast.innerHTML = `
<div>
<div style="font-weight:600; font-size:0.9rem; margin-bottom:2px">${title}</div>
<div style="font-size:0.8rem; color:var(--text-muted)">${message}</div>
</div>
`;
container.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
setTimeout(() => toast.remove(), 400);
}, 3000);
}
function switchTab(tab) {
const btnSingle = document.getElementById('tab-single');
const btnBatch = document.getElementById('tab-batch');
const viewSingle = document.getElementById('view-single');
const viewBatch = document.getElementById('view-batch');
if (tab === 'single') {
btnSingle.className = 'btn btn-primary';
btnBatch.className = 'btn';
viewSingle.style.display = 'flex';
viewBatch.style.display = 'none';
} else {
btnSingle.className = 'btn';
btnBatch.className = 'btn btn-primary';
viewSingle.style.display = 'none';
viewBatch.style.display = 'flex';
}
}
async function _generateSingleTTS(text, reference_id) {
try {
// Fetch directly from frontend to use the local browser's network
const fishRes = await fetch('https://api.fish.audio/v1/tts', {
method: 'POST',
headers: {
'Authorization': 'Bearer 233f3a9bcc204b20a1979d59d33cc649',
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: text,
reference_id: reference_id,
model: TTS_MODEL,
format: "wav"
})
});
if (!fishRes.ok) {
let msg = 'Fish Audio API Error';
try { msg = await fishRes.text(); } catch(e){}
throw new Error(`[${fishRes.status}] ${msg}`);
}
const audioBlob = await fishRes.blob();
// Upload the blob to the server as a temporary file
const formData = new FormData();
formData.append('audio', audioBlob, 'temp.wav');
const uploadRes = await fetch('/api/tts/upload_temp', {
method: 'POST',
body: formData
});
const data = await uploadRes.json();
if (!data.success) throw new Error(data.message);
return data;
} catch (err) {
if (err.name === 'TypeError' && err.message === 'Failed to fetch') {
throw new Error('网络跨域拦截(CORS)或连接失败。请确保你的浏览器已安装并开启 CORS Unblock 插件!');
}
throw err;
}
}
async function _importSingleAudio(code, zhTemp, jaTemp) {
const res = await fetch('/api/tts/import', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ stationCode: code, zhTemp, jaTemp, type: currentMode })
});
const data = await res.json();
if (!data.success) throw new Error(data.message);
return data;
}
let batchTasks = [];
function parseBatchInput(input) {
const lines = input.split('\n').map(l => l.trim()).filter(l => l);
const tasks = [];
for (let i = 0; i < lines.length; i++) {
const parts = lines[i].split(/\t+/);
if (parts.length >= 3) {
tasks.push({ code: parts[0].trim(), zh: parts[1].trim(), en: parts[2].trim() });
} else {
const spaceParts = lines[i].split(/\s{2,}/);
if (spaceParts.length >= 3) {
tasks.push({ code: spaceParts[0].trim(), zh: spaceParts[1].trim(), en: spaceParts[2].trim() });
}
}
}
return tasks;
}
function previewBatch() {
const input = document.getElementById('batch-input').value.trim();
if (!input) {
showToast("Error", "Please paste batch data first");
return;
}
batchTasks = parseBatchInput(input);
if (batchTasks.length === 0) {
showToast("Error", "No valid tasks parsed. Make sure data is separated by tabs.");
return;
}
const previewList = document.getElementById('batch-preview-list');
previewList.innerHTML = '';
batchTasks.forEach((task, index) => {
// Initialize temp vars
task.zhTempName = null;
task.jaTempName = null;
task.zhUrl = null;
task.jaUrl = null;
const item = document.createElement('div');
item.className = 'edit-file-item';
item.style.flexDirection = 'column';
item.style.alignItems = 'stretch';
item.style.gap = '8px';
item.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:flex-start;">
<div style="display:flex; flex-direction:column; gap:2px;">
<span style="font-weight:600; color:var(--accent)">${task.code}</span>
<span class="edit-file-name" style="color:var(--text-muted)">ZH: ${task.zh} | JA: ${task.en}</span>
</div>
<span id="batch-status-span-${index}" style="font-size:0.8rem; color:var(--text-muted)">Pending</span>
</div>
<div id="batch-audio-preview-${index}" style="display:none; flex-direction:column; gap:6px; margin-top:4px; padding-top:8px; border-top:1px solid var(--bg-border);">
<div style="display:flex; align-items:center; gap:8px;">
<span style="font-size:0.75rem; color:var(--text-muted); width:20px;">ZH</span>
<audio id="batch-audio-zh-${index}" controls style="height:24px;"></audio>
</div>
<div style="display:flex; align-items:center; gap:8px;">
<span style="font-size:0.75rem; color:var(--text-muted); width:20px;">JA</span>
<audio id="batch-audio-ja-${index}" controls style="height:24px;"></audio>
</div>
</div>
`;
item.id = `batch-task-${index}`;
previewList.appendChild(item);
});
document.getElementById('batch-input-group').style.display = 'none';
document.getElementById('batch-preview-area').style.display = 'flex';
document.getElementById('btn-batch-import').style.display = 'none';
document.getElementById('btn-batch-generate').style.display = 'flex';
document.getElementById('btn-batch-cancel').style.display = 'block';
}
function cancelBatchPreview() {
document.getElementById('batch-preview-area').style.display = 'none';
document.getElementById('batch-status').style.display = 'none';
document.getElementById('batch-input-group').style.display = 'flex';
}
async function startBatchGeneration() {
if (batchTasks.length === 0) return;
const btn = document.getElementById('btn-batch-generate');
const btnCancel = document.getElementById('btn-batch-cancel');
const statusBox = document.getElementById('batch-status');
btn.disabled = true;
btnCancel.style.display = 'none'; // Prevent cancel during generation
statusBox.style.display = 'block';
statusBox.innerHTML = `Starting batch generation for ${batchTasks.length} ${currentMode === 'station' ? 'stations' : 'lines'}...\n`;
let successCount = 0;
let failCount = 0;
for (let i = 0; i < batchTasks.length; i++) {
const task = batchTasks[i];
const taskEl = document.getElementById(`batch-task-${i}`);
const statusSpan = document.getElementById(`batch-status-span-${i}`);
statusSpan.textContent = 'Generating...';
statusSpan.style.color = 'var(--warning)';
statusBox.innerHTML += `\n[${i+1}/${batchTasks.length}] Generating ${task.code} (${task.zh})... `;
statusBox.scrollTop = statusBox.scrollHeight;
let retryCount = 0;
let success = false;
let lastError = "";
while (retryCount < 2 && !success) {
try {
// 1. Generate ZH
const zhData = await _generateSingleTTS(task.zh, ZH_REFERENCE_ID);
// 2. Generate JA
const jaData = await _generateSingleTTS(task.en, JA_REFERENCE_ID);
task.zhTempName = zhData.tempName;
task.jaTempName = jaData.tempName;
task.zhUrl = zhData.url;
task.jaUrl = jaData.url;
success = true;
} catch (e) {
lastError = e.message;
retryCount++;
if (retryCount < 2) {
statusBox.innerHTML += `<span style="color:var(--warning)">[Retry ${retryCount}] </span>`;
await new Promise(r => setTimeout(r, 2000)); // wait 2s before retry
}
}
}
if (success) {
statusBox.innerHTML += `<span style="color:var(--success)">OK</span>`;
statusSpan.textContent = 'Generated';
statusSpan.style.color = 'var(--success)';
taskEl.style.borderColor = 'var(--success)';
// Show audio players
const previewDiv = document.getElementById(`batch-audio-preview-${i}`);
previewDiv.style.display = 'flex';
document.getElementById(`batch-audio-zh-${i}`).src = task.zhUrl;
document.getElementById(`batch-audio-ja-${i}`).src = task.jaUrl;
successCount++;
} else {
statusBox.innerHTML += `<span style="color:var(--danger)">FAILED (${lastError})</span>`;
statusSpan.textContent = 'Failed';
statusSpan.style.color = 'var(--danger)';
taskEl.style.borderColor = 'var(--danger)';
failCount++;
}
}
statusBox.innerHTML += `\n\n<b>Generation Complete!</b> Success: ${successCount}, Failed: ${failCount}`;
statusBox.scrollTop = statusBox.scrollHeight;
btn.style.display = 'none'; // Hide generate button
if (successCount > 0) {
statusBox.innerHTML += `\n<i>Please preview the audio files above, then click 'Import All to List' to save them.</i>`;
statusBox.scrollTop = statusBox.scrollHeight;
document.getElementById('btn-batch-import').style.display = 'flex'; // Show import button
document.getElementById('btn-batch-cancel').style.display = 'block'; // Allow cancel/discard
} else {
btn.disabled = false;
btn.style.display = 'flex';
btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"></path><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v6h6"></path></svg> Retry Generation`;
document.getElementById('btn-batch-cancel').style.display = 'block';
}
}
async function importBatchAudio() {
const btn = document.getElementById('btn-batch-import');
const btnCancel = document.getElementById('btn-batch-cancel');
const statusBox = document.getElementById('batch-status');
btn.disabled = true;
btnCancel.style.display = 'none';
statusBox.innerHTML += `\n\nStarting import process...\n`;
statusBox.scrollTop = statusBox.scrollHeight;
let importCount = 0;
for (let i = 0; i < batchTasks.length; i++) {
const task = batchTasks[i];
if (!task.zhTempName || !task.jaTempName) continue; // Skip failed ones
// Replace slashes with dashes to ensure filesystem compatibility
if (task.code.includes('/')) {
console.log(`Replacing slashes in batch code: ${task.code}`);
task.code = task.code.replace(/\//g, '-');
}
const statusSpan = document.getElementById(`batch-status-span-${i}`);
statusSpan.textContent = 'Importing...';
statusSpan.style.color = 'var(--warning)';
try {
await _importSingleAudio(task.code, task.zhTempName, task.jaTempName);
// Update listData
const filesStr = `ch-${task.code}.wav,ja-${task.code}.wav`;
const existingIndex = listData.findIndex(item => item.code === task.code);
if (existingIndex >= 0) {
listData[existingIndex].files = filesStr.split(',');
} else {
listData.push({ code: task.code, files: filesStr.split(',') });
}
statusSpan.textContent = 'Imported';
statusSpan.style.color = 'var(--success)';
importCount++;
} catch (e) {
statusBox.innerHTML += `\n<span style="color:var(--danger)">Failed to import ${task.code}: ${e.message}</span>`;
statusBox.scrollTop = statusBox.scrollHeight;
statusSpan.textContent = 'Import Failed';
statusSpan.style.color = 'var(--danger)';
document.getElementById(`batch-task-${i}`).style.borderColor = 'var(--danger)';
}
}
if (importCount > 0) {
renderList();
saveList();
showToast("Batch Complete", `${importCount} ${currentMode === 'station' ? 'stations' : 'lines'} imported and added to list`);
statusBox.innerHTML += `\n<b>All ${importCount} items imported successfully!</b>`;
// Reset UI after 2 seconds
setTimeout(() => {
cancelBatchPreview();
document.getElementById('batch-input').value = '';
btn.disabled = false;
}, 2000);
} else {
btn.disabled = false;
btnCancel.style.display = 'block';
}
statusBox.scrollTop = statusBox.scrollHeight;
}
async function generateAudio() {
const zhText = document.getElementById('zh-text').value.trim();
const jaText = document.getElementById('en-text').value.trim();
if (!zhText || !jaText) {
showToast("Error", "Please enter both Chinese and Japanese names");
return;
}
const btn = document.getElementById('btn-generate');
const status = document.getElementById('gen-status');
btn.disabled = true;
btn.textContent = "Generating...";
status.textContent = "Generating Chinese audio...";
try {
// Generate Chinese via Client-Side Fetch
const dataZh = await _generateSingleTTS(zhText, ZH_REFERENCE_ID);
status.textContent = "Generating Japanese audio...";
// Generate Japanese via Client-Side Fetch
const dataJa = await _generateSingleTTS(jaText, JA_REFERENCE_ID);
// Success
zhTempFile = dataZh.tempName;
jaTempFile = dataJa.tempName;
document.getElementById('zh-audio').src = dataZh.url;
document.getElementById('en-audio').src = dataJa.url;
document.getElementById('preview-section').style.display = 'block';
status.textContent = "Generation complete!";
showToast("Success", "Audio generated successfully");
} catch (e) {
status.textContent = "Error: " + e.message;
showToast("Error", e.message);
} finally {
btn.disabled = false;
btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg> Generate Audio`;
}
}
async function importAudio() {
let code = document.getElementById('station-code').value.trim();
if (!code) {
showToast("Error", `Please enter a ${currentMode === 'station' ? 'station' : 'line'} code`);
return;
}
// Replace slashes with dashes to ensure filesystem compatibility
if (code.includes('/')) {
console.log(`Replacing slashes in code: ${code}`);
code = code.replace(/\//g, '-');
}
if (!zhTempFile || !jaTempFile) {
showToast("Error", "No audio generated to import");
return;
}
const btn = document.getElementById('btn-import');
btn.disabled = true;
btn.textContent = "Importing...";
try {
const res = await fetch('/api/tts/import', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
stationCode: code,
zhTemp: zhTempFile,
jaTemp: jaTempFile,
type: currentMode
})
});
const data = await res.json();
if (!data.success) throw new Error(data.message);
showToast("Success", `Files imported to ${currentMode === 'station' ? 'Station' : 'General'} folder!`);
// Automatically add/update to listData if generated via this panel
const filesStr = `ch-${code}.wav,ja-${code}.wav`;
const existingIndex = listData.findIndex(item => item.code === code);
if (existingIndex >= 0) {
listData[existingIndex].files = filesStr.split(',');
} else {
listData.push({ code: code, files: filesStr.split(',') });
}
renderList();
saveList(); // Auto-save list.txt
// Reset
zhTempFile = null;
jaTempFile = null;
document.getElementById('preview-section').style.display = 'none';
document.getElementById('gen-status').textContent = "";
document.getElementById('zh-text').value = "";
document.getElementById('en-text').value = "";
} catch (e) {
showToast("Error", "Import failed: " + e.message);
} finally {
btn.disabled = false;
btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg> Import & Add to List`;
}
}
// --- List Manager ---
async function fetchAvailableFiles() {
try {
const res = await fetch('/api/files');
const data = await res.json();
if (data.files) {
availableFiles = data.files
.filter(f => f.type === 'station' || f.type === 'general')
.map(f => f.name);
availableFiles.sort();
populateFileSelect();
}
} catch (e) {
console.error("Failed to load files", e);
}
}
function populateFileSelect() {
const optionsContainer = document.getElementById('custom-select-options');
let html = `<div class="custom-select-option ${selectedFileValue === '' ? 'selected' : ''}" data-value="" onclick="selectCustomOption('', '-- Select an audio file --')">-- Select an audio file --</div>`;
availableFiles.forEach(f => {
const isSelected = selectedFileValue === f ? 'selected' : '';
html += `<div class="custom-select-option ${isSelected}" data-value="${f}" onclick="selectCustomOption('${f}', '${f}')">${f}</div>`;
});
optionsContainer.innerHTML = html;
}
async function loadList() {
try {
const targetPath = currentMode === 'station' ? 'list.txt' : 'Audio/general/line.txt';
const res = await fetch(`/api/files/content?path=${encodeURIComponent(targetPath)}`);
const data = await res.json();
listData = [];
if (data.success && data.content) {
const lines = data.content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
const match = trimmed.match(/^([^=]+)==(.+)$/);
if (match) {
listData.push({ code: match[1], files: match[2].split(',').filter(f => f) });
}
}
}
renderList();
} catch (e) {
document.getElementById('list-container').innerHTML = `<div style="color:var(--danger)">Failed to load ${currentMode === 'station' ? 'list.txt' : 'line.txt'}</div>`;
}
}
function renderList() {
const container = document.getElementById('list-container');
container.innerHTML = '';
listData.sort((a, b) => a.code.localeCompare(b.code));
if (listData.length === 0) {
container.innerHTML = '<div style="text-align:center; color:var(--text-muted)">No entries found</div>';
return;
}
listData.forEach((item, index) => {
const div = document.createElement('div');
div.className = 'list-item';
div.style.gridTemplateColumns = '100px 1fr auto';
const tagsHtml = item.files.map(f => `<span style="background:var(--bg-hover); padding:2px 6px; border-radius:4px; font-size:0.8rem; margin-right:4px;">${f}</span>`).join('');
div.innerHTML = `
<div style="font-family:var(--font-mono); font-weight:600;">${item.code}</div>
<div>${tagsHtml || '<span style="color:var(--text-muted);font-style:italic">No files</span>'}</div>
<div style="display:flex; gap:8px;">
<button class="btn-icon" onclick="openEditModal(${index})">${ICONS.edit}</button>
<button class="btn-icon" onclick="removeListItem(${index})">${ICONS.trash}</button>
</div>
`;
container.appendChild(div);
});
}
function removeListItem(index) {
const typeName = currentMode === 'station' ? 'station' : 'line';
if (confirm(`Delete ${typeName} ${listData[index].code}?`)) {
listData.splice(index, 1);
renderList();
}
}
// --- Modal Logic ---
function openAddModal() {
editingIndex = -1;
currentSequence = [];
document.getElementById('modal-title').textContent = currentMode === 'station' ? 'Add Station Configuration' : 'Add Line Configuration';
document.getElementById('edit-code').value = '';
renderSequence();
selectCustomOption('', '-- Select an audio file --');
document.getElementById('edit-modal').classList.add('active');
}
function openEditModal(index) {
editingIndex = index;
const item = listData[index];
currentSequence = [...item.files];
document.getElementById('modal-title').textContent = currentMode === 'station' ? `Edit Station: ${item.code}` : `Edit Line: ${item.code}`;
document.getElementById('edit-code').value = item.code;
renderSequence();
selectCustomOption('', '-- Select an audio file --');
document.getElementById('edit-modal').classList.add('active');
}
function closeModal() {
closeCustomSelect();
document.getElementById('edit-modal').classList.remove('active');
}
function renderSequence() {
const list = document.getElementById('edit-files-list');
list.innerHTML = '';
if (currentSequence.length === 0) {
list.innerHTML = '<div style="color:var(--text-muted); font-size:0.85rem; text-align:center; padding:10px;">No files added yet</div>';
return;
}
currentSequence.forEach((file, idx) => {
const div = document.createElement('div');
div.className = 'edit-file-item';
div.innerHTML = `
<div class="edit-file-name">${file}</div>
<div class="edit-file-actions">
<button class="btn-icon" onclick="moveFile(${idx}, -1)" ${idx === 0 ? 'disabled style="opacity:0.3"' : ''}>${ICONS.up}</button>
<button class="btn-icon" onclick="moveFile(${idx}, 1)" ${idx === currentSequence.length - 1 ? 'disabled style="opacity:0.3"' : ''}>${ICONS.down}</button>
<button class="btn-icon" onclick="removeFileFromSequence(${idx})" style="color:var(--danger); margin-left:8px">${ICONS.trash}</button>
</div>
`;
list.appendChild(div);
});
}
function addFileToSequence() {
const file = selectedFileValue;
if (!file) return;
currentSequence.push(file);
renderSequence();
selectCustomOption('', '-- Select an audio file --');
}
function removeFileFromSequence(idx) {
currentSequence.splice(idx, 1);
renderSequence();
}
function moveFile(idx, dir) {
if (idx + dir >= 0 && idx + dir < currentSequence.length) {
const temp = currentSequence[idx];
currentSequence[idx] = currentSequence[idx + dir];
currentSequence[idx + dir] = temp;
renderSequence();
}
}
function confirmEdit() {
const code = document.getElementById('edit-code').value.trim();
const typeName = currentMode === 'station' ? 'Station' : 'Line';
if (!code) {
alert(`${typeName} code is required`);
return;
}
if (editingIndex === -1) {
if (listData.some(item => item.code === code)) {
alert(`${typeName} ${code} already exists!`);
return;
}
listData.push({ code, files: [...currentSequence] });
} else {
listData[editingIndex].code = code;
listData[editingIndex].files = [...currentSequence];
}
renderList();
closeModal();
}
async function saveList() {
const btn = document.getElementById('btn-save-list');
btn.disabled = true;
btn.textContent = "Saving...";
const content = listData
.filter(item => item.code)
.map(item => `${item.code}==${item.files.join(',')}`)
.join('\n');
try {
const targetPath = currentMode === 'station' ? 'list.txt' : 'Audio/general/line.txt';
const res = await fetch('/api/files/content', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
path: targetPath,
content: content + '\n'
})
});
const data = await res.json();
if (data.success) {
showToast("Success", `${currentMode === 'station' ? 'list.txt' : 'line.txt'} saved successfully!`);
} else {
throw new Error(data.message);
}
} catch (e) {
showToast("Error", "Save failed: " + e.message);
} finally {
btn.disabled = false;
btn.innerHTML = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg> Save to Server`;
}
}
// Init
async function init() {
await fetchAvailableFiles();
await loadList();
}
init();
</script>
</body>
</html>