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:
Jonathan Sykes
2026-09-20 00:07:12 +08:00
parent ca07e1fdf1
commit 9c1ec4ac51
17 changed files with 2912 additions and 111 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -39,7 +39,10 @@
<button class="nav-item" data-view="history">🕘 History</button>
<button class="nav-item" data-view="saved">💾 Saved</button>
<button class="nav-item" data-view="downloads">⬇ Downloads <span id="navDlCount" class="nav-badge hidden">0</span></button>
<button class="nav-item" data-view="notes">📝 Notes</button>
<button class="nav-item" data-view="stats">📊 Stats</button>
<button class="nav-item" data-view="settings">⚙ Settings</button>
<button class="nav-item" id="navPartyBtn" type="button" title="Watch together with friends">🎉 Watch party</button>
<button class="nav-item" id="navRemoteBtn" type="button" title="Control this screen from a phone, or use this phone as a remote">📱 Remote</button>
</nav>
@@ -135,6 +138,8 @@
</div>
</div>
<div id="bufferSpinner" class="spinner hidden"></div>
<!-- Touch gesture feedback (Gestures in app.js) -->
<div id="gestureHud" class="gesture-hud hidden"></div>
</div>
<div id="controls" class="controls hidden">
@@ -193,6 +198,8 @@
<button id="loopBtn" class="ctrl" title="Loop current video (L)">🔂</button>
<button id="repeatBtn" class="ctrl" title="Repeat list when finished (R)">🔁</button>
<button id="transitionBtn" class="ctrl" title="Between songs in a playlist: Normal — click to change">↦</button>
<button id="eqBtn" class="ctrl" title="Equalizer (E)">🎚</button>
<button id="pipBtn" class="ctrl hidden" title="Picture-in-picture (I)">⧉</button>
<button id="fsBtn" class="ctrl" title="Fullscreen">⛶</button>
</div>
</div>
@@ -212,6 +219,8 @@
<button id="saveBtn" class="np-btn" title="Save this video for offline playback">⬇ Save</button>
<button id="editBtn" class="np-btn" title="Cut parts out and save a custom edited copy">✂ Edit &amp; download</button>
<button id="addPlaylistBtn" class="np-btn" title="Add to a playlist">+ Playlist</button>
<button id="shareMomentBtn" class="np-btn" title="Share a link or GIF of this exact moment">🔗 Share moment</button>
<button id="externalBtn" class="np-btn" title="Play this in VLC, MX Player or another app">📺 Open in…</button>
<button id="brokenBtn" class="np-btn" title="Video won't play or looks broken? Re-download the server's copy" hidden>⚠ Broken</button>
</div>
</div>
@@ -221,6 +230,7 @@
<div class="notes-tabs" role="tablist">
<button class="notes-tab active" data-tab="lyrics" role="tab" type="button">Lyrics</button>
<button class="notes-tab" data-tab="markers" role="tab" type="button">Chapters &amp; bookmarks <span id="markersCount" class="notes-count"></span></button>
<button class="notes-tab" data-tab="search" role="tab" type="button">🔎 Search</button>
<span id="notesStatus" class="notes-status"></span>
</div>
@@ -248,11 +258,24 @@
<div id="lyricsList" class="lyrics-list"></div>
</div>
<div id="searchPane" class="notes-pane hidden">
<div class="ts-bar">
<input id="tsQuery" class="ts-query" type="search" placeholder="Search what's said or sung…" autocomplete="off" />
<select id="tsSource" class="ts-source" aria-label="Search in">
<option value="lyrics">Lyrics</option>
<option value="captions">YouTube transcript</option>
</select>
</div>
<div id="tsInfo" class="ts-info"></div>
<div id="tsResults" class="ts-results"></div>
</div>
<div id="markersPane" class="notes-pane hidden">
<div id="markerChips" class="marker-chips"></div>
<div class="markers-toolbar">
<button id="addChapterBtn" class="np-btn" type="button">+ Chapter</button>
<button id="addBookmarkBtn" class="np-btn" type="button" title="Pin a private bookmark here [P]">📌 Bookmark</button>
<button id="addNoteBtn" class="np-btn" type="button" title="Write a note tied to this moment [N]">📝 Note</button>
<span class="notes-spacer"></span>
<button id="chaptersHistoryBtn" class="np-btn" type="button" title="Earlier versions of the shared chapters">🕘</button>
<button id="exportMarkersBtn" class="np-btn" type="button">⤓ Export</button>
@@ -262,7 +285,7 @@
<div id="chapterList" class="marker-list"></div>
</div>
<div class="markers-section">
<div class="markers-heading">My bookmarks <span class="markers-scope">only you</span></div>
<div class="markers-heading">My notes &amp; bookmarks <span class="markers-scope">only you</span></div>
<div id="bookmarkList" class="marker-list"></div>
</div>
</div>
@@ -357,6 +380,11 @@
</div>
</section>
<!-- Sleep timer "screen off": black curtain; tap to wake -->
<div id="sleepCurtain" class="sleep-curtain hidden" role="button" aria-label="Wake">
<span id="sleepCurtainMsg">Sleep timer ended · tap to wake</span>
</div>
<div id="toastContainer" class="toast-container"></div>
<!-- Mini now-playing bar -->
@@ -376,11 +404,36 @@
</div>
<!-- YouTube-style bottom navigation bar (portrait PWA mode only) -->
<!-- Watch party panel (Party in app.js) -->
<aside id="partyPanel" class="party-panel hidden" aria-label="Watch party">
<div class="pt-head"><span id="ptTitle">🎉 Watch party</span><span id="ptStatus" class="pt-status"></span><button id="ptMin" class="rv-icon" type="button" aria-label="Minimise">▁</button></div>
<div class="pt-body"></div>
</aside>
<!-- Desktop: shown while phones are driving this screen (remote.js) -->
<button id="remoteChip" class="remote-chip hidden" type="button" title="Phone remote settings">
<span class="remote-chip-dot"></span><span id="remoteChipText">Remote connected</span>
</button>
<!-- Presenter / projector screen: lyrics of whatever the paired screen plays -->
<section id="presenterView" class="presenter-view hidden" aria-label="Presenter view">
<div id="pvList" class="sl-list pv-list"></div>
<div id="pvTitle" class="pv-title hidden"></div>
<div id="pvBar" class="pv-bar">
<span id="pvStatus" class="pv-status">Connecting…</span>
<span class="pv-hint">Double-click or F = fullscreen · B = blank screen</span>
<button id="pvFull" class="pv-btn" type="button">⛶ Fullscreen</button>
</div>
<div id="pvPair" class="pv-pair hidden">
<div class="pv-pair-title">Presenter screen</div>
<p>Enter the 6-digit code shown on the playing computer (📱 Remote → Control this screen → Presenter).</p>
<input id="pvCode" inputmode="numeric" maxlength="7" placeholder="000 000" />
<button id="pvPairBtn" class="rv-primary" type="button">Connect</button>
<div id="pvMsg" class="rv-pair-msg"></div>
</div>
<div id="pvBlank" class="pv-blank hidden"></div>
</section>
<!-- Phone: remote control for a desktop instance -->
<section id="remoteView" class="remote-view hidden" aria-label="Phone remote">
<div class="rv-top">
@@ -481,6 +534,9 @@
<div class="shortcut-row"><kbd>A</kbd><span>Set A-B loop start point</span></div>
<div class="shortcut-row"><kbd>B</kbd><span>Set A-B loop end point</span></div>
<div class="shortcut-row"><kbd>P</kbd><span>Pin a bookmark at the current time</span></div>
<div class="shortcut-row"><kbd>N</kbd><span>Write a note at this moment</span></div>
<div class="shortcut-row"><kbd>I</kbd><span>Picture-in-picture</span></div>
<div class="shortcut-row"><kbd>E</kbd><span>Equalizer</span></div>
<div class="shortcut-row"><kbd>?</kbd><span>Show this help</span></div>
<div class="shortcut-row"><kbd>Esc</kbd><span>Close overlay / cancel selection</span></div>
</div>
@@ -491,6 +547,7 @@
<script src="opfs.js"></script>
<script src="video-edit.js"></script>
<script src="lyrics-core.js"></script>
<script src="stats-core.js"></script>
<script src="async-guard.js"></script>
<script src="sw-update.js"></script>
<script src="app.js"></script>

