feat: sidebar streamer counter + VOD duration badge + queue shimmer + chat polish

Round-4 polish.

- Streamer section counter. Tiny line next to the "Streamer" sidebar
  heading: "12" when nobody is live, "12 · 3 live" with the live
  count highlighted red when broadcasters from the watch list are
  on air. Re-rendered on every renderStreamers call so it stays in
  sync with add/remove and the 60s live-status poll.

- VOD duration badge. Twitch-style bottom-right pill on every VOD
  thumbnail showing the recordings duration ("32h37m9s"). 11px,
  white-on-near-black, 2px backdrop-blur, hover deepens the
  background, fades out when the storyboard preview activates so
  the preview frame reads cleanly. Pairs with the existing
  downloaded checkmark badge (top-left) and live-recording badge
  to give each thumbnail a complete at-a-glance status row.

- Queue progress bar shimmer. The fill bar now uses a purple-to-
  light-purple gradient and rides a moving white-translucent
  highlight strip that sweeps L->R every 1.8s. Same translateX-100%
  to 100% trick used everywhere else, but only visible because
  the underlying bar has colour. Makes "currently downloading"
  obvious without needing a separate spinner.

- Chat viewer polish. Replaced the inline per-message styling with
  proper .chat-viewer-* classes: hoverable row background, system
  events (subs/raids/deletions) get a left-purple-border + tinted
  background to set them apart from normal chat lines, the type
  tag (e.g. [sub], [raid]) renders as a real chip with a border,
  timestamps are mono-fonted and faded. Per-user IRC colour from
  Twitch metadata is still respected as an inline override on the
  username.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
xRangerDE 2026-05-11 01:23:18 +02:00
parent 8edbef0a60
commit f7a54a2007
4 changed files with 159 additions and 25 deletions

View File

@ -240,7 +240,10 @@
</nav>
<div class="section-title" id="streamerSectionTitle" style="display:flex; align-items:center; gap:6px; justify-content:space-between;">
<span id="streamerSectionTitleText">Streamer</span>
<span style="display:flex; align-items:baseline; gap:8px;">
<span id="streamerSectionTitleText">Streamer</span>
<span id="streamerSectionCounter" class="streamer-section-counter"></span>
</span>
<button id="btnStreamerBulkRemove" class="btn-close" type="button" onclick="bulkRemoveStreamers()" title="Bulk remove" style="display:none;">x</button>
</div>
<input type="text" id="streamerListFilter" placeholder="Filter..." oninput="onStreamerListFilterChange()" style="display:none; width:calc(100% - 16px); margin:0 8px 8px; background:var(--bg-card); border:1px solid var(--border-soft); border-radius:4px; padding:4px 8px; color:var(--text); font-size:12px;">

View File

