MPD and Scene fixes + twitch-bot cleanup

This commit is contained in:
Jakub Zych
2026-04-28 12:13:42 +02:00
parent 4c818e327b
commit 32be7e4073
16 changed files with 858 additions and 972 deletions

View File

@@ -189,11 +189,11 @@ body {
color: var(--term-fg);
text-shadow: 0 0 6px var(--term-glow);
display: grid;
/* art | meta | LIBRARY | <slack 1fr> | spectrum | progress
/* art | meta | FILE | LIBRARY | <slack 1fr> | spectrum | progress
Slack sits between the upper info block and the bottom strip so the
spectrum analyzer reserve docks just above the progress bar — pairs
them visually as a single "playback" zone at the foot of the pane. */
grid-template-rows: auto auto auto 1fr auto auto;
grid-template-rows: auto auto auto auto 1fr auto auto;
gap: 18px;
}
@@ -283,6 +283,14 @@ body {
}
.np-stats .stats-grid .val.empty { color: var(--term-fg-dim); }
/* Lossless badge — bright green for FORMAT when the codec is FLAC/ALAC/WAV
so the "high quality" cue reads at a glance. Lossy formats (MP3/Opus/AAC)
stay in the regular term-fg color. */
.np-stats .stats-grid .val.lossless {
color: var(--term-fg-bright);
text-shadow: 0 0 8px var(--term-glow);
}
/* Spectrum slot — reserves the row that the waveform plugin source sits
on top of. Empty + transparent: the plugin draws into this region, the
widget just holds the layout space so progress stays in its expected
@@ -438,6 +446,14 @@ body {
<span class="key">YEAR</span> <span class="val empty" id="metaYear"></span>
<span class="key">GENRE</span> <span class="val empty" id="metaGenre"></span>
</div>
<div class="np-stats">
<div class="stats-label">─ FILE ─</div>
<div class="stats-grid">
<span class="key">FORMAT</span> <span class="val empty" id="fileFormat"></span>
<span class="key">QUALITY</span><span class="val empty" id="fileQuality"></span>
<span class="key">BITRATE</span><span class="val empty" id="fileBitrate"></span>
</div>
</div>
<div class="np-stats">
<div class="stats-label">─ LIBRARY ─</div>
<div class="stats-grid">
@@ -530,6 +546,7 @@ body {
const npTitle = pinnedBlock.querySelector('.np-title');
const npTime = pinnedBlock.querySelector('.np-time');
const nextSlots = [0, 1, 2].map(i => pinnedBlock.querySelector(`[data-slot="${i}"]`));
const nextRows = nextSlots.map(slot => slot.closest('.next-line'));
const PROMPT = 'OPHI-118://> ';
const MAX_TERM_LINES = 200;
const MODE_KEYS = ['repeat', 'random', 'single'];
@@ -610,6 +627,20 @@ body {
return parts.join(' ');
}
function nextPreviewConfig(playback) {
const p = normalizePlayback(playback);
if (p.single && p.repeat) {
return { count: 1, empty: '— repeat single armed —' };
}
if (p.single) {
return { count: 0, empty: '— single mode —' };
}
if (p.random) {
return { count: 1, empty: '— shuffle active —' };
}
return { count: 3, empty: '— end of queue —' };
}
async function logModeChange(mode, enabled) {
await typeCommand(`music --${mode}=${enabled ? 'true' : 'false'}`);
logBeforePins(`[audio] ${mode} mode ${enabled ? 'enabled' : 'disabled'}`, 'term-out', null);
@@ -640,14 +671,23 @@ body {
npTitle.textContent = s.title || '—';
npTime.textContent = `[${fmtClock(s.currentTime)} / ${fmtClock(s.duration)}]`;
const titles = Array.isArray(s.nextTitles) ? s.nextTitles : [];
const preview = nextPreviewConfig(s.playback);
nextSlots.forEach((slot, i) => {
const v = titles[i];
const v = i < preview.count ? titles[i] : null;
if (v) {
slot.textContent = v;
slot.classList.remove('empty');
nextRows[i].style.display = '';
} else {
slot.textContent = i === 0 ? '— end of queue —' : '—';
slot.classList.add('empty');
if (i === 0) {
slot.textContent = preview.empty;
slot.classList.add('empty');
nextRows[i].style.display = '';
} else {
slot.textContent = '';
slot.classList.add('empty');
nextRows[i].style.display = 'none';
}
}
});
@@ -669,6 +709,15 @@ body {
progElapsed.textContent = fmtClock(cur);
progTotal.textContent = fmtClock(dur);
// File / audio info — codec from extension, sample rate + bit depth +
// bitrate from MPD `status.audio` and `status.bitrate`. FLAC and friends
// get the bright "lossless" treatment on FORMAT to advertise quality.
const audio = s.audio || {};
setMeta(fileFormat, audio.format);
fileFormat.classList.toggle('lossless', LOSSLESS_FORMATS.has(audio.format));
setMeta(fileQuality, fmtQuality(audio));
setMeta(fileBitrate, audio.bitrate ? `${fmtCount(audio.bitrate)} kbps` : null);
// Library stats — bridge updates `stats` ~every 30s. Queue is built
// from the per-tick index/total fields; render 1-indexed (mpc style).
const stats = s.stats;
@@ -721,6 +770,33 @@ body {
const statSongs = document.getElementById('statSongs');
const statPlaytime = document.getElementById('statPlaytime');
const statQueue = document.getElementById('statQueue');
const fileFormat = document.getElementById('fileFormat');
const fileQuality = document.getElementById('fileQuality');
const fileBitrate = document.getElementById('fileBitrate');
// Codecs whose bitstream is mathematically lossless — drives the bright
// FORMAT highlight in the FILE block.
const LOSSLESS_FORMATS = new Set(['FLAC', 'ALAC', 'WAV', 'WAVPACK', 'APE']);
// "44100" → "44.1 kHz", "96000" → "96 kHz". Trim the decimal when the rate
// is a clean multiple of 1000 so common rates render as "44.1/48/96 kHz".
function fmtSampleRate(hz) {
if (!hz || !isFinite(hz) || hz <= 0) return null;
const khz = hz / 1000;
const s = (khz % 1 === 0) ? khz.toFixed(0) : khz.toFixed(1);
return `${s} kHz`;
}
// Quality cell merges sample rate + bit depth: "96 kHz · 24-bit". Either
// half can be missing (DSD streams report bits='dsd64' which we drop, MP3s
// sometimes lack a clean rate at start of decode).
function fmtQuality(audio) {
const sr = fmtSampleRate(audio.samplerate);
const bits = (typeof audio.bits === 'number' && audio.bits > 0)
? `${audio.bits}-bit` : null;
if (sr && bits) return `${sr} · ${bits}`;
return sr || bits || null;
}
const fmtCount = n => (typeof n === 'number' && isFinite(n))
? n.toLocaleString('en-US') : null;