152
frontend/stats-core.js Normal file
View 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);

View File

@@ -0,0 +1,67 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
const SC = require('./stats-core');
const at = (y, m, d, h = 12) => new Date(y, m - 1, d, h);
const songA = { id: 'aaaaaaaaaaa', title: 'Goodness of God', channel: 'Bethel Music - Topic' };
const songB = { id: 'bbbbbbbbbbb', title: 'Build My Life', channel: 'Housefires' };
test('record accumulates seconds and plays per local day', () => {
let s = SC.empty();
s = SC.record(s, songA, 100, true, at(2026, 9, 18));
s = SC.record(s, songA, 50, false, at(2026, 9, 18));
s = SC.record(s, songB, 30, true, at(2026, 9, 19));
assert.deepStrictEqual(s.days['2026-09-18'], { s: 150, p: 1, songs: { aaaaaaaaaaa: 1 } });
assert.strictEqual(s.days['2026-09-19'].p, 1);
assert.strictEqual(s.meta.aaaaaaaaaaa.t, 'Goodness of God');
});
test('old days are pruned', () => {
let s = SC.empty();
s = SC.record(s, songA, 10, true, at(2024, 1, 1));
s = SC.record(s, songA, 10, true, at(2026, 9, 18));
assert.deepStrictEqual(Object.keys(s.days), ['2026-09-18']);
});
test('streak counts consecutive 5-minute days, allowing today to be pending', () => {
let s = SC.empty();
for (const d of [10, 11, 12, 15, 16, 17]) s = SC.record(s, songA, 400, true, at(2026, 9, d));
s = SC.record(s, songA, 60, true, at(2026, 9, 18)); // today, under 5 min so far
const st = SC.streaks(s, at(2026, 9, 18));
assert.strictEqual(st.current, 3); // 15,16,17
assert.strictEqual(st.best, 3);
s = SC.record(s, songA, 400, true, at(2026, 9, 18));
assert.strictEqual(SC.streaks(s, at(2026, 9, 18)).current, 4);
});
test('summary: month totals, top artists (Topic stripped), top songs with sparkline, heatmap', () => {
let s = SC.empty();
s = SC.record(s, songA, 3600, true, at(2026, 8, 20)); // last month
s = SC.record(s, songA, 1800, true, at(2026, 9, 14)); // this month (Mon)
s = SC.record(s, songA, 1800, true, at(2026, 9, 17));
s = SC.record(s, songB, 600, true, at(2026, 9, 17));
const sum = SC.summarize(s, at(2026, 9, 18));
assert.strictEqual(sum.thisMonthSec, 4200);
assert.strictEqual(sum.lastMonthSec, 3600);
assert.strictEqual(sum.totalPlays, 4);
assert.strictEqual(sum.songsPlayed, 2);
assert.deepStrictEqual(sum.topArtists, [{ name: 'Bethel Music', plays: 2 }, { name: 'Housefires', plays: 1 }]);
assert.strictEqual(sum.topSongs[0].title, 'Goodness of God');
assert.strictEqual(sum.topSongs[0].plays, 3);
assert.strictEqual(sum.topSongs[0].spark.length, 30);
assert.strictEqual(sum.topSongs[0].spark.at(-2), 1); // Sep 17
assert.strictEqual(sum.heat.length, 16);
const cur = sum.heat[15];
assert.strictEqual(cur[0].date, '2026-09-14'); // Monday of this week
assert.strictEqual(cur[0].minutes, 30);
assert.strictEqual(cur[6].future, true); // Sunday Sep 20 hasn't happened
assert.strictEqual(sum.peak.day, 'Thursday'); // Sep 17 had 40 min
});
test('fmtHours and artistOf', () => {
assert.strictEqual(SC.fmtHours(152280), '42h 18m');
assert.strictEqual(SC.fmtHours(600), '10m');
assert.strictEqual(SC.artistOf('BethelVEVO'), 'Bethel');
assert.strictEqual(SC.artistOf(''), 'Unknown');
});

