Add master display UI with audio output management and styling
- Implement main.js for the master display functionality, including WebSocket connection, audio output management, and state handling. - Create style.css for the master display's visual design, ensuring a cohesive look and feel with a dark theme and responsive layout. - Integrate device management with a fallback for non-Electron environments, allowing users to select audio outputs. - Add features for managing favorites, including toggling favorites and filtering by genre. - Enhance user experience with a responsive favorites grid and drag-to-scroll functionality.
This commit is contained in:
111
server/stats.js
111
server/stats.js
@@ -1,23 +1,47 @@
|
||||
// Vote + play stats and the ranking algorithm.
|
||||
//
|
||||
// Score combines two signals:
|
||||
// Score combines three signals:
|
||||
// - voteZ = (up - down) / sqrt(up + down + 1) z-like, penalizes small N
|
||||
// - playLog = log10(plays + 1) gentle popularity boost
|
||||
// - score = voteZ + 0.5 * playLog
|
||||
// - timeLog = log10(hours_listened + 1) rewards actual listen time
|
||||
// - score = voteZ + 0.5 * playLog + 0.4 * timeLog
|
||||
//
|
||||
// Net effect:
|
||||
// * A handful of downvotes on an obscure station sinks it hard.
|
||||
// * One stray upvote on a brand new station barely moves it.
|
||||
// * Popular stations float up only if they aren't being actively buried.
|
||||
// * Pressing play and skipping immediately barely counts; sticking with a
|
||||
// station for hours/days pushes it up the leaderboard.
|
||||
// * Established + positively-voted stations dominate the top.
|
||||
|
||||
import { getDb } from './db/index.js';
|
||||
|
||||
export function computeScore({ up = 0, down = 0, plays = 0 } = {}) {
|
||||
// Sessions longer than this are almost certainly a forgotten-tab leak (laptop
|
||||
// closed, browser put to sleep). Clamp so one user can't poison total_play_ms.
|
||||
const MAX_SESSION_MS = 6 * 60 * 60 * 1000; // 6 hours
|
||||
// Sessions shorter than this don't get credited toward listen time (people
|
||||
// scrubbing through stations should still bump `plays`, but not `total_play_ms`).
|
||||
const MIN_SESSION_MS = 3 * 1000;
|
||||
|
||||
export function computeScore({ up = 0, down = 0, plays = 0, totalPlayMs = 0 } = {}) {
|
||||
const n = up + down;
|
||||
const voteZ = n === 0 ? 0 : (up - down) / Math.sqrt(n + 1);
|
||||
const playLog = Math.log10(plays + 1);
|
||||
return voteZ + 0.5 * playLog;
|
||||
const hours = totalPlayMs / 3600000;
|
||||
const timeLog = Math.log10(hours + 1);
|
||||
return voteZ + 0.5 * playLog + 0.4 * timeLog;
|
||||
}
|
||||
|
||||
function statsFromRow(r) {
|
||||
const up = r.up || 0;
|
||||
const down = r.down || 0;
|
||||
const plays = r.plays || 0;
|
||||
const sessions = r.sessions || 0;
|
||||
const totalPlayMs = r.total_play_ms || 0;
|
||||
const avgSessionMs = sessions > 0 ? Math.round(totalPlayMs / sessions) : 0;
|
||||
return {
|
||||
up, down, plays, sessions, totalPlayMs, avgSessionMs,
|
||||
score: computeScore({ up, down, plays, totalPlayMs })
|
||||
};
|
||||
}
|
||||
|
||||
export function getStationStats(stationId, userId = null) {
|
||||
@@ -28,14 +52,15 @@ export function getStationStats(stationId, userId = null) {
|
||||
COALESCE(SUM(CASE WHEN value = -1 THEN 1 ELSE 0 END), 0) AS down
|
||||
FROM station_votes WHERE station_id = ?
|
||||
`).get(stationId) || { up: 0, down: 0 };
|
||||
const p = db.prepare('SELECT plays FROM station_plays WHERE station_id = ?').get(stationId);
|
||||
const plays = p?.plays || 0;
|
||||
const p = db.prepare(`
|
||||
SELECT plays, sessions, total_play_ms FROM station_plays WHERE station_id = ?
|
||||
`).get(stationId) || {};
|
||||
let myVote = 0;
|
||||
if (userId) {
|
||||
const r = db.prepare('SELECT value FROM station_votes WHERE user_id = ? AND station_id = ?').get(userId, stationId);
|
||||
myVote = r?.value || 0;
|
||||
}
|
||||
return { up: v.up, down: v.down, plays, myVote, score: computeScore({ up: v.up, down: v.down, plays }) };
|
||||
return { ...statsFromRow({ ...v, ...p }), myVote };
|
||||
}
|
||||
|
||||
// Bulk stats for many stations in one query. Returns a Map<station_id, stats>.
|
||||
@@ -46,7 +71,9 @@ export function getStatsMap(userId = null) {
|
||||
s.id AS station_id,
|
||||
COALESCE(v.up, 0) AS up,
|
||||
COALESCE(v.down, 0) AS down,
|
||||
COALESCE(p.plays, 0) AS plays
|
||||
COALESCE(p.plays, 0) AS plays,
|
||||
COALESCE(p.sessions, 0) AS sessions,
|
||||
COALESCE(p.total_play_ms, 0) AS total_play_ms
|
||||
FROM stations s
|
||||
LEFT JOIN (
|
||||
SELECT station_id,
|
||||
@@ -65,11 +92,7 @@ export function getStatsMap(userId = null) {
|
||||
}
|
||||
const out = new Map();
|
||||
for (const r of rows) {
|
||||
const myVote = my.get(r.station_id) || 0;
|
||||
out.set(r.station_id, {
|
||||
up: r.up, down: r.down, plays: r.plays, myVote,
|
||||
score: computeScore({ up: r.up, down: r.down, plays: r.plays })
|
||||
});
|
||||
out.set(r.station_id, { ...statsFromRow(r), myVote: my.get(r.station_id) || 0 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -89,18 +112,67 @@ export function castVote(userId, stationId, value) {
|
||||
return getStationStats(stationId, userId);
|
||||
}
|
||||
|
||||
export function recordPlay(stationId) {
|
||||
getDb().prepare(`
|
||||
INSERT INTO station_plays (station_id, plays, last_played_at) VALUES (?, 1, datetime('now'))
|
||||
// Record the start of a listening session. Bumps the play counter immediately
|
||||
// (so spam-clickers still register as taps) and opens a play_history row that
|
||||
// `endPlaySession` will close with a duration. Returns the new session id when
|
||||
// a user is known, or null for anonymous plays (which still bump the counter).
|
||||
export function recordPlay(stationId, userId = null, streamId = null) {
|
||||
const db = getDb();
|
||||
db.prepare(`
|
||||
INSERT INTO station_plays (station_id, plays, sessions, total_play_ms, last_played_at)
|
||||
VALUES (?, 1, 0, 0, datetime('now'))
|
||||
ON CONFLICT(station_id) DO UPDATE SET
|
||||
plays = station_plays.plays + 1,
|
||||
last_played_at = datetime('now')
|
||||
`).run(stationId);
|
||||
if (!userId) return null;
|
||||
const info = db.prepare(`
|
||||
INSERT INTO play_history (user_id, station_id, stream_id, started_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
`).run(userId, stationId, streamId || null);
|
||||
return info.lastInsertRowid;
|
||||
}
|
||||
|
||||
// Close a session opened by recordPlay. `durationMs` is optional — when the
|
||||
// client knows the real wall-clock listen time (e.g. an `audio.currentTime`
|
||||
// derivative) it should pass it; otherwise we compute it from started_at.
|
||||
// Returns the station_id we closed against, or null when the session is
|
||||
// unknown / already closed / belongs to someone else.
|
||||
export function endPlaySession(sessionId, userId, durationMs = null) {
|
||||
const db = getDb();
|
||||
const row = db.prepare(`
|
||||
SELECT id, station_id, user_id, started_at, ended_at
|
||||
FROM play_history WHERE id = ?
|
||||
`).get(sessionId);
|
||||
if (!row || row.user_id !== userId || row.ended_at) return null;
|
||||
|
||||
let ms = Number.isFinite(durationMs) && durationMs >= 0
|
||||
? Math.floor(durationMs)
|
||||
: Math.max(0, Date.now() - Date.parse(String(row.started_at).replace(' ', 'T') + 'Z'));
|
||||
if (!Number.isFinite(ms) || ms < 0) ms = 0;
|
||||
if (ms > MAX_SESSION_MS) ms = MAX_SESSION_MS;
|
||||
|
||||
db.prepare(`UPDATE play_history SET ended_at = datetime('now') WHERE id = ?`).run(sessionId);
|
||||
|
||||
// Only credit listen-time aggregates for "real" sessions; sub-second
|
||||
// sessions don't earn the station any score, but they already bumped
|
||||
// `plays` in recordPlay so they aren't completely free either.
|
||||
if (ms >= MIN_SESSION_MS) {
|
||||
db.prepare(`
|
||||
INSERT INTO station_plays (station_id, plays, sessions, total_play_ms, last_played_at)
|
||||
VALUES (?, 0, 1, ?, datetime('now'))
|
||||
ON CONFLICT(station_id) DO UPDATE SET
|
||||
sessions = station_plays.sessions + 1,
|
||||
total_play_ms = station_plays.total_play_ms + excluded.total_play_ms,
|
||||
last_played_at = datetime('now')
|
||||
`).run(row.station_id, ms);
|
||||
}
|
||||
return row.station_id;
|
||||
}
|
||||
|
||||
// Sort helper used by routes. Mutates the array.
|
||||
export function sortByMode(items, mode, statsMap) {
|
||||
const s = (id) => statsMap.get(id) || { up: 0, down: 0, plays: 0, score: 0 };
|
||||
const s = (id) => statsMap.get(id) || { up: 0, down: 0, plays: 0, totalPlayMs: 0, score: 0 };
|
||||
switch (mode) {
|
||||
case 'hot':
|
||||
items.sort((a, b) => s(b.id).score - s(a.id).score || a.name.localeCompare(b.name));
|
||||
@@ -111,6 +183,9 @@ export function sortByMode(items, mode, statsMap) {
|
||||
case 'plays':
|
||||
items.sort((a, b) => s(b.id).plays - s(a.id).plays || a.name.localeCompare(b.name));
|
||||
break;
|
||||
case 'playtime':
|
||||
items.sort((a, b) => s(b.id).totalPlayMs - s(a.id).totalPlayMs || a.name.localeCompare(b.name));
|
||||
break;
|
||||
case 'controversial':
|
||||
items.sort((a, b) => {
|
||||
const A = s(a.id), B = s(b.id);
|
||||
|
||||
Reference in New Issue
Block a user