1503 lines
64 KiB
HTML
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 <Tab> Chinese <Tab> Japanese)</label>
|
|
<textarea id="batch-input" rows="8" placeholder="01-01	千叶	ちば CK-01	沼津	ぬまづ" 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>
|