@ -257,6 +257,7 @@ function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set<string
<input type="checkbox" class="vod-select-checkbox" data-vod-url="${safeUrlAttr}" ${isChecked ? 'checked' : ''} title="${escapeHtml(UI_TEXT.vods.bulkSelectedCount.replace('{count}', '0').replace(/[0-9]/g, '').trim() || 'Select')}" style="position:absolute; top:8px; left:8px; width:18px; height:18px; accent-color:#9146FF; cursor:pointer; z-index:2;">
${downloadedBadge}
<img class="vod-thumbnail" loading="lazy" decoding="async" src="${thumb}" alt="" title="${escapeHtml(UI_TEXT.vods.openOnTwitch)}" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 180%22><rect fill=%22%23333%22 width=%22320%22 height=%22180%22/></svg>'">
<div class="vod-duration-badge">${escapeHtml(vod.duration)}</div>
<div class="vod-info">
<div class="vod-title" title="${escapeHtml(vod.title || '')}">${safeDisplayTitle}</div>
<div class="vod-meta">
@ -425,6 +426,20 @@ function renderStreamers(): void {
// Compact title margin when filter is shown — avoids double gap.
if (sectionTitle) sectionTitle.style.marginBottom = showFilter ? '4px' : '';
// Section counter — "X · Y live". Updates on every re-render, so it
// stays accurate after add/remove/live-status changes.
const counter = document.getElementById('streamerSectionCounter');
if (counter) {
const liveCount = all.reduce((n, s) => n + (liveStatusByLogin.get(s.toLowerCase()) === true ? 1 : 0), 0);
if (all.length === 0) {
counter.textContent = '';
} else if (liveCount > 0) {
counter.innerHTML = `${all.length} <span class="streamer-section-counter-divider">·</span> <span class="streamer-section-counter-live">${liveCount} live</span>`;
} else {
counter.textContent = String(all.length);
}
}
const q = (streamerListFilterQuery || '').trim().toLowerCase();
const visible = q ? all.filter((s) => s.toLowerCase().includes(q)) : all;

View File

@ -464,44 +464,41 @@ function renderChatViewerList(messages: ChatViewerMessage[]): void {
const end = Math.min(idx + CHUNK, messages.length);
for (let i = idx; i < end; i++) {
const m = messages[i];
const isMessageType = m.type === 'msg' || !m.type;
const row = document.createElement('div');
row.style.padding = '2px 0';
row.style.lineHeight = '1.5';
row.className = 'chat-viewer-row' + (!isMessageType ? ' is-system' : '');
// System events (subs, raids, deletions) lead with a faint tag.
if (!isMessageType) {
const tag = document.createElement('span');
tag.className = 'chat-viewer-tag';
tag.textContent = m.type || 'event';
row.appendChild(tag);
}
const time = formatChatTimeMarker(m);
if (time) {
const tSpan = document.createElement('span');
tSpan.style.color = 'var(--text-secondary)';
tSpan.style.marginRight = '6px';
tSpan.textContent = `[${time}]`;
tSpan.className = 'chat-viewer-time';
tSpan.textContent = time;
row.appendChild(tSpan);
}
const user = m.u || m.user || m.login || '';
if (user) {
const uSpan = document.createElement('span');
uSpan.style.fontWeight = '600';
uSpan.style.color = m.color || 'var(--accent)';
uSpan.style.marginRight = '4px';
uSpan.className = 'chat-viewer-user';
// Per-user IRC color is preserved; the class supplies weight.
if (m.color) uSpan.style.color = m.color;
else uSpan.style.color = 'var(--accent)';
uSpan.textContent = `${user}:`;
row.appendChild(uSpan);
}
const msgSpan = document.createElement('span');
msgSpan.textContent = m.msg || m.text || '';
msgSpan.textContent = ' ' + (m.msg || m.text || '');
row.appendChild(msgSpan);
// System events (subs, raids, deletions) get a faint bracketed prefix
const isMessageType = m.type === 'msg' || !m.type;
if (!isMessageType) {
const tag = document.createElement('span');
tag.style.color = 'var(--text-secondary)';
tag.style.fontStyle = 'italic';
tag.style.marginRight = '4px';
tag.textContent = `[${m.type}]`;
row.insertBefore(tag, row.firstChild);
}
fragment.appendChild(row);
}
list.appendChild(fragment);

View File

@ -459,17 +459,36 @@ select:focus {
}
.queue-progress-wrap {
height: 4px;
height: 5px;
border-radius: 999px;
overflow: hidden;
background: rgba(255,255,255,0.12);
background: rgba(255,255,255,0.10);
position: relative;
}
.queue-progress-bar {
height: 100%;
width: 0;
background: var(--accent);
transition: width 0.2s ease;
background: linear-gradient(90deg, var(--accent) 0%, #b97aff 100%);
transition: width 0.3s ease;
position: relative;
overflow: hidden;
}
/* Moving shimmer overlay implies "active" without needing a different
border or pulse rule. Only visible because the bar is purple. */
.queue-progress-bar::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.35) 50%, transparent 100%);
transform: translateX(-100%);
animation: queue-progress-shimmer 1.8s ease-in-out infinite;
}
@keyframes queue-progress-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.queue-progress-bar.indeterminate {
@ -2315,6 +2334,106 @@ body.theme-light .modal {
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 70, 70, 0.25);
}
/* ============================================
STREAMER SECTION COUNTER
============================================
Tiny "X · Y live" line next to the "Streamer" section heading.
Updated by renderStreamers on every redraw. */
.streamer-section-counter {
font-size: 11px;
color: var(--text-secondary);
font-weight: 400;
letter-spacing: 0.2px;
}
.streamer-section-counter-divider {
opacity: 0.5;
margin: 0 1px;
}
.streamer-section-counter-live {
color: #e91916;
font-weight: 600;
}
/* ============================================
VOD DURATION BADGE Twitch-style pill on the thumbnail
============================================ */
.vod-duration-badge {
position: absolute;
bottom: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.78);
color: #fff;
font-size: 11px;
font-weight: 600;
padding: 3px 7px;
border-radius: 3px;
z-index: 1;
letter-spacing: 0.3px;
backdrop-filter: blur(2px);
pointer-events: none;
}
.vod-card:hover .vod-duration-badge {
background: rgba(0, 0, 0, 0.88);
}
.vod-card.preview-active .vod-duration-badge {
opacity: 0;
transition: opacity 0.2s;
}
/* ============================================
CHAT VIEWER Twitch-chat-like message rows
============================================
Replaces the inline-style chat row inside the chat-viewer modal
with proper class-based styling. Renderer still uses inline
per-message colour for the username (driven by Twitch's IRC color
metadata). */
.chat-viewer-row {
padding: 4px 8px;
line-height: 1.55;
border-radius: 4px;
transition: background 0.12s;
font-size: 13px;
word-wrap: break-word;
}
.chat-viewer-row:hover {
background: rgba(255, 255, 255, 0.04);
}
.chat-viewer-row .chat-viewer-time {
color: var(--text-secondary);
margin-right: 8px;
font-size: 10px;
opacity: 0.7;
font-family: 'Segoe UI Mono', 'Consolas', monospace;
}
.chat-viewer-row .chat-viewer-user {
font-weight: 700;
margin-right: 4px;
}
.chat-viewer-row .chat-viewer-tag {
color: var(--accent);
font-style: italic;
font-size: 11px;
margin-right: 6px;
background: rgba(145, 70, 255, 0.12);
padding: 1px 6px;
border-radius: 3px;
border: 1px solid rgba(145, 70, 255, 0.3);
}
.chat-viewer-row.is-system {
background: rgba(145, 70, 255, 0.05);
border-left: 2px solid rgba(145, 70, 255, 0.45);
padding-left: 10px;
}
/* ============================================
STREAMER PROFILE HEADER
============================================