Add service mode set-list view with screen wake lock

This commit is contained in:
Jonathan Sykes
2026-08-16 13:51:27 +00:00
parent c5f47514ad
commit 14e1d8e3dd
3 changed files with 457 additions and 8 deletions

View File

@@ -1384,6 +1384,7 @@ const Player = {
updateNowPlayingActions();
markPlayingCard();
showMiniBar();
updateServiceMode();
// Media Session API — OS media keys + lock screen
if ('mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
@@ -1711,10 +1712,19 @@ const Player = {
// faster than the 1s watchdog interval, which matters for a jarring "audio
// paused, video kept playing" UX.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') Player.resumeIfNeeded();
if (document.visibilityState === 'visible') {
Player.resumeIfNeeded();
if (isServiceModeOpen()) acquireWakeLock();
}
});
window.addEventListener('pageshow', () => {
Player.resumeIfNeeded();
if (isServiceModeOpen()) acquireWakeLock();
});
window.addEventListener('focus', () => {
Player.resumeIfNeeded();
if (isServiceModeOpen()) acquireWakeLock();
});
window.addEventListener('pageshow', () => Player.resumeIfNeeded());
window.addEventListener('focus', () => Player.resumeIfNeeded());
function showSpinner(on) {
els.spinner.classList.toggle('hidden', !on);
@@ -1839,6 +1849,8 @@ function wirePlayerEvents() {
function updatePlayBtn() {
els.playBtn.textContent = Player.master.paused ? '▶' : '⏸';
$('miniPlayBtn').textContent = Player.master.paused ? '▶' : '⏸';
const spBtn = $('servicePlayBtn');
if (spBtn) spBtn.textContent = Player.master.paused ? '▶' : '⏸';
}
// Reflect cache state on the now-playing Save button.
function updateNowPlayingActions() {
@@ -1878,6 +1890,7 @@ function updateProgress() {
els.durTime.textContent = fmtTime(dur);
if (dur) els.seek.value = String((cur / dur) * 1000);
updateMiniBar();
updateServiceProgress(cur, dur);
// A-B loop: passing B behaves like the track ending. Loop the A-B segment only
// when "loop current" is on; otherwise B is the effective end of the track and
// we advance per the repeat settings (next track, or wrap when "repeat list").
@@ -2029,6 +2042,7 @@ function playQueue(index = 0) {
}
function renderUpNext() {
updateServiceUpNext();
const upcoming = queue.slice(queueIndex + 1);
if (!upcoming.length) { $('upnext').classList.add('hidden'); return; }
$('upnext').classList.remove('hidden');
@@ -2113,6 +2127,131 @@ function playPrev() {
}
}
// ============================================================================
// Service mode (set-list view) with screen wake lock
// ============================================================================
let serviceWakeLock = null;
function isServiceModeOpen() {
const el = $('serviceMode');
return !!(el && !el.classList.contains('hidden'));
}
async function acquireWakeLock() {
if (!isServiceModeOpen()) return;
if (!('wakeLock' in navigator) || typeof navigator.wakeLock.request !== 'function') {
serviceWakeLock = null;
updateWakeStateUI();
return;
}
try {
if (serviceWakeLock && !serviceWakeLock.released) {
try { await serviceWakeLock.release(); } catch {}
}
const lock = await navigator.wakeLock.request('screen');
serviceWakeLock = lock;
lock.addEventListener('release', () => {
if (serviceWakeLock === lock) {
serviceWakeLock = null;
updateWakeStateUI();
}
});
} catch {
serviceWakeLock = null;
}
updateWakeStateUI();
}
async function releaseWakeLock() {
if (serviceWakeLock) {
try {
if (!serviceWakeLock.released) {
await serviceWakeLock.release();
}
} catch {}
serviceWakeLock = null;
}
updateWakeStateUI();
}
function updateWakeStateUI() {
const el = $('serviceWakeState');
if (!el) return;
const isAwake = !!(serviceWakeLock && !serviceWakeLock.released);
el.textContent = isAwake ? 'Screen will stay awake' : 'Screen may sleep';
el.classList.toggle('awake', isAwake);
}
function updateServiceUpNext() {
const upNextEl = $('serviceUpNext');
if (!upNextEl) return;
const upcoming = (queue && queueIndex >= 0) ? queue.slice(queueIndex + 1) : [];
if (upcoming.length > 0) {
upNextEl.textContent = 'Up next: ' + (upcoming[0].title || 'Untitled');
} else {
upNextEl.textContent = 'Last song';
}
}
function updateServiceProgress(cur, dur) {
const curEl = $('serviceCurTime');
const durEl = $('serviceDurTime');
if (curEl) curEl.textContent = fmtTime(cur || 0);
if (durEl) durEl.textContent = fmtTime(dur || 0);
}
function updateServiceMode() {
if (!isServiceModeOpen()) return;
const meta = current ? current.meta : null;
const titleEl = $('serviceTitle');
const chanEl = $('serviceChannel');
if (titleEl) titleEl.textContent = meta ? (meta.title || '') : 'No track playing';
if (chanEl) chanEl.textContent = meta ? (meta.channel || '') : '';
// Current track note (Section 5): read entry.notes, degrade gracefully if absent
const currentEntry = (queue && queueIndex >= 0 && queueIndex < queue.length) ? queue[queueIndex] : meta;
const note = (currentEntry && currentEntry.notes) ? String(currentEntry.notes).trim() : '';
const noteEl = $('serviceNote');
if (noteEl) {
if (note) {
noteEl.textContent = note;
noteEl.classList.remove('hidden');
} else {
noteEl.textContent = '';
noteEl.classList.add('hidden');
}
}
updateServiceUpNext();
const cur = Player.master ? (Player.master.currentTime || 0) : 0;
const dur = Player.master ? (Player.master.duration || (meta ? meta.duration : 0) || 0) : 0;
updateServiceProgress(cur, dur);
const spBtn = $('servicePlayBtn');
if (spBtn && Player.master) {
spBtn.textContent = Player.master.paused ? '▶' : '⏸';
}
updateWakeStateUI();
}
function enterServiceMode(pl) {
if (pl && pl.videos && pl.videos.length) {
playFromList(pl.videos, 0, 'playlist:' + pl.id);
}
const el = $('serviceMode');
if (el) el.classList.remove('hidden');
updateServiceMode();
acquireWakeLock();
}
function exitServiceMode() {
const el = $('serviceMode');
if (el) el.classList.add('hidden');
releaseWakeLock();
}
// ============================================================================
// Channel view — list a channel's uploads with quick actions
// ============================================================================
@@ -2375,6 +2514,13 @@ function renderList() {
playFromList(pl.videos, 0, 'playlist:' + pl.id, { playFull: true });
toast('Playing in full — stops after the last video');
};
const serviceBtn = document.createElement('button');
serviceBtn.textContent = '🎤 Service mode';
serviceBtn.title = 'Set-list view with screen wake lock';
serviceBtn.onclick = () => {
if (!pl.videos || !pl.videos.length) { toast('Playlist is empty'); return; }
enterServiceMode(pl);
};
const queueAll = document.createElement('button');
queueAll.textContent = '+ Queue';
queueAll.onclick = () => { pl.videos.forEach((v) => addToQueue(v, { quiet: true })); toast('Added playlist to queue'); };
@@ -2398,7 +2544,7 @@ function renderList() {
pinBtn.title = isPinned ? 'Pinned for offline keep — click to unpin' : 'Pin to keep all videos in this playlist available offline';
pinBtn.onclick = () => togglePinPlaylist(pl);
const buttons = [playAll, playFull, queueAll, offlineBtn, pinBtn];
const buttons = [playAll, playFull, serviceBtn, queueAll, offlineBtn, pinBtn];
if (pl.videos && pl.videos.length >= 1) {
const share = document.createElement('button');
share.textContent = '🔗 Share';
@@ -4136,15 +4282,24 @@ document.querySelectorAll('.chip').forEach((c) => {
else if (e.key === 'b') { if (current) setAbB(); }
else if (e.key === '?') toggleShortcutHelp();
else if (e.key === 'Escape') {
const app = document.querySelector('.app');
if (app && app.classList.contains('sidebar-open')) closeSidebar();
else if (!$('shortcutHelp').classList.contains('hidden')) $('shortcutHelp').classList.add('hidden');
else if (selectMode) { exitSelectMode(); renderList(); }
if (isServiceModeOpen()) exitServiceMode();
else {
const app = document.querySelector('.app');
if (app && app.classList.contains('sidebar-open')) closeSidebar();
else if (!$('shortcutHelp').classList.contains('hidden')) $('shortcutHelp').classList.add('hidden');
else if (selectMode) { exitSelectMode(); renderList(); }
}
}
});
$('modal').addEventListener('click', (e) => { if (e.target.id === 'modal') closeModal(); });
// Service mode controls
$('servicePlayBtn').addEventListener('click', () => Player.toggle());
$('serviceNextBtn').addEventListener('click', playNext);
$('servicePrevBtn').addEventListener('click', playPrev);
$('serviceExitBtn').addEventListener('click', exitServiceMode);
// Mini now-playing bar
$('miniPlayBtn').addEventListener('click', (e) => { e.stopPropagation(); Player.toggle(); });
$('miniBar').addEventListener('click', () => {

View File

@@ -256,6 +256,32 @@
</div>
</div>
<!-- Service mode (set-list view) overlay -->
<section id="serviceMode" class="service-mode hidden" aria-label="Service Mode">
<div class="service-topbar">
<div id="serviceWakeState" class="service-wake-state">Screen may sleep</div>
<button id="serviceExitBtn" class="service-exit-btn" type="button" aria-label="Exit Service Mode">Exit</button>
</div>
<div class="service-center">
<div class="service-meta">
<h1 id="serviceTitle" class="service-title"></h1>
<div id="serviceChannel" class="service-channel"></div>
<div id="serviceNote" class="service-note hidden"></div>
</div>
<div id="serviceUpNext" class="service-upnext">Last song</div>
</div>
<div class="service-bottom">
<div id="serviceTime" class="service-time">
<span id="serviceCurTime">0:00</span> / <span id="serviceDurTime">0:00</span>
</div>
<div class="service-controls">
<button id="servicePrevBtn" class="service-btn" type="button" aria-label="Previous track">⏮</button>
<button id="servicePlayBtn" class="service-btn service-btn-play" type="button" aria-label="Play or Pause">▶</button>
<button id="serviceNextBtn" class="service-btn" type="button" aria-label="Next track">⏭</button>
</div>
</div>
</section>
<div id="toastContainer" class="toast-container"></div>
<!-- Mini now-playing bar -->

View File

@@ -2458,3 +2458,271 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
}
.ve-marker-a { left: 0; }
.ve-marker-b { right: 0; }
/* ===================== Service Mode (Set-List View) ===================== */
.service-mode {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
height: 100dvh;
background: var(--bg);
color: var(--text);
z-index: 110;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: calc(env(safe-area-inset-top, 0px) + 24px)
calc(env(safe-area-inset-right, 0px) + 28px)
calc(env(safe-area-inset-bottom, 0px) + 28px)
calc(env(safe-area-inset-left, 0px) + 28px);
box-sizing: border-box;
animation: fade 0.2s var(--ease);
overflow-y: auto;
user-select: none;
}
.service-topbar {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
max-width: 900px;
margin: 0 auto;
gap: 16px;
}
.service-wake-state {
font-family: var(--ui);
font-size: 13px;
color: var(--text-dim);
display: inline-flex;
align-items: center;
gap: 8px;
letter-spacing: 0.01em;
transition: color 0.2s var(--ease);
}
.service-wake-state::before {
content: "";
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--line);
transition: background 0.2s var(--ease), box-shadow 0.2s var(--ease);
}
.service-wake-state.awake {
color: var(--text-2);
}
.service-wake-state.awake::before {
background: #34d399;
box-shadow: 0 0 10px rgba(52, 211, 153, 0.6);
}
.service-exit-btn {
background: var(--bg-3);
border: 1px solid var(--line);
color: var(--text);
padding: 9px 18px;
border-radius: var(--radius-sm);
font-family: var(--ui);
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 40px;
touch-action: manipulation;
transition: all 0.16s var(--ease);
}
.service-exit-btn:hover {
background: var(--bg-2);
border-color: var(--accent);
transform: translateY(-1px);
}
.service-exit-btn:active {
transform: translateY(0);
}
.service-center {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
max-width: 900px;
width: 100%;
margin: 0 auto;
padding: 24px 0;
flex: 1 1 auto;
}
.service-meta {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.service-title {
margin: 0 0 12px;
font-family: var(--display);
font-weight: 800;
font-size: clamp(28px, 5.5vw, 56px);
line-height: 1.15;
letter-spacing: -0.02em;
color: var(--text);
max-width: 100%;
word-break: break-word;
}
.service-channel {
font-family: var(--ui);
font-size: clamp(16px, 2.5vw, 22px);
color: var(--text-2);
font-weight: 500;
margin-bottom: 12px;
}
.service-note {
background: var(--bg-2);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 14px 20px;
margin: 12px 0 0;
color: var(--text);
font-family: var(--ui);
font-size: clamp(14px, 2vw, 17px);
line-height: 1.5;
max-width: 680px;
width: calc(100% - 32px);
text-align: left;
white-space: pre-wrap;
word-break: break-word;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
}
.service-upnext {
margin-top: 24px;
font-family: var(--ui);
font-size: clamp(15px, 2.2vw, 20px);
color: var(--text-dim);
font-weight: 500;
letter-spacing: -0.01em;
max-width: 100%;
word-break: break-word;
}
.service-bottom {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
width: 100%;
max-width: 900px;
margin: 0 auto;
}
.service-time {
font-family: var(--mono);
font-size: clamp(17px, 2.8vw, 24px);
color: var(--text-2);
letter-spacing: 0.04em;
font-weight: 600;
}
.service-controls {
display: flex;
align-items: center;
justify-content: center;
gap: clamp(18px, 4vw, 36px);
}
.service-btn {
background: var(--bg-2);
border: 1px solid var(--line);
color: var(--text);
border-radius: 50%;
width: clamp(56px, 12vw, 70px);
height: clamp(56px, 12vw, 70px);
font-size: clamp(20px, 4vw, 26px);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
touch-action: manipulation;
transition: all 0.16s var(--ease);
}
.service-btn:hover {
background: var(--bg-3);
border-color: var(--text-dim);
transform: scale(1.06);
}
.service-btn:active {
transform: scale(0.95);
}
.service-btn-play {
width: clamp(72px, 15vw, 92px);
height: clamp(72px, 15vw, 92px);
font-size: clamp(28px, 5.5vw, 38px);
background: linear-gradient(145deg, var(--accent-bright), var(--accent-deep));
border: none;
color: #fff;
box-shadow: 0 10px 28px -6px var(--accent-glow);
}
.service-btn-play:hover {
background: linear-gradient(145deg, var(--accent-bright), var(--accent-deep));
box-shadow: 0 14px 34px -4px var(--accent-glow);
transform: scale(1.06);
}
.service-btn-play:active {
transform: scale(0.95);
}
@media (max-height: 520px) {
.service-mode {
padding: 12px 20px;
}
.service-center {
padding: 8px 0;
}
.service-title {
font-size: clamp(22px, 4.5vh, 32px);
margin-bottom: 4px;
}
.service-channel {
font-size: 14px;
margin-bottom: 4px;
}
.service-upnext {
margin-top: 8px;
font-size: 14px;
}
.service-bottom {
gap: 10px;
}
.service-time {
font-size: 15px;
}
.service-btn {
width: 48px;
height: 48px;
font-size: 18px;
}
.service-btn-play {
width: 58px;
height: 58px;
font-size: 22px;
}
}