Add presenter view, stats, lyrics worker, watch party with voice chat, timestamp sharing, soundbites, notes, transcript search, PiP, EQ, sleep fade, gestures and external players; fix the crossfade end-of-song race
This commit is contained in:
152
frontend/stats-core.js
Normal file
152
frontend/stats-core.js
Normal file
@@ -0,0 +1,152 @@
|
||||
/* ============================================================================
|
||||
* stats-core.js — listening statistics (pure; window.StatsCore / node)
|
||||
*
|
||||
* Stored in data.stats (synced with the profile):
|
||||
* { v: 1,
|
||||
* days: { 'YYYY-MM-DD': { s: secondsListened, p: plays, songs: { id: plays } } },
|
||||
* meta: { id: { t: title, c: channel } } }
|
||||
* Days are LOCAL calendar days. A play counts once a song has been listened
|
||||
* to for 30 s in one load. Days older than KEEP_DAYS are pruned.
|
||||
* ========================================================================== */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
const KEEP_DAYS = 400;
|
||||
const META_MAX = 600;
|
||||
const STREAK_MIN_SEC = 5 * 60; // a day counts toward the streak at 5 min
|
||||
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const dayKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
const addDays = (d, n) => { const x = new Date(d.getFullYear(), d.getMonth(), d.getDate()); x.setDate(x.getDate() + n); return x; };
|
||||
|
||||
function empty() { return { v: 1, days: {}, meta: {} }; }
|
||||
|
||||
function ensure(stats) {
|
||||
if (!stats || typeof stats !== 'object' || !stats.days) return empty();
|
||||
if (!stats.meta) stats.meta = {};
|
||||
return stats;
|
||||
}
|
||||
|
||||
// Add listened seconds (and optionally one play) for a song on `now`'s day.
|
||||
function record(stats, song, seconds, countPlay, now = new Date()) {
|
||||
stats = ensure(stats);
|
||||
const k = dayKey(now);
|
||||
const d = stats.days[k] || (stats.days[k] = { s: 0, p: 0, songs: {} });
|
||||
if (seconds > 0) d.s = Math.round((d.s + seconds) * 10) / 10;
|
||||
if (countPlay && song && song.id) {
|
||||
d.p += 1;
|
||||
d.songs[song.id] = (d.songs[song.id] || 0) + 1;
|
||||
}
|
||||
if (song && song.id) {
|
||||
stats.meta[song.id] = { t: String(song.title || '').slice(0, 200), c: String(song.channel || '').slice(0, 120) };
|
||||
const ids = Object.keys(stats.meta);
|
||||
if (ids.length > META_MAX) {
|
||||
const used = new Set();
|
||||
Object.values(stats.days).forEach((x) => Object.keys(x.songs || {}).forEach((id) => used.add(id)));
|
||||
for (const id of ids) if (!used.has(id) && Object.keys(stats.meta).length > META_MAX) delete stats.meta[id];
|
||||
}
|
||||
}
|
||||
prune(stats, now);
|
||||
return stats;
|
||||
}
|
||||
|
||||
function prune(stats, now = new Date()) {
|
||||
const cutoff = dayKey(addDays(now, -KEEP_DAYS));
|
||||
for (const k of Object.keys(stats.days)) if (k < cutoff) delete stats.days[k];
|
||||
return stats;
|
||||
}
|
||||
|
||||
// "Hillsong Worship - Topic", "BethelVEVO" → artist name for grouping.
|
||||
function artistOf(channel) {
|
||||
return String(channel || '').replace(/\s*-\s*Topic$/i, '').replace(/VEVO$/i, '').replace(/\s+Official$/i, '').trim() || 'Unknown';
|
||||
}
|
||||
|
||||
function streaks(stats, now = new Date()) {
|
||||
const active = (d) => ((stats.days[dayKey(d)] || {}).s || 0) >= STREAK_MIN_SEC;
|
||||
// Current streak: ending today, or yesterday if today hasn't counted yet.
|
||||
let cur = 0;
|
||||
let d = active(now) ? now : addDays(now, -1);
|
||||
while (active(d)) { cur++; d = addDays(d, -1); }
|
||||
let best = 0, run = 0;
|
||||
const keys = Object.keys(stats.days).sort();
|
||||
if (keys.length) {
|
||||
const [y, m, dd] = keys[0].split('-').map(Number);
|
||||
for (let x = new Date(y, m - 1, dd); dayKey(x) <= dayKey(now); x = addDays(x, 1)) {
|
||||
run = active(x) ? run + 1 : 0;
|
||||
if (run > best) best = run;
|
||||
}
|
||||
}
|
||||
return { current: cur, best: Math.max(best, cur) };
|
||||
}
|
||||
|
||||
function summarize(statsIn, now = new Date()) {
|
||||
const stats = ensure(statsIn);
|
||||
const monthKey = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
|
||||
const thisMonth = monthKey(now);
|
||||
const lastMonth = monthKey(new Date(now.getFullYear(), now.getMonth() - 1, 1));
|
||||
let thisMonthSec = 0, lastMonthSec = 0, totalSec = 0, totalPlays = 0;
|
||||
const songsAll = {}, artistsMonth = {};
|
||||
for (const [k, d] of Object.entries(stats.days)) {
|
||||
totalSec += d.s || 0;
|
||||
totalPlays += d.p || 0;
|
||||
if (k.startsWith(thisMonth)) thisMonthSec += d.s || 0;
|
||||
if (k.startsWith(lastMonth)) lastMonthSec += d.s || 0;
|
||||
for (const [id, n] of Object.entries(d.songs || {})) {
|
||||
songsAll[id] = (songsAll[id] || 0) + n;
|
||||
if (k.startsWith(thisMonth)) {
|
||||
const a = artistOf((stats.meta[id] || {}).c);
|
||||
artistsMonth[a] = (artistsMonth[a] || 0) + n;
|
||||
}
|
||||
}
|
||||
}
|
||||
const topArtists = Object.entries(artistsMonth).sort((a, b) => b[1] - a[1]).slice(0, 5)
|
||||
.map(([name, plays]) => ({ name, plays }));
|
||||
|
||||
// Top songs over the last 30 days, each with a 30-point daily sparkline.
|
||||
const last30 = Array.from({ length: 30 }, (_, i) => dayKey(addDays(now, i - 29)));
|
||||
const recent = {};
|
||||
for (const k of last30) for (const [id, n] of Object.entries((stats.days[k] || {}).songs || {})) recent[id] = (recent[id] || 0) + n;
|
||||
const topSongs = Object.entries(recent).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([id, plays]) => ({
|
||||
id, plays, title: (stats.meta[id] || {}).t || id, channel: (stats.meta[id] || {}).c || '',
|
||||
spark: last30.map((k) => ((stats.days[k] || {}).songs || {})[id] || 0),
|
||||
}));
|
||||
|
||||
// 16-week heatmap: columns are weeks (oldest → current), rows Mon..Sun.
|
||||
const WEEKS = 16;
|
||||
const monIdx = (d) => (d.getDay() + 6) % 7; // Mon=0 … Sun=6
|
||||
const thisMonday = addDays(now, -monIdx(now));
|
||||
const start = addDays(thisMonday, -(WEEKS - 1) * 7);
|
||||
const heat = [];
|
||||
for (let w = 0; w < WEEKS; w++) {
|
||||
const col = [];
|
||||
for (let r = 0; r < 7; r++) {
|
||||
const d = addDays(start, w * 7 + r);
|
||||
const k = dayKey(d);
|
||||
col.push({ date: k, minutes: Math.round(((stats.days[k] || {}).s || 0) / 60), future: k > dayKey(now) });
|
||||
}
|
||||
heat.push(col);
|
||||
}
|
||||
// Peak weekday: average hours on that weekday across the heatmap window.
|
||||
const byDow = Array.from({ length: 7 }, () => ({ sum: 0, n: 0 }));
|
||||
heat.forEach((col) => col.forEach((c, r) => { if (!c.future) { byDow[r].sum += c.minutes; byDow[r].n += 1; } }));
|
||||
const names = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||||
let peak = null;
|
||||
byDow.forEach((x, i) => { const avg = x.n ? x.sum / x.n / 60 : 0; if (avg > 0 && (!peak || avg > peak.avgHours)) peak = { day: names[i], avgHours: avg }; });
|
||||
|
||||
return {
|
||||
totalSec, totalPlays, thisMonthSec, lastMonthSec,
|
||||
songsPlayed: Object.keys(songsAll).length,
|
||||
streak: streaks(stats, now), topArtists, topSongs, heat, peak,
|
||||
};
|
||||
}
|
||||
|
||||
function fmtHours(sec) {
|
||||
const m = Math.round((sec || 0) / 60);
|
||||
const h = Math.floor(m / 60);
|
||||
return h ? `${h}h ${pad(m % 60)}m` : `${m}m`;
|
||||
}
|
||||
|
||||
const StatsCore = { empty, ensure, record, prune, summarize, streaks, artistOf, dayKey, fmtHours, STREAK_MIN_SEC };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = StatsCore;
|
||||
else root.StatsCore = StatsCore;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||
Reference in New Issue
Block a user