Add DJ curation metadata, public auto-play radio, and Lyria web controls.
Extensive per-track meta feeds DeepSeek planning. Caravan of the Night kept with electric guitar marked disliked. Sahara Saz remains gold standard. Gateway index.html auto-plays on tinqs.com. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+415
-5
@@ -28,7 +28,7 @@
|
||||
radial-gradient(ellipse 60% 40% at 100% 100%, rgba(123,92,255,0.15), transparent);
|
||||
}
|
||||
.radio {
|
||||
width: min(440px, 92vw);
|
||||
width: min(480px, 92vw);
|
||||
background: var(--panel);
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 20px;
|
||||
@@ -188,6 +188,35 @@
|
||||
text-align: center;
|
||||
}
|
||||
.num-input:focus { outline: none; border-color: var(--accent); }
|
||||
.select-input {
|
||||
min-width: 140px;
|
||||
max-width: 180px;
|
||||
background: #14141f;
|
||||
border: 1px solid #3a3a50;
|
||||
border-radius: 8px;
|
||||
padding: 0.35rem 0.5rem;
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.select-input:focus { outline: none; border-color: var(--accent); }
|
||||
.lyria-status {
|
||||
font-size: 0.72rem;
|
||||
padding: 0.45rem 0.55rem;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.65rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.lyria-status.ok { background: rgba(107, 207, 142, 0.12); color: #6bcf8e; border: 1px solid rgba(107, 207, 142, 0.25); }
|
||||
.lyria-status.err { background: rgba(255, 92, 122, 0.1); color: #ff5c7a; border: 1px solid rgba(255, 92, 122, 0.25); }
|
||||
.settings-divider {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
padding: 0.65rem 0 0.35rem;
|
||||
border-top: 1px solid #2a2a3a;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.range-input {
|
||||
width: 100px;
|
||||
accent-color: var(--accent);
|
||||
@@ -381,6 +410,99 @@
|
||||
.song-row.playing { border-color: var(--accent); background: rgba(255,107,53,0.08); }
|
||||
.song-meta { color: var(--muted); font-size: 0.72rem; }
|
||||
.library-empty { color: var(--muted); font-size: 0.8rem; }
|
||||
.vocal-booth {
|
||||
margin-top: 1.25rem;
|
||||
border-top: 1px solid #2a2a3a;
|
||||
padding-top: 1rem;
|
||||
display: none;
|
||||
}
|
||||
.vocal-booth.open { display: block; }
|
||||
.vocal-booth h2 {
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
.vocal-booth .booth-note {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
.vocal-booth .booth-style {
|
||||
font-size: 0.75rem;
|
||||
color: #b0b0c8;
|
||||
font-style: italic;
|
||||
margin-bottom: 0.75rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.cue-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.cue-line {
|
||||
padding: 0.5rem 0.65rem;
|
||||
background: #1a1a28;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 10px;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.4;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
.cue-line.active {
|
||||
border-color: var(--accent);
|
||||
background: rgba(255, 107, 53, 0.1);
|
||||
}
|
||||
.cue-time {
|
||||
font-size: 0.68rem;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
.cue-text {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.cue-hint {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.booth-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.booth-controls button { flex: 1; min-width: 120px; }
|
||||
.booth-status {
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
min-height: 1.2rem;
|
||||
}
|
||||
.booth-status.recording { color: #ff5c7a; }
|
||||
.booth-status.ready { color: #6bcf8e; }
|
||||
.take-player {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.booth-toggle {
|
||||
width: 100%;
|
||||
margin-top: 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px dashed #3a3a50;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.booth-toggle:hover { border-color: var(--accent); color: var(--text); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -390,7 +512,8 @@
|
||||
<h1>Ozan Radio</h1>
|
||||
<button type="button" class="icon-btn" id="settingsBtn" title="Settings" aria-label="Settings">⚙</button>
|
||||
</div>
|
||||
<p class="tagline">No catalog tracks. Taste from settings.json — set yours via Cursor + Spotify screenshots.</p>
|
||||
<p class="tagline">No catalog tracks. Taste from settings.json — set yours via Cursor + Spotify screenshots.
|
||||
<a href="index.html" style="color:var(--accent)">Public auto-play</a> (tinqs.com, no server).</p>
|
||||
|
||||
<section class="dashboard" id="dashboard">
|
||||
<div class="stat-card">
|
||||
@@ -446,6 +569,43 @@
|
||||
</label>
|
||||
<input type="number" class="num-input" id="setMaxPerDay" min="1" max="100" value="10">
|
||||
</div>
|
||||
<div class="settings-divider">Lyria 3 (from API)</div>
|
||||
<div class="lyria-status ok" id="lyriaApiStatus">Checking Gemini / Lyria…</div>
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
Model
|
||||
<span class="hint" id="lyriaModelHint">Pro = full songs · Clip = 30s previews</span>
|
||||
</label>
|
||||
<select class="select-input" id="setLyriaModel"></select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
Vocal mode
|
||||
<span class="hint" id="lyriaVocalHint">How DJ + Lyria treat vocals</span>
|
||||
</label>
|
||||
<select class="select-input" id="setVocalMode"></select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
Lyric language
|
||||
<span class="hint">Lyria sings in the language you steer</span>
|
||||
</label>
|
||||
<select class="select-input" id="setLanguage"></select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
Singer profile
|
||||
<span class="hint">Vocal delivery when mode is Full vocals</span>
|
||||
</label>
|
||||
<select class="select-input" id="setSinger"></select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label>
|
||||
Output format
|
||||
<span class="hint">WAV = larger files, Pro only</span>
|
||||
</label>
|
||||
<select class="select-input" id="setOutputFormat"></select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="viz" id="viz">
|
||||
@@ -467,6 +627,23 @@
|
||||
|
||||
<div class="status" id="status">Connecting…</div>
|
||||
|
||||
<button type="button" class="booth-toggle" id="boothToggle">🎙 Vocal booth — record your layer (Chrome)</button>
|
||||
|
||||
<section class="vocal-booth" id="vocalBooth">
|
||||
<h2>Vocal booth</h2>
|
||||
<p class="booth-note" id="boothIntro"></p>
|
||||
<p class="booth-style" id="boothStyle"></p>
|
||||
<div class="cue-list" id="cueList"></div>
|
||||
<div class="booth-controls">
|
||||
<button type="button" class="secondary" id="skipIntroBtn">Skip to saz</button>
|
||||
<button type="button" class="primary" id="recordBtn">Record take</button>
|
||||
<button type="button" class="secondary" id="stopRecordBtn" disabled>Stop</button>
|
||||
<button type="button" class="secondary" id="downloadTakeBtn" disabled>Download take</button>
|
||||
</div>
|
||||
<div class="booth-status" id="boothStatus">Mic uses Chrome — wear headphones so only your voice is captured.</div>
|
||||
<audio id="takePlayer" class="take-player" controls hidden></audio>
|
||||
</section>
|
||||
|
||||
<section class="library">
|
||||
<h2>Saved songs</h2>
|
||||
<div class="library-list" id="libraryList">
|
||||
@@ -484,7 +661,7 @@
|
||||
</section>
|
||||
|
||||
<div class="stack">
|
||||
DJ: DeepSeek · Music: Lyria 3 · Taste: screenshots → Cursor → settings.json<br>
|
||||
DJ: DeepSeek · Music: Lyria 3 · Vocal booth: Chrome mic + cue sheet<br>
|
||||
Optional live layer: Magenta RealTime 2 on Apple Silicon
|
||||
</div>
|
||||
</div>
|
||||
@@ -512,21 +689,243 @@
|
||||
const setMix = document.getElementById('setMix');
|
||||
const setChance = document.getElementById('setChance');
|
||||
const setMaxPerDay = document.getElementById('setMaxPerDay');
|
||||
const lyriaApiStatus = document.getElementById('lyriaApiStatus');
|
||||
const setLyriaModel = document.getElementById('setLyriaModel');
|
||||
const setVocalMode = document.getElementById('setVocalMode');
|
||||
const setLanguage = document.getElementById('setLanguage');
|
||||
const setSinger = document.getElementById('setSinger');
|
||||
const setOutputFormat = document.getElementById('setOutputFormat');
|
||||
const lyriaModelHint = document.getElementById('lyriaModelHint');
|
||||
const lyriaVocalHint = document.getElementById('lyriaVocalHint');
|
||||
const statSpent = document.getElementById('statSpent');
|
||||
const statPerTrack = document.getElementById('statPerTrack');
|
||||
const statGenerated = document.getElementById('statGenerated');
|
||||
const statRemaining = document.getElementById('statRemaining');
|
||||
const statMaxBudget = document.getElementById('statMaxBudget');
|
||||
const boothToggle = document.getElementById('boothToggle');
|
||||
const vocalBooth = document.getElementById('vocalBooth');
|
||||
const boothIntro = document.getElementById('boothIntro');
|
||||
const boothStyle = document.getElementById('boothStyle');
|
||||
const cueList = document.getElementById('cueList');
|
||||
const skipIntroBtn = document.getElementById('skipIntroBtn');
|
||||
const recordBtn = document.getElementById('recordBtn');
|
||||
const stopRecordBtn = document.getElementById('stopRecordBtn');
|
||||
const downloadTakeBtn = document.getElementById('downloadTakeBtn');
|
||||
const boothStatus = document.getElementById('boothStatus');
|
||||
const takePlayer = document.getElementById('takePlayer');
|
||||
|
||||
let currentTrackId = null;
|
||||
let radioSettings = { playback: { shuffle: true, mix_existing_and_new: true, new_song_chance: 0.35 } };
|
||||
let savingSettings = false;
|
||||
let lastGenError = null;
|
||||
let activeVocalCues = null;
|
||||
let mediaRecorder = null;
|
||||
let recordedChunks = [];
|
||||
let micStream = null;
|
||||
let lastTakeBlob = null;
|
||||
let lastTakeUrl = null;
|
||||
let lyriaCaps = null;
|
||||
|
||||
function fillSelect(sel, options, valueKey = 'id', labelKey = 'label') {
|
||||
sel.innerHTML = '';
|
||||
for (const opt of options) {
|
||||
const o = document.createElement('option');
|
||||
o.value = opt[valueKey];
|
||||
o.textContent = opt[labelKey];
|
||||
if (opt.hint) o.title = opt.hint;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
}
|
||||
|
||||
function syncLyriaUI(active) {
|
||||
if (!active || savingSettings) return;
|
||||
if (active.model) setLyriaModel.value = active.model;
|
||||
if (active.vocal_mode) setVocalMode.value = active.vocal_mode;
|
||||
if (active.language) setLanguage.value = active.language;
|
||||
if (active.singer_profile != null) setSinger.value = active.singer_profile;
|
||||
if (active.output_format) setOutputFormat.value = active.output_format;
|
||||
updateLyriaHints();
|
||||
}
|
||||
|
||||
function updateLyriaHints() {
|
||||
const mode = lyriaCaps?.capabilities?.vocal_modes?.find(m => m.id === setVocalMode.value);
|
||||
if (mode) lyriaVocalHint.textContent = mode.hint;
|
||||
const model = lyriaCaps?.capabilities?.models?.find(m => m.id === setLyriaModel.value);
|
||||
if (model) lyriaModelHint.textContent = `${model.label} · ${model.duration}`;
|
||||
if (setLyriaModel.value?.includes('clip')) {
|
||||
setOutputFormat.value = 'mp3';
|
||||
setOutputFormat.disabled = true;
|
||||
} else {
|
||||
setOutputFormat.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLyriaCapabilities() {
|
||||
try {
|
||||
const res = await fetch(`${API}/api/lyria`);
|
||||
const data = await res.json();
|
||||
lyriaCaps = data;
|
||||
const caps = data.capabilities || {};
|
||||
fillSelect(setLyriaModel, caps.models || []);
|
||||
fillSelect(setVocalMode, caps.vocal_modes || []);
|
||||
fillSelect(setLanguage, caps.languages || []);
|
||||
fillSelect(setSinger, caps.singer_profiles || []);
|
||||
fillSelect(setOutputFormat, caps.output_formats || []);
|
||||
syncLyriaUI(data.active || {});
|
||||
const api = data.api || {};
|
||||
lyriaApiStatus.textContent = api.message || 'Lyria status unknown';
|
||||
lyriaApiStatus.className = 'lyria-status ' + (api.ok ? 'ok' : 'err');
|
||||
} catch (_) {
|
||||
lyriaApiStatus.textContent = 'Could not reach /api/lyria';
|
||||
lyriaApiStatus.className = 'lyria-status err';
|
||||
}
|
||||
}
|
||||
|
||||
function fmtUsd(n) {
|
||||
return '$' + Number(n || 0).toFixed(2);
|
||||
}
|
||||
|
||||
function fmtTime(sec) {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function loadVocalBooth(trackId) {
|
||||
activeVocalCues = null;
|
||||
cueList.innerHTML = '';
|
||||
boothIntro.textContent = '';
|
||||
boothStyle.textContent = '';
|
||||
skipIntroBtn.disabled = true;
|
||||
if (!trackId) return;
|
||||
try {
|
||||
const res = await fetch(`${API}/api/vocal-cues/${trackId}`);
|
||||
if (!res.ok) return;
|
||||
activeVocalCues = await res.json();
|
||||
boothIntro.textContent = activeVocalCues.intro_note || '';
|
||||
boothStyle.textContent = activeVocalCues.style || '';
|
||||
skipIntroBtn.disabled = !activeVocalCues.skip_intro_sec;
|
||||
for (const phrase of activeVocalCues.phrases || []) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'cue-line';
|
||||
el.dataset.start = phrase.start_sec;
|
||||
el.dataset.end = phrase.end_sec;
|
||||
el.innerHTML = `
|
||||
<div class="cue-time">${fmtTime(phrase.start_sec)} — ${phrase.label || ''}</div>
|
||||
<div class="cue-text">${phrase.text || ''}</div>
|
||||
<div class="cue-hint">${phrase.hint || ''}</div>`;
|
||||
cueList.appendChild(el);
|
||||
}
|
||||
if (vocalBooth.classList.contains('open')) {
|
||||
boothToggle.textContent = `🎙 Vocal booth — ${activeVocalCues.title || 'cues loaded'}`;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function highlightCueAtTime(t) {
|
||||
if (!activeVocalCues) return;
|
||||
for (const el of cueList.querySelectorAll('.cue-line')) {
|
||||
const start = parseFloat(el.dataset.start);
|
||||
const end = parseFloat(el.dataset.end);
|
||||
el.classList.toggle('active', t >= start && t < end);
|
||||
}
|
||||
}
|
||||
|
||||
function resetTake() {
|
||||
if (lastTakeUrl) URL.revokeObjectURL(lastTakeUrl);
|
||||
lastTakeUrl = null;
|
||||
lastTakeBlob = null;
|
||||
takePlayer.hidden = true;
|
||||
takePlayer.removeAttribute('src');
|
||||
downloadTakeBtn.disabled = true;
|
||||
}
|
||||
|
||||
async function ensureMic() {
|
||||
if (micStream) return micStream;
|
||||
if (!navigator.mediaDevices?.getUserMedia) {
|
||||
throw new Error('Mic not supported — use Chrome on desktop.');
|
||||
}
|
||||
micStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { echoCancellation: true, noiseSuppression: false, autoGainControl: false },
|
||||
});
|
||||
return micStream;
|
||||
}
|
||||
|
||||
async function startRecording() {
|
||||
resetTake();
|
||||
boothStatus.textContent = 'Requesting mic…';
|
||||
boothStatus.className = 'booth-status';
|
||||
try {
|
||||
const stream = await ensureMic();
|
||||
recordedChunks = [];
|
||||
const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
|
||||
? 'audio/webm;codecs=opus'
|
||||
: 'audio/webm';
|
||||
mediaRecorder = new MediaRecorder(stream, { mimeType: mime });
|
||||
mediaRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) recordedChunks.push(e.data);
|
||||
};
|
||||
mediaRecorder.onstop = () => {
|
||||
lastTakeBlob = new Blob(recordedChunks, { type: mime });
|
||||
lastTakeUrl = URL.createObjectURL(lastTakeBlob);
|
||||
takePlayer.src = lastTakeUrl;
|
||||
takePlayer.hidden = false;
|
||||
downloadTakeBtn.disabled = false;
|
||||
boothStatus.textContent = 'Take ready — preview below or download.';
|
||||
boothStatus.className = 'booth-status ready';
|
||||
recordBtn.disabled = false;
|
||||
stopRecordBtn.disabled = true;
|
||||
};
|
||||
if (activeVocalCues?.skip_intro_sec != null) {
|
||||
player.currentTime = activeVocalCues.skip_intro_sec;
|
||||
}
|
||||
await player.play().catch(() => {});
|
||||
mediaRecorder.start(250);
|
||||
recordBtn.disabled = true;
|
||||
stopRecordBtn.disabled = false;
|
||||
boothStatus.textContent = 'Recording… sing to the highlighted cue lines.';
|
||||
boothStatus.className = 'booth-status recording';
|
||||
} catch (err) {
|
||||
boothStatus.textContent = err.message || 'Mic permission denied.';
|
||||
boothStatus.className = 'booth-status';
|
||||
recordBtn.disabled = false;
|
||||
stopRecordBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function stopRecording() {
|
||||
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
|
||||
mediaRecorder.stop();
|
||||
}
|
||||
}
|
||||
|
||||
function downloadTake() {
|
||||
if (!lastTakeBlob || !currentTrackId) return;
|
||||
const a = document.createElement('a');
|
||||
a.href = lastTakeUrl;
|
||||
a.download = `${currentTrackId}_vocal_take.webm`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
boothToggle.addEventListener('click', () => {
|
||||
vocalBooth.classList.toggle('open');
|
||||
const open = vocalBooth.classList.contains('open');
|
||||
boothToggle.textContent = open
|
||||
? (activeVocalCues ? `🎙 Vocal booth — ${activeVocalCues.title}` : '🎙 Vocal booth (open a cued track)')
|
||||
: '🎙 Vocal booth — record your layer (Chrome)';
|
||||
if (open && currentTrackId) loadVocalBooth(currentTrackId);
|
||||
});
|
||||
skipIntroBtn.addEventListener('click', () => {
|
||||
if (activeVocalCues?.skip_intro_sec != null) {
|
||||
player.currentTime = activeVocalCues.skip_intro_sec;
|
||||
player.play().catch(() => {});
|
||||
}
|
||||
});
|
||||
recordBtn.addEventListener('click', startRecording);
|
||||
stopRecordBtn.addEventListener('click', stopRecording);
|
||||
downloadTakeBtn.addEventListener('click', downloadTake);
|
||||
player.addEventListener('timeupdate', () => highlightCueAtTime(player.currentTime));
|
||||
|
||||
function applyTrack(t, source) {
|
||||
if (!t) return;
|
||||
currentTrackId = t.track_id;
|
||||
@@ -541,6 +940,8 @@
|
||||
const srcLabel = source === 'generated' ? 'New · ' : source === 'library' ? 'Library · ' : '';
|
||||
statusEl.textContent = `${srcLabel}Playing · ${t.track_id}`;
|
||||
loadLibrary();
|
||||
loadVocalBooth(t.track_id);
|
||||
resetTake();
|
||||
}
|
||||
|
||||
function applyGenerationState(gen) {
|
||||
@@ -623,6 +1024,7 @@
|
||||
if (data.budget) {
|
||||
setMaxPerDay.value = data.budget.max_per_day || 10;
|
||||
}
|
||||
if (data.lyria) syncLyriaUI(data.lyria);
|
||||
updateModeBadge();
|
||||
} catch (_) {}
|
||||
}
|
||||
@@ -644,6 +1046,13 @@
|
||||
limits: {
|
||||
max_new_songs_per_day: parseInt(setMaxPerDay.value, 10) || 10,
|
||||
},
|
||||
lyria: {
|
||||
model: setLyriaModel.value,
|
||||
vocal_mode: setVocalMode.value,
|
||||
language: setLanguage.value,
|
||||
singer_profile: setSinger.value,
|
||||
output_format: setOutputFormat.value,
|
||||
},
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`${API}/api/settings`, {
|
||||
@@ -668,8 +1077,8 @@
|
||||
settingsPanel.classList.toggle('open');
|
||||
settingsBtn.classList.toggle('active', settingsPanel.classList.contains('open'));
|
||||
});
|
||||
[setShuffle, setMix, setChance, setMaxPerDay].forEach(el => {
|
||||
el.addEventListener('change', scheduleSaveSettings);
|
||||
[setShuffle, setMix, setChance, setMaxPerDay, setLyriaModel, setVocalMode, setLanguage, setSinger, setOutputFormat].forEach(el => {
|
||||
el.addEventListener('change', () => { updateLyriaHints(); scheduleSaveSettings(); });
|
||||
el.addEventListener('input', scheduleSaveSettings);
|
||||
});
|
||||
|
||||
@@ -863,6 +1272,7 @@
|
||||
loadLibrary();
|
||||
loadChat();
|
||||
loadSettings();
|
||||
loadLyriaCapabilities();
|
||||
loadStats();
|
||||
setInterval(() => { refreshNow(); loadStats(); }, 15000);
|
||||
</script>
|
||||
|
||||
|
Before
After
|
Reference in New Issue
Block a user