Files
ytplayer/frontend/stats-view.js
2026-10-08 04:46:40 +08:00

125 lines
7.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* Lazy classic chunk; its executing singleton stays pinned until reload. */
(window.YT ||= {}).StatsView = { render: async function renderStats() {
try { await Lazy.load('feature:stats'); } catch (error) { toast(error.message); return; }
if (view.type !== 'stats') return;
StatsTrack.flush();
els.listTitle.textContent = 'Stats';
resetListHeaderExtras();
els.listActions.innerHTML = '';
els.status.classList.add('hidden');
const c = els.cards;
const S = StatsCore.summarize(data.stats);
if (!S.totalPlays && S.totalSec < 60) {
c.innerHTML = `
<div class="empty-state stats-empty">
<div class="empty-icon">📊</div>
<h3 class="empty-title">No listening stats yet</h3>
<p class="empty-desc">Stats start counting now — a song counts as played after 30 seconds. They sync with your online profile.</p>
</div>`;
return;
}
const hrs = (sec) => StatsCore.fmtHours(sec);
const delta = S.thisMonthSec - S.lastMonthSec;
const deltaTxt = S.lastMonthSec || S.thisMonthSec
? `${delta >= 0 ? '▲ +' : '▼ −'}${(Math.abs(delta) / 3600).toFixed(1)}h vs last month`
: '';
const maxArtist = Math.max(1, ...S.topArtists.map((a) => a.plays));
const spark = (vals) => {
const w = 96, h = 26, max = Math.max(1, ...vals);
const pts = vals.map((v, i) => `${(i / (vals.length - 1)) * w},${h - 3 - (v / max) * (h - 6)}`).join(' ');
return `<svg class="st-spark" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}" aria-hidden="true"><polyline points="${pts}" /></svg>`;
};
const heatMax = Math.max(1, ...S.heat.flat().map((x) => x.minutes));
const level = (m) => (m <= 0 ? 0 : Math.min(4, 1 + Math.floor((m / heatMax) * 3.999)));
const fmtDay = (k) => new Date(`${k}T12:00:00`).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
// Month label over the first week column of each month.
const months = S.heat.map((col, w) => {
const d = new Date(`${col[0].date}T12:00:00`);
const prev = w ? new Date(`${S.heat[w - 1][0].date}T12:00:00`) : null;
return !prev || prev.getMonth() !== d.getMonth() ? d.toLocaleDateString(undefined, { month: 'short' }) : '';
});
const playlistsWith = data.playlists.length;
c.innerHTML = `
<div class="stats">
<div class="st-tiles">
<div class="st-tile">
<div class="st-label">⏱ Listening this month</div>
<div class="st-big">${hrs(S.thisMonthSec)}</div>
<div class="st-sub">${escapeHtml(deltaTxt)}${deltaTxt ? ' · ' : ''}${hrs(S.totalSec)} all time</div>
</div>
<div class="st-tile">
<div class="st-label">▶ Track plays</div>
<div class="st-big">${S.totalPlays}</div>
<div class="st-sub">${S.songsPlayed} song${S.songsPlayed === 1 ? '' : 's'} · ${playlistsWith} playlist${playlistsWith === 1 ? '' : 's'}</div>
</div>
<div class="st-tile">
<div class="st-label">🔥 Practice streak</div>
<div class="st-big">${S.streak.current}-day</div>
<div class="st-sub">${S.streak.current ? 'Streak active' : 'Listen 5 min today to start one'} · best ${S.streak.best} day${S.streak.best === 1 ? '' : 's'}</div>
</div>
</div>
<div class="st-grid">
<section class="st-panel">
<div class="st-panel-head"><span>🎤 Top artists</span><small>plays this month</small></div>
${S.topArtists.length ? S.topArtists.map((a) => `
<div class="st-bar-row" data-tip="${escapeHtml(a.name)} — ${a.plays} play${a.plays === 1 ? '' : 's'} this month">
<span class="st-bar-name">${escapeHtml(a.name)}</span>
<span class="st-bar-track"><span class="st-bar" style="width:${Math.max(3, (a.plays / maxArtist) * 100)}%"></span></span>
<span class="st-bar-val">${a.plays}</span>
</div>`).join('') : '<div class="st-none">No plays this month yet.</div>'}
</section>
<section class="st-panel">
<div class="st-panel-head"><span>🎵 Top songs</span><small>last 30 days</small></div>
${S.topSongs.length ? S.topSongs.map((t, i) => `
<div class="st-song" data-tip="${escapeHtml(t.title)} — ${t.plays} play${t.plays === 1 ? '' : 's'} in 30 days" data-id="${escapeHtml(t.id)}">
<span class="st-rank">${String(i + 1).padStart(2, '0')}</span>
<span class="st-song-text"><b>${escapeHtml(t.title)}</b><small>${escapeHtml(t.channel)}</small></span>
${spark(t.spark)}
<span class="st-bar-val">${t.plays}</span>
</div>`).join('') : '<div class="st-none">No plays in the last 30 days.</div>'}
</section>
</div>
<section class="st-panel">
<div class="st-panel-head"><span>🗓 Listening habits</span><small>last 16 weeks${S.peak ? ` · peak day: ${S.peak.day}s (avg ${S.peak.avgHours.toFixed(1)} h)` : ''}</small></div>
<div class="st-heat-wrap">
<div class="st-heat-days"><span></span><span>Mon</span><span></span><span>Wed</span><span></span><span>Fri</span><span></span><span>Sun</span></div>
<div class="st-heat-main">
<div class="st-heat-months">${months.map((m) => `<span>${m}</span>`).join('')}</div>
<div class="st-heat">${S.heat.map((col) => col.map((x) => x.future
? '<span class="st-cell future"></span>'
: `<span class="st-cell l${level(x.minutes)}" data-tip="${fmtDay(x.date)} · ${x.minutes} min"></span>`).join('')).join('')}</div>
</div>
</div>
<div class="st-legend"><span>Less</span>${[0, 1, 2, 3, 4].map((l) => `<span class="st-cell l${l}"></span>`).join('')}<span>More</span></div>
<details class="st-table"><summary>Show as table</summary>
<table><thead><tr><th>Day</th><th>Minutes</th></tr></thead><tbody>
${S.heat.flat().filter((x) => !x.future && x.minutes > 0).reverse().map((x) => `<tr><td>${fmtDay(x.date)}</td><td>${x.minutes}</td></tr>`).join('') || '<tr><td colspan="2">No listening in this window.</td></tr>'}
</tbody></table>
</details>
</section>
<div id="statsTip" class="st-tip hidden"></div>
</div>`;
// One tooltip for every mark (bars, songs, heat cells).
const root = c.querySelector('.stats');
const tip = $('statsTip');
root.addEventListener('pointermove', (e) => {
const el = e.target.closest('[data-tip]');
if (!el) { tip.classList.add('hidden'); return; }
tip.textContent = el.dataset.tip;
tip.classList.remove('hidden');
const r = root.getBoundingClientRect();
const half = tip.offsetWidth / 2 + 4; // keep the whole tip inside the panel
tip.style.left = `${Math.min(r.width - half, Math.max(half, e.clientX - r.left))}px`;
tip.style.top = `${e.clientY - r.top - 14}px`;
});
root.addEventListener('pointerleave', () => tip.classList.add('hidden'));
root.querySelectorAll('.st-song').forEach((row) => row.addEventListener('click', () => {
const v = data.history.find((x) => x.id === row.dataset.id) || { id: row.dataset.id, title: row.querySelector('b').textContent };
Player.loadVideo(v);
}));
} };