Add a service mode shortcut to the now-playing bar and a video panel toggle

This commit is contained in:
Jonathan Sykes
2026-09-08 16:59:43 +08:00
parent 3e1b56610f
commit a3089dde74
3 changed files with 133 additions and 1 deletions

View File

@@ -251,6 +251,7 @@ const DEFAULT_SETTINGS = {
layout: 'classic', // 'classic'|'sanctuary-stage'|'liturgical'|'living-hymnal'|'broadcaster'|'contemplative'|'bento-hub' layout: 'classic', // 'classic'|'sanctuary-stage'|'liturgical'|'living-hymnal'|'broadcaster'|'contemplative'|'bento-hub'
autoBackupEnabled: false, autoBackupEnabled: false,
autoBackupIntervalDays: 7, autoBackupIntervalDays: 7,
serviceVideo: false, // show the video panel inside service mode (lyric videos)
}; };
let data = { playlists: [], history: [], queue: [], customVideos: [], pinnedPlaylists: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, lastAutoBackup: 0, profile: null }; let data = { playlists: [], history: [], queue: [], customVideos: [], pinnedPlaylists: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, lastAutoBackup: 0, profile: null };
let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel' let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel'
@@ -2345,6 +2346,62 @@ function updateServiceMode() {
updateWakeStateUI(); updateWakeStateUI();
} }
// ---------- Service mode video panel ----------
// Lyric videos carry the words in the picture, so service mode can show the
// video instead of hiding it. The <video> element is NEVER reparented — moving
// a playing video breaks playback on iOS (and drops the stream on some
// Androids). Instead .player-stage is pinned over a placeholder slot inside the
// overlay with inline fixed coordinates, which are cleared when it closes.
function serviceVideoOn() {
return !!data.settings.serviceVideo && !data.settings.audioOnly;
}
function positionServiceVideo() {
const stage = document.querySelector('.player-stage');
if (!stage) return;
if (!(isServiceModeOpen() && serviceVideoOn())) {
stage.style.position = '';
stage.style.top = '';
stage.style.left = '';
stage.style.width = '';
stage.style.height = '';
return;
}
const slot = $('serviceVideoSlot');
if (!slot) return;
const r = slot.getBoundingClientRect();
if (!r.width || !r.height) return; // slot not laid out yet
stage.style.position = 'fixed';
stage.style.top = `${r.top}px`;
stage.style.left = `${r.left}px`;
stage.style.width = `${r.width}px`;
stage.style.height = `${r.height}px`;
}
function updateServiceVideoUI() {
const on = isServiceModeOpen() && serviceVideoOn();
document.body.classList.toggle('service-video', on);
const btn = $('serviceVideoBtn');
if (btn) {
btn.textContent = serviceVideoOn() ? '📺 Video on' : '📺 Video off';
btn.setAttribute('aria-pressed', serviceVideoOn() ? 'true' : 'false');
btn.classList.toggle('on', serviceVideoOn());
}
// The slot only has a box once the class above has been applied, so measure
// on the next frame rather than in this one.
requestAnimationFrame(positionServiceVideo);
}
function toggleServiceVideo() {
if (data.settings.audioOnly) {
toast('Audio-only mode is on — turn it off in Settings to show video');
return;
}
data.settings.serviceVideo = !data.settings.serviceVideo;
persist();
updateServiceVideoUI();
}
function enterServiceMode(pl) { function enterServiceMode(pl) {
if (pl && pl.videos && pl.videos.length) { if (pl && pl.videos && pl.videos.length) {
const source = 'playlist:' + pl.id; const source = 'playlist:' + pl.id;
@@ -2365,6 +2422,7 @@ function enterServiceMode(pl) {
// Hides the mini-bar / bottom-nav / sidebar for as long as the overlay is up // Hides the mini-bar / bottom-nav / sidebar for as long as the overlay is up
// (see .service-open in styles.css). // (see .service-open in styles.css).
document.body.classList.add('service-open'); document.body.classList.add('service-open');
updateServiceVideoUI();
updateServiceMode(); updateServiceMode();
acquireWakeLock(); acquireWakeLock();
} }
@@ -2373,6 +2431,9 @@ function exitServiceMode() {
const el = $('serviceMode'); const el = $('serviceMode');
if (el) el.classList.add('hidden'); if (el) el.classList.add('hidden');
document.body.classList.remove('service-open'); document.body.classList.remove('service-open');
// Runs after the overlay is hidden, so this clears the pinned video styles
// and returns .player-stage to the player pane.
updateServiceVideoUI();
releaseWakeLock(); releaseWakeLock();
} }
@@ -4522,6 +4583,11 @@ document.querySelectorAll('.chip').forEach((c) => {
$('serviceNextBtn').addEventListener('click', playNext); $('serviceNextBtn').addEventListener('click', playNext);
$('servicePrevBtn').addEventListener('click', playPrev); $('servicePrevBtn').addEventListener('click', playPrev);
$('serviceExitBtn').addEventListener('click', exitServiceMode); $('serviceExitBtn').addEventListener('click', exitServiceMode);
$('serviceVideoBtn').addEventListener('click', toggleServiceVideo);
// The pinned panel is positioned in viewport coordinates, so it has to be
// re-measured whenever the viewport changes shape.
window.addEventListener('resize', positionServiceVideo);
window.addEventListener('orientationchange', () => setTimeout(positionServiceVideo, 150));
// Mini now-playing bar // Mini now-playing bar
$('miniPlayBtn').addEventListener('click', (e) => { e.stopPropagation(); Player.toggle(); }); $('miniPlayBtn').addEventListener('click', (e) => { e.stopPropagation(); Player.toggle(); });
@@ -4534,6 +4600,12 @@ document.querySelectorAll('.chip').forEach((c) => {
if (isPortraitPWA()) scrollPlayerIntoViewPortrait(); if (isPortraitPWA()) scrollPlayerIntoViewPortrait();
else els.playerPane.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); else els.playerPane.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}); });
// Opens the set-list view for whatever is already playing. No playlist arg,
// so enterServiceMode leaves the live queue and position untouched.
$('miniServiceBtn').addEventListener('click', (e) => {
e.stopPropagation();
enterServiceMode();
});
$('miniCloseBtn').addEventListener('click', (e) => { e.stopPropagation(); hideMiniBar(); }); $('miniCloseBtn').addEventListener('click', (e) => { e.stopPropagation(); hideMiniBar(); });
// Drag-to-reorder: prevent default on cards container // Drag-to-reorder: prevent default on cards container

View File

@@ -260,9 +260,17 @@
<section id="serviceMode" class="service-mode hidden" aria-label="Service Mode"> <section id="serviceMode" class="service-mode hidden" aria-label="Service Mode">
<div class="service-topbar"> <div class="service-topbar">
<div id="serviceWakeState" class="service-wake-state">Screen may sleep</div> <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 class="service-topbar-actions">
<button id="serviceVideoBtn" class="service-exit-btn service-video-btn" type="button"
aria-pressed="false" aria-label="Show or hide the video">📺 Video off</button>
<button id="serviceExitBtn" class="service-exit-btn" type="button" aria-label="Exit Service Mode">Exit</button>
</div>
</div> </div>
<div class="service-center"> <div class="service-center">
<!-- Placeholder the real .player-stage is positioned over while the
video panel is on. The <video> is never moved into here — see
positionServiceVideo() in app.js. -->
<div id="serviceVideoSlot" class="service-video-slot" aria-hidden="true"></div>
<div class="service-meta"> <div class="service-meta">
<h1 id="serviceTitle" class="service-title"></h1> <h1 id="serviceTitle" class="service-title"></h1>
<div id="serviceChannel" class="service-channel"></div> <div id="serviceChannel" class="service-channel"></div>
@@ -293,6 +301,8 @@
<div id="miniChannel" class="mini-channel"></div> <div id="miniChannel" class="mini-channel"></div>
</div> </div>
<span id="miniTime" class="mini-time">0:00</span> <span id="miniTime" class="mini-time">0:00</span>
<button id="miniServiceBtn" class="mini-btn mini-service" type="button"
title="Service mode" aria-label="Service mode">🎤</button>
<button id="miniCloseBtn" class="mini-btn mini-close">✕</button> <button id="miniCloseBtn" class="mini-btn mini-close">✕</button>
</div> </div>
<div id="miniProgress" class="mini-progress"><div id="miniProgressFill" class="mini-progress-fill"></div></div> <div id="miniProgress" class="mini-progress"><div id="miniProgressFill" class="mini-progress-fill"></div></div>

View File

@@ -1469,6 +1469,9 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
color: var(--text-dim); color: var(--text-dim);
} }
.mini-close:hover { color: var(--text); border-color: var(--text-dim); } .mini-close:hover { color: var(--text); border-color: var(--text-dim); }
/* Set-list view straight from the now-playing bar. */
.mini-service { font-size: 15px; }
.mini-service:hover { border-color: var(--accent); }
.mini-info { .mini-info {
min-width: 0; flex: 1; min-width: 0; flex: 1;
display: flex; flex-direction: column; display: flex; flex-direction: column;
@@ -2647,6 +2650,50 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
box-shadow: 0 0 10px rgba(52, 211, 153, 0.6); box-shadow: 0 0 10px rgba(52, 211, 153, 0.6);
} }
.service-topbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* Toggle reads as pressed when the video panel is on. */
.service-video-btn.on {
border-color: var(--accent);
color: var(--text);
}
/* Placeholder the real .player-stage is pinned over. It reserves the space and
defines the geometry; it never contains the video itself. */
.service-video-slot { display: none; }
body.service-video .service-video-slot {
display: block;
width: 100%;
max-width: 720px;
aspect-ratio: 16 / 9;
margin: 0 auto 18px;
border-radius: var(--radius);
background: #000;
}
/* Lift the pinned stage above the overlay (320) while it is on loan. */
body.service-open.service-video .player-stage {
z-index: 330;
}
/* With the picture carrying the song, the type steps back so both fit. */
body.service-video .service-title {
font-size: clamp(20px, 4.2vw, 32px);
margin-bottom: 6px;
}
body.service-video .service-channel {
font-size: clamp(14px, 2.2vw, 18px);
margin-bottom: 6px;
}
body.service-video .service-upnext {
margin-top: 12px;
}
.service-exit-btn { .service-exit-btn {
background: var(--bg-3); background: var(--bg-3);
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -2856,6 +2903,9 @@ body.service-open .sidebar-backdrop {
max-width: none; max-width: none;
padding: 12px 14px; padding: 12px 14px;
} }
body.service-video .service-video-slot { margin-bottom: 12px; }
body.service-video .service-title { font-size: clamp(19px, 5.2vw, 26px); }
body.service-video .service-note { display: none; }
.service-time { .service-time {
font-size: clamp(16px, 4.6vw, 22px); font-size: clamp(16px, 4.6vw, 22px);
} }