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:
parent
8edbef0a60
commit
f7a54a2007
@ -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;">
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
127
src/styles.css
127
src/styles.css
@ -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
|
||||
============================================
|
||||
|
||||
Loading…
Reference in New Issue
Block a user