View File

@@ -3802,3 +3802,234 @@ body.remote-open { overflow: hidden; }
color: var(--text-2); font-size: 12px; cursor: pointer;
}
.wave-loud:hover { color: var(--text); border-color: var(--accent); }
/* ============================================================================
* Stats view (renderStats in app.js)
* ========================================================================== */
.stats { grid-column: 1 / -1; position: relative; display: flex; flex-direction: column; gap: 16px; padding-bottom: 24px; }
.stats-empty { grid-column: 1 / -1; }
.st-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.st-tile, .st-panel {
background: linear-gradient(180deg, var(--bg-2), var(--bg-1));
border: 1px solid var(--line); border-radius: var(--radius); padding: 14px 16px;
}
.st-label { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-dim); }
.st-big { font-family: var(--mono); font-size: 32px; font-weight: 700; color: var(--text); margin: 6px 0 4px; font-variant-numeric: tabular-nums; }
.st-sub { font-size: 12px; color: var(--text-2); }
.st-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 12px; }
.st-panel-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; margin-bottom: 12px; font-weight: 700; font-size: 14px; color: var(--text); }
.st-panel-head small { font-weight: 400; font-size: 11px; color: var(--text-dim); text-align: right; }
.st-none { font-size: 13px; color: var(--text-dim); }
.st-bar-row { display: grid; grid-template-columns: minmax(80px, 34%) 1fr 34px; align-items: center; gap: 10px; padding: 5px 0; }
.st-bar-name { font-size: 13px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.st-bar-track { height: 10px; }
.st-bar { display: block; height: 10px; border-radius: 0 4px 4px 0; background: var(--accent); }
.st-bar-row:hover .st-bar { background: var(--accent-bright); }
.st-bar-val { font-family: var(--mono); font-size: 12px; color: var(--text-2); text-align: right; font-variant-numeric: tabular-nums; }
.st-song { display: grid; grid-template-columns: 24px 1fr 96px 34px; align-items: center; gap: 10px; padding: 6px 0; cursor: pointer; border-bottom: 1px solid var(--line-soft); }
.st-song:last-child { border-bottom: none; }
.st-song:hover b { color: var(--accent-bright); }
.st-rank { font-family: var(--mono); font-size: 11px; color: var(--text-dim); }
.st-song-text { display: flex; flex-direction: column; min-width: 0; }
.st-song-text b { font-size: 13px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.st-song-text small { font-size: 11px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.st-spark polyline { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.st-heat-wrap { display: flex; gap: 6px; overflow-x: auto; padding-bottom: 4px; }
.st-heat-days { display: grid; grid-template-rows: 14px repeat(7, 14px); row-gap: 3px; font-size: 9px; color: var(--text-dim); font-family: var(--mono); }
.st-heat-days span { line-height: 14px; }
.st-heat-months { display: grid; grid-template-columns: repeat(16, 14px); column-gap: 3px; height: 14px; font-size: 9px; color: var(--text-dim); font-family: var(--mono); white-space: nowrap; margin-bottom: 3px; }
.st-heat { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 14px); grid-template-columns: repeat(16, 14px); gap: 3px; }
.st-cell { width: 14px; height: 14px; border-radius: 3px; background: var(--bg-3); }
.st-cell.l1 { background: color-mix(in srgb, var(--accent) 28%, var(--bg-3)); }
.st-cell.l2 { background: color-mix(in srgb, var(--accent) 50%, var(--bg-3)); }
.st-cell.l3 { background: color-mix(in srgb, var(--accent) 74%, var(--bg-3)); }
.st-cell.l4 { background: var(--accent); }
.st-cell.future { background: transparent; border: 1px dashed var(--line); box-sizing: border-box; }
.st-heat .st-cell:hover { outline: 2px solid var(--text); outline-offset: 1px; }
.st-legend { display: flex; align-items: center; gap: 4px; margin-top: 10px; font-size: 11px; color: var(--text-dim); }
.st-legend span:first-child { margin-right: 4px; }
.st-legend span:last-child { margin-left: 4px; }
.st-table { margin-top: 10px; font-size: 12px; color: var(--text-2); }
.st-table summary { cursor: pointer; color: var(--text-dim); }
.st-table table { margin-top: 8px; border-collapse: collapse; }
.st-table td, .st-table th { padding: 3px 12px 3px 0; text-align: left; font-variant-numeric: tabular-nums; }
.st-tip {
position: absolute; transform: translate(-50%, -100%); pointer-events: none; z-index: 5;
background: var(--bg-3); border: 1px solid var(--line); color: var(--text);
padding: 5px 9px; border-radius: 8px; font-size: 12px; white-space: nowrap; box-shadow: var(--shadow);
}
/* ============================================================================
* Presenter / projector view (Presenter in app.js) — always dark
* ========================================================================== */
.presenter-view {
--text: #f7f5f2; --text-2: #c9c8d0; --text-dim: #8a8994; --line: #2a2a33;
position: fixed; inset: 0; z-index: 420;
background: #000; color: var(--text);
overflow: hidden;
}
body.presenter-open { overflow: hidden; }
.pv-list { position: absolute; inset: 4vh 0 8vh 0; }
.presenter-view .sl-line { cursor: default; }
.pv-title {
position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center; padding: 0 6vw; font-family: var(--display); font-weight: 800;
font-size: clamp(32px, 6vw, 96px); line-height: 1.1; color: var(--text);
}
.pv-title small { font-family: var(--ui); font-weight: 500; font-size: clamp(16px, 2vw, 28px); color: var(--text-dim); margin-top: 16px; }
.pv-bar {
position: absolute; left: 0; right: 0; bottom: 0; display: flex; align-items: center; gap: 16px;
padding: 10px 18px; font-size: 13px; color: var(--text-dim);
background: linear-gradient(transparent, rgba(0, 0, 0, 0.85)); transition: opacity 0.4s;
}
.pv-status { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pv-hint { font-size: 11px; }
.pv-btn { background: #16161b; color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 6px 12px; cursor: pointer; }
.presenter-view.idle { cursor: none; }
.presenter-view.idle .pv-bar { opacity: 0; }
.pv-pair {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
width: min(420px, 90vw); display: flex; flex-direction: column; gap: 12px; text-align: center;
background: #111116; border: 1px solid var(--line); border-radius: 16px; padding: 24px;
}
.pv-pair-title { font-family: var(--display); font-size: 26px; font-weight: 700; }
.pv-pair p { margin: 0; font-size: 13px; color: var(--text-2); line-height: 1.5; }
.pv-pair input {
font-family: var(--mono); font-size: 32px; letter-spacing: 0.2em; text-align: center;
background: #000; color: var(--text); border: 1px solid var(--line); border-radius: 12px; padding: 12px;
}
.pv-blank { position: absolute; inset: 0; background: #000; z-index: 5; }
.rh-present { display: flex; gap: 14px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
.rh-qr-sm { width: 96px; height: 96px; }
.rh-present code { font-family: var(--mono); font-size: 11px; word-break: break-all; }
/* Timestamp sharing (Share in app.js) */
.share-moment { display: flex; flex-direction: column; gap: 12px; }
.sm-row { display: flex; align-items: center; gap: 10px; font-size: 12px; color: var(--text-dim); }
.sm-time { width: 90px; background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 7px 9px; font-family: var(--mono); font-size: 14px; }
.sm-links { display: flex; flex-direction: column; gap: 8px; }
.sm-link { display: grid; grid-template-columns: 64px 1fr auto; gap: 8px; align-items: center; font-size: 12px; color: var(--text-dim); }
.sm-link code { font-family: var(--mono); font-size: 11px; color: var(--text); background: var(--bg-1); border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; user-select: all; }
.sm-gif { border-top: 1px solid var(--line-soft); padding-top: 12px; }
.sm-gif-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; font-size: 12px; color: var(--text-dim); }
.sm-gif-controls select { background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 5px 8px; }
.sm-gif-out { margin-top: 10px; }
.sm-preview { display: block; max-width: 100%; border-radius: 10px; border: 1px solid var(--line); }
.sm-gif-actions { display: flex; gap: 8px; margin-top: 8px; }
.sm-busy, .sm-err { font-size: 13px; color: var(--text-2); }
.sm-err { color: var(--accent-bright); }
/* Sleep timer panel, curtain, equalizer */
.sleep-panel, .eq-panel { display: flex; flex-direction: column; gap: 8px; }
.sp-label { font-family: var(--mono); font-size: 10px; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-dim); margin-top: 6px; }
.sp-chips, .eq-presets { display: flex; flex-wrap: wrap; gap: 6px; }
.sp-chip {
background: var(--bg-1); color: var(--text-2); border: 1px solid var(--line); border-radius: 999px;
padding: 7px 12px; font-size: 13px; cursor: pointer;
}
.sp-chip:hover { color: var(--text); border-color: var(--text-dim); }
.sp-chip.on { color: var(--text); border-color: var(--accent); background: var(--bg-3); }
.sp-note { margin: 6px 0 0; font-size: 11px; line-height: 1.5; color: var(--text-dim); }
.sleep-curtain {
position: fixed; inset: 0; z-index: 500; background: #000; cursor: pointer;
display: flex; align-items: flex-end; justify-content: center; padding-bottom: 12vh;
}
.sleep-curtain span { color: #666; font-size: 14px; transition: opacity 2s; }
.sleep-curtain.dim span { opacity: 0; }
.eq-bands { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; margin-top: 8px; }
.eq-band { display: flex; flex-direction: column; align-items: center; gap: 6px; font-size: 11px; color: var(--text-dim); }
.eq-band input[type=range] { writing-mode: vertical-lr; direction: rtl; width: 28px; height: 150px; accent-color: var(--accent); }
.eq-val { font-family: var(--mono); font-size: 11px; color: var(--text); min-height: 14px; }
.eq-f { font-family: var(--mono); }
/* Touch gestures (Gestures in app.js) */
.player-stage { touch-action: pan-y; } /* page still scrolls; we get double-tap + pinch */
:fullscreen .player-stage, .player-stage:fullscreen,
body.landscape-fs .player-stage { touch-action: none; } /* fullscreen (real or the iOS landscape one): swipes too */
#video { transform-origin: center center; transition: transform 0.05s linear; }
.gesture-hud {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 6;
min-width: 140px; padding: 12px 16px; border-radius: 14px; text-align: center;
background: rgba(0, 0, 0, 0.62); color: #fff; pointer-events: none; font-family: var(--ui);
}
.gh-label { font-size: 14px; font-weight: 600; }
.gh-bar { margin-top: 8px; height: 4px; border-radius: 2px; background: rgba(255, 255, 255, 0.25); overflow: hidden; }
.gh-bar span { display: block; height: 100%; background: #fff; }
.gh-seek { font-size: 22px; font-weight: 700; }
/* Notes: long personal notes + the 📝 Notes view */
.marker-form textarea {
background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius-sm);
padding: 9px 10px; font: 14px/1.5 var(--ui); resize: vertical;
}
.mk-note.long { white-space: pre-wrap; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; }
.notes-view { display: flex; flex-direction: column; gap: 12px; padding-bottom: 24px; }
.nv-filter { background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 9px 11px; font-size: 14px; }
.nv-group { background: linear-gradient(180deg, var(--bg-2), var(--bg-1)); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; }
.nv-head { display: flex; flex-direction: column; margin-bottom: 6px; }
.nv-head b { font-size: 14px; color: var(--text); }
.nv-head small { font-size: 11px; color: var(--text-dim); }
.nv-item { display: flex; gap: 10px; width: 100%; text-align: left; background: transparent; border: none; border-top: 1px solid var(--line-soft); padding: 8px 2px; cursor: pointer; color: var(--text-2); }
.nv-item:hover .nv-title { color: var(--accent-bright); }
.nv-time { flex: none; width: 48px; font-family: var(--mono); font-size: 12px; color: var(--accent-bright); }
.nv-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.nv-title { font-size: 13px; color: var(--text); }
.nv-note { font-size: 12px; white-space: pre-wrap; color: var(--text-2); }
/* Transcript search tab */
.ts-bar { display: flex; gap: 8px; margin-bottom: 8px; }
.ts-query { flex: 1; min-width: 0; background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 8px 10px; font-size: 14px; }
.ts-source { background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 6px 8px; font-size: 12px; }
.ts-info { font-size: 11px; color: var(--text-dim); margin-bottom: 6px; }
.ts-results { max-height: min(46vh, 420px); overflow-y: auto; overscroll-behavior: contain; }
.ts-hit { display: flex; gap: 10px; width: 100%; text-align: left; background: transparent; border: none; border-radius: 8px; padding: 6px; cursor: pointer; color: var(--text-2); font-size: 14px; line-height: 1.4; }
.ts-hit:hover { background: var(--bg-3); color: var(--text); }
.ts-time { flex: none; width: 42px; font-family: var(--mono); font-size: 11px; color: var(--accent-bright); padding-top: 2px; }
.ts-hit mark { background: color-mix(in srgb, var(--accent) 35%, transparent); color: var(--text); border-radius: 3px; padding: 0 1px; }
.sm-bite input { width: 70px; background: var(--bg-1); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 5px 7px; font-family: var(--mono); font-size: 12px; }
.sm-bite-out { margin-top: 10px; }
.sm-audio { width: 100%; }
.ext-panel { display: flex; flex-direction: column; gap: 10px; }
.ext-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
.ext-buttons a.np-btn { text-decoration: none; display: inline-flex; align-items: center; }
/* Watch party panel (Party in app.js) */
.party-panel {
position: fixed; right: 16px; top: 76px; bottom: 96px; width: 340px; z-index: 160;
display: flex; flex-direction: column; gap: 10px; padding: 12px 14px;
background: var(--bg-2); border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow);
}
.party-panel.min { bottom: auto; }
.party-panel.min .pt-body { display: none; }
.pt-head { display: flex; align-items: center; gap: 8px; font-weight: 700; }
.pt-head .rv-icon { width: 30px; height: 30px; font-size: 12px; margin-left: auto; }
.pt-status { font-family: var(--mono); font-size: 10px; color: var(--text-dim); font-weight: 400; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pt-body { display: flex; flex-direction: column; gap: 10px; min-height: 0; flex: 1; }
.pt-row { display: flex; gap: 8px; align-items: center; font-size: 12px; color: var(--text-dim); }
.pt-row input, .pt-msg, .pt-name {
flex: 1; min-width: 0; background: var(--bg-1); color: var(--text); border: 1px solid var(--line);
border-radius: var(--radius-sm); padding: 8px 10px; font-size: 14px;
}
.pt-code { font-family: var(--mono); letter-spacing: 0.2em; text-transform: uppercase; }
.pt-or { text-align: center; font-size: 11px; color: var(--text-dim); }
.pt-share { display: flex; gap: 6px; align-items: center; }
.pt-share code { flex: 1; min-width: 0; font-family: var(--mono); font-size: 11px; background: var(--bg-1); border: 1px solid var(--line); border-radius: 8px; padding: 6px 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pt-now { font-size: 12px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pt-members { display: flex; flex-wrap: wrap; gap: 6px; }
.pt-member { font-size: 12px; padding: 3px 9px; border-radius: 999px; background: var(--bg-3); color: var(--text-2); }
.pt-member.me { color: var(--text); border: 1px solid var(--accent); }
.pt-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-2); }
.pt-voice { display: flex; align-items: center; gap: 8px; }
.pt-voice .np-btn.on { border-color: #7ee0a8; }
.pt-voice-n { font-size: 11px; color: var(--text-dim); }
.pt-chat { flex: 1; min-height: 80px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; padding: 6px; background: var(--bg-1); border: 1px solid var(--line-soft); border-radius: var(--radius-sm); }
.pt-line { font-size: 13px; color: var(--text-2); line-height: 1.4; overflow-wrap: anywhere; }
.pt-line b { color: var(--text); }
.pt-line.me b { color: var(--accent-bright); }
.pt-sys { font-size: 11px; color: var(--text-dim); text-align: center; }
.pt-send { display: flex; gap: 6px; }
.pt-leave { background: transparent; border: none; color: var(--text-dim); font-size: 12px; cursor: pointer; text-decoration: underline; align-self: center; }
@media (max-width: 720px) {
.party-panel { left: 8px; right: 8px; width: auto; top: auto; bottom: 8px; height: 62vh; }
.party-panel.min { height: auto; }
}

View File

@@ -63,6 +63,7 @@ const SHELL = [
'/opfs-worker.js',
'/video-edit.js',
'/lyrics-core.js',
'/stats-core.js',
'/app.js',
'/manifest.webmanifest',
'/icons/icon-192.png',