Rework the music UI: circles/squares instead of movie posters, a real now-playing screen #11

Merged
kyuno merged 2 commits from feature/music-ui into master 2026-09-16 15:59:34 +02:00
17 changed files with 633 additions and 36 deletions
Showing only changes of commit 617a3d0d49 - Show all commits

View file

@ -344,6 +344,16 @@ Then put it behind your existing reverse proxy like everything else
action — resolved server-side (one atomic operation, not one
request per track) the same way launching a playlist already
expands into everything it contains — see docs/architecture.md
- [x] Picture-in-picture for the video player (standard
`requestPictureInPicture`, confirmed present on Android Chrome) —
asked for directly for mobile, where there's no second window to
put this app in while switching apps — see docs/architecture.md
- [x] Installable as a PWA (manifest + icons, standalone display,
no service worker — this app has nothing worth caching offline)
— see docs/architecture.md
- [x] Mobile navigation redesigned as a bottom sheet (Accueil /
Bibliothèques / Listes de lecture) instead of a horizontally
scrolling sidebar — see docs/architecture.md
- [x] Music UI reworked to look like a music app instead of reusing
movie-poster tiles — circular artist tiles, square album tiles,
a real hero header, and a mobile "now playing" full-screen player

View file

@ -11,7 +11,7 @@
// npm run watch -> rebuilds on save, for local dev (see README's
// Running locally section)
import * as esbuild from 'esbuild';
import { copyFileSync, mkdirSync } from 'fs';
import { copyFileSync, cpSync, mkdirSync } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
@ -20,6 +20,11 @@ const outdir = path.join(here, 'dist');
mkdirSync(outdir, { recursive: true });
copyFileSync(path.join(here, 'src', 'index.html'), path.join(outdir, 'index.html'));
// PWA manifest + icons (manifest.webmanifest, icons/*) - see
// docs/architecture.md's "Progressive Web App" section. Copied wholesale
// rather than named file-by-file like index.html above, so adding another
// icon later needs no build.mjs change.
cpSync(path.join(here, 'src', 'public'), outdir, { recursive: true });
const buildOptions = {
entryPoints: [path.join(here, 'src', 'app.ts')],

View file

@ -4,6 +4,24 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Watch Party</title>
<!-- PWA - see docs/architecture.md's "Progressive Web App" section.
theme-color/background_color both come straight from base.css's
--bg-header/--bg (the manifest can't read CSS custom properties,
so these are duplicated by hand - if that palette ever changes,
update manifest.webmanifest to match). -->
<link rel="manifest" href="manifest.webmanifest" />
<meta name="theme-color" content="#221d24" />
<link rel="icon" type="image/png" href="icons/favicon.png" />
<!-- iOS ignores the manifest above for installed-app chrome - Safari
needs its own separate meta tags/link for the equivalent
standalone/home-screen-icon behavior. -->
<link rel="apple-touch-icon" href="icons/apple-touch-icon.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Watch Party" />
<link rel="stylesheet" href="app.css" />
<script src="https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.17/hls.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.5/socket.io.min.js"></script>

View file

@ -6,13 +6,13 @@
able to browse/add tracks while it plays). Sits above .mainContent, at
a lower z-index than .playerWrap (20) so a video party's takeover still
correctly covers it - moot in practice since joining a video party
calls leaveMusicParty() first (see Player.ts), but cheap to get right. */
calls leaveMusicParty() first (see Player.ts), but cheap to get right.
The fixed-to-viewport positioning itself lives one level up, on
Shell.css's .bottomStack wrapper - not here - so that on a narrow
screen the mobile nav trigger right below it in that wrapper always
sits flush above this bar's own actual rendered height (its content
can wrap taller on a narrow screen), never a hardcoded guess at it. */
.musicBar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 15;
background: var(--bg-header);
border-top: 1px solid rgba(255, 255, 255, .08);
box-shadow: 0 -4px 16px rgba(0, 0, 0, .3);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,003 B

After

Width:  |  Height:  |  Size: 1,003 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View file

@ -0,0 +1,16 @@
{
"name": "Watch Party",
"short_name": "Watch Party",
"description": "Regardez et écoutez en même temps que les autres utilisateurs de votre serveur Emby.",
"lang": "fr",
"start_url": "/",
"display": "standalone",
"background_color": "#151216",
"theme_color": "#221d24",
"categories": ["entertainment", "multimedia"],
"icons": [
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

View file

@ -62,20 +62,162 @@
padding: 24px clamp(16px, 3vw, 48px) 80px;
}
/* Sidebar becomes a horizontal scrollable topbar on narrow screens. */
/* ==== Mobile navigation (<=720px) ====
Replaces the sidebar outright - see docs/architecture.md's "Mobile
navigation" section for why a bottom sheet was chosen over reshaping
the sidebar into a horizontal bar. Every rule below the base
`display: none` only ever applies once the 720px media query turns
it back on, so none of this affects desktop. */
.mobileTopBar,
.mobileNavTrigger,
.mobileNavScrim,
.mobileNavSheet {
display: none;
}
.mobileTopBar {
position: sticky;
top: 0;
z-index: 3;
padding: 14px 16px;
background: var(--bg-header);
}
.mobileBrand { font-size: 16px; font-weight: 700; }
/* position:fixed, not sticky - #musicBar below already relies on a
fixed wrapper to stay docked regardless of how far #mainContent's
own (whole-page, not internally-scrolled) content runs. */
.bottomStack {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 15;
display: flex;
flex-direction: column;
}
.mobileNavTrigger {
align-items: center;
justify-content: center;
gap: 8px;
background: var(--bg-header);
border: none;
border-top: 1px solid rgba(255, 255, 255, .08);
color: var(--text);
font-family: inherit;
font-size: 13px;
font-weight: 600;
padding: 13px;
padding-bottom: calc(13px + env(safe-area-inset-bottom, 0px));
}
.mobileNavScrim {
position: fixed;
inset: 0;
z-index: 20;
background: rgba(0, 0, 0, .5);
opacity: 0;
pointer-events: none;
transition: opacity .2s;
}
.mobileNavScrim.open { opacity: 1; pointer-events: auto; }
.mobileNavSheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 21;
max-height: 75vh;
overflow-y: auto;
background: var(--bg-header);
border-radius: 16px 16px 0 0;
padding: 8px 16px calc(20px + env(safe-area-inset-bottom, 0px));
transform: translateY(100%);
transition: transform .22s ease;
box-shadow: 0 -8px 24px rgba(0, 0, 0, .3);
}
.mobileNavSheet.open { transform: translateY(0); }
.sheetGrabber {
width: 36px;
height: 4px;
border-radius: 99px;
background: var(--muted);
margin: 10px auto 6px;
}
/* Accueil and the (conditional) Listes de lecture row both use this -
same visual weight, bookending the "Bibliothèques" group between
them, so the one part whose size actually varies (however many
libraries Emby returns) reads as visually distinct from the two
fixed rows around it. */
.sheetPrimary {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
background: var(--panel);
border: none;
border-radius: 10px;
color: var(--text);
font-family: inherit;
font-size: 14px;
font-weight: 600;
padding: 12px;
margin: 4px 0;
text-decoration: none;
}
.sheetPrimary.active { background: var(--accent); color: #fff; }
.sheetGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 4px 0 10px;
}
.sheetItem {
display: flex;
align-items: center;
gap: 9px;
background: var(--panel);
border-radius: 10px;
color: var(--text);
font-size: 13px;
padding: 12px 10px;
text-decoration: none;
}
.sheetItem.active { background: var(--accent); color: #fff; font-weight: 600; }
.navIcon { width: 20px; height: 20px; flex-shrink: 0; display: block; }
.navIcon svg { width: 100%; height: 100%; }
.sheetLogout {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
margin-top: 8px;
padding: 12px;
border: 1px solid rgba(255, 255, 255, .15);
border-radius: 10px;
color: var(--muted);
font-size: 13px;
}
@media (max-width: 720px) {
.appShell { flex-direction: column; }
.sidebar {
width: 100%;
height: auto;
flex-direction: row;
align-items: center;
padding: 10px 12px;
overflow-x: auto;
overflow-y: hidden;
}
.sidebarBrand { padding: 0 12px 0 0; }
.sidebarNav { flex-direction: row; flex: none; }
.navSectionLabel { display: none; }
.sidebarLogout { margin: 0 0 0 12px; }
.sidebar { display: none; }
.mobileTopBar { display: flex; align-items: center; }
.mobileNavTrigger { display: flex; }
.mobileNavScrim { display: block; }
.mobileNavSheet { display: block; }
}

View file

@ -6,8 +6,37 @@
<div class="navSectionLabel">Bibliothèques</div>
<p class="hint" id="libStatus"><span class="spinner"></span>Chargement...</p>
</nav>
<button class="linklike sidebarLogout" id="logout"></button>
<button class="linklike sidebarLogout logoutBtn" id="logout"></button>
</aside>
<!-- Mobile-only (see Shell.css's 720px breakpoint) - the sidebar
above is display:none there instead of reshaping into a
horizontal bar. See docs/architecture.md's "Mobile navigation". -->
<header class="mobileTopBar">
<span class="mobileBrand">Watch Party</span>
</header>
<main id="mainContent" class="mainContent"></main>
<!-- Wraps #musicBar so the trigger below always sits directly above
whatever height the bar actually renders at - see Shell.css. -->
<div class="bottomStack">
<div id="musicBar" class="musicBar"></div>
<button class="mobileNavTrigger" id="mobileNavTrigger" aria-expanded="false" aria-controls="mobileNavSheet">
Parcourir
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
<path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
</div>
<div class="mobileNavScrim" id="mobileNavScrim"></div>
<div class="mobileNavSheet" id="mobileNavSheet">
<div class="sheetGrabber"></div>
<a href="#/home" class="navItem sheetPrimary"><span class="navIcon" data-icon="home"></span>Accueil</a>
<div class="navSectionLabel">Bibliothèques</div>
<div class="sheetGrid" id="mobileLibGrid"></div>
<div id="mobilePlaylistsSlot"></div>
<button class="linklike sheetLogout logoutBtn" id="mobileLogout"></button>
</div>
</div>

View file

@ -5,41 +5,97 @@ import './Shell.css';
import html from './Shell.html';
import { auth, getLibraries, logout } from '../api/embyApi';
import { initMusicBar } from '../musicBar/MusicBar';
import type { LibraryDto } from '../types';
// One icon per Emby CollectionType - only used by the mobile bottom
// sheet (see docs/architecture.md's "Mobile navigation"); the desktop
// sidebar stays plain text, unchanged. Same small inline-SVG-constant
// convention as views/player/Player.ts's own icon strings.
const HOME_ICON_SVG = '<svg viewBox="0 0 24 24"><path d="M3.5 11.5 12 4l8.5 7.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.5 10v9a1 1 0 0 0 1 1H10v-5.5a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1V20h3.5a1 1 0 0 0 1-1v-9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const MOVIES_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3.5" y="6" width="17" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M3.5 6 7 3h3l-2.5 3M11 6l2.5-3h3L14 6m3.5 0 2.5-3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const SHOWS_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3" y="5.5" width="18" height="12" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M9 20.5h6M12 17.5v3" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
const MUSIC_ICON_SVG = '<svg viewBox="0 0 24 24"><circle cx="8" cy="17" r="2.6" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10.5 17V5.5l7-1.5v10" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/><circle cx="17.5" cy="14" r="2.6" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>';
const MUSICVIDEOS_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3" y="5.5" width="18" height="13" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M10 9.5 15.5 12 10 14.5Z" fill="currentColor"/></svg>';
const PLAYLISTS_ICON_SVG = '<svg viewBox="0 0 24 24"><circle cx="4.5" cy="7" r="1.3" fill="currentColor"/><circle cx="4.5" cy="12" r="1.3" fill="currentColor"/><circle cx="4.5" cy="17" r="1.3" fill="currentColor"/><line x1="9" y1="7" x2="20" y2="7" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><line x1="9" y1="12" x2="20" y2="12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/><line x1="9" y1="17" x2="20" y2="17" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/></svg>';
// Emby could in principle add a CollectionType this app doesn't map yet
// (server/src/services/CatalogService.ts's VISIBLE_COLLECTION_TYPES
// would need updating first either way) - falls back to a plain folder
// glyph rather than rendering an empty icon slot.
const GENERIC_LIBRARY_ICON_SVG = '<svg viewBox="0 0 24 24"><rect x="3.5" y="5" width="17" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/></svg>';
const COLLECTION_ICONS: Record<string, string> = {
movies: MOVIES_ICON_SVG,
tvshows: SHOWS_ICON_SVG,
music: MUSIC_ICON_SVG,
musicvideos: MUSICVIDEOS_ICON_SVG,
playlists: PLAYLISTS_ICON_SVG,
};
/** One `<a>` per library, shared by the desktop sidebar (plain text)
* and the mobile sheet (icon + text) - `withIcon` is the only
* difference between the two call sites below. */
function createLibraryLink(lib: LibraryDto, className: string, withIcon: boolean): HTMLAnchorElement {
const a = document.createElement('a');
a.href = `#/library/${lib.id}`;
a.className = className;
if (withIcon) {
const icon = document.createElement('span');
icon.className = 'navIcon';
icon.innerHTML = COLLECTION_ICONS[lib.collectionType] || GENERIC_LIBRARY_ICON_SVG;
a.appendChild(icon);
}
a.appendChild(document.createTextNode(lib.name));
return a;
}
export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
appEl.innerHTML = html;
appEl.querySelector('#logout')!.textContent = `Se déconnecter (${auth.userName || ''})`;
appEl.querySelector('[data-icon="home"]')!.innerHTML = HOME_ICON_SVG;
// Mounted once, outside #mainContent, so a listen party survives
// navigating the router - see musicBar/MusicBar.ts's header comment.
initMusicBar(appEl.querySelector('#musicBar') as HTMLElement);
appEl.querySelector('#logout')!.addEventListener('click', async () => {
// Both the sidebar's and the mobile sheet's logout button - same
// label, same handler, one line each instead of a shared DOM node
// fought over by two very differently-laid-out containers.
appEl.querySelectorAll<HTMLButtonElement>('.logoutBtn').forEach((btn) => {
btn.textContent = `Se déconnecter (${auth.userName || ''})`;
btn.addEventListener('click', async () => {
await logout();
// Simplest reliable way back to a bare login screen: a real reload,
// rather than hand-tearing-down every shell/router listener in place.
location.hash = '';
location.reload();
});
});
const nav = appEl.querySelector('#sidebarNav')!;
const status = appEl.querySelector('#libStatus')!;
const mobileLibGrid = appEl.querySelector('#mobileLibGrid')!;
const mobilePlaylistsSlot = appEl.querySelector('#mobilePlaylistsSlot')!;
try {
const libraries = await getLibraries();
status.remove();
libraries.forEach((lib) => {
const a = document.createElement('a');
a.href = `#/library/${lib.id}`;
a.className = 'navItem';
a.textContent = lib.name;
nav.appendChild(a);
nav.appendChild(createLibraryLink(lib, 'navItem', false));
// 'playlists' gets pulled out of the "Bibliothèques" group into
// its own fixed row - see docs/architecture.md. Everything else
// (movies/tvshows/music/musicvideos, whatever mix this server
// actually has) lands in the grid, unchanged in count or order.
if (lib.collectionType === 'playlists') {
mobilePlaylistsSlot.appendChild(createLibraryLink(lib, 'navItem sheetPrimary', true));
} else {
mobileLibGrid.appendChild(createLibraryLink(lib, 'navItem sheetItem', true));
}
});
} catch {
status.textContent = 'Bibliothèques indisponibles.';
}
const highlight = () => {
nav.querySelectorAll('.navItem').forEach((a) => {
appEl.querySelectorAll('.navItem').forEach((a) => {
const isHome = a.getAttribute('href') === '#/home';
a.classList.toggle('active', a.getAttribute('href') === location.hash || (isHome && !location.hash));
});
@ -47,5 +103,29 @@ export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
highlight();
window.addEventListener('hashchange', highlight);
// ---- Mobile bottom sheet - see Shell.css's "Mobile navigation". ----
const navTrigger = appEl.querySelector('#mobileNavTrigger') as HTMLButtonElement;
const navSheet = appEl.querySelector('#mobileNavSheet')!;
const navScrim = appEl.querySelector('#mobileNavScrim')!;
const closeMobileNav = () => {
navSheet.classList.remove('open');
navScrim.classList.remove('open');
navTrigger.setAttribute('aria-expanded', 'false');
};
navTrigger.addEventListener('click', () => {
const opening = !navSheet.classList.contains('open');
navSheet.classList.toggle('open', opening);
navScrim.classList.toggle('open', opening);
navTrigger.setAttribute('aria-expanded', String(opening));
});
navScrim.addEventListener('click', closeMobileNav);
// Any of the three zones (Accueil, a library, Listes de lecture) -
// the sheet itself has no other clickable children, so this one
// delegated listener covers all of them without a per-link handler.
navSheet.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('.navItem')) closeMobileNav();
});
window.addEventListener('hashchange', closeMobileNav);
return appEl.querySelector('#mainContent') as HTMLElement;
}

View file

@ -366,6 +366,15 @@
.fsBtn:hover { background: rgba(255, 255, 255, .18); }
/* #pipBtn while active - same "clearly-on" treatment as .iconBtn's own
[aria-pressed="true"] rule (#castBtn), just scoped to .fsBtn instead -
the two aren't combined onto one shared class since .iconBtn and
.fsBtn otherwise disagree on padding/border-radius/background. */
.fsBtn[aria-pressed="true"] {
color: #ff9800;
background: rgba(255, 152, 0, .18);
}
/* Replaces the video content while casting - the local <video> is
paused/torn down (see Player.ts's startCasting/cast.ts), so leaving
its last frame frozen with no explanation would just look stuck. */

View file

@ -127,6 +127,17 @@
<span id="ambientNotice" class="ambientNotice hide"></span>
</div>
<div class="bottomBarZone right">
<!-- Hidden by default, shown only once Player.ts confirms
document.pictureInPictureEnabled - not every browser supports
this (see its own comment), and there's no live
"availability changed" event to react to the way the cast
button has, since this never changes mid-session. -->
<button id="pipBtn" class="fsBtn hide" title="Picture-in-picture" aria-label="Picture-in-picture" aria-pressed="false">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<rect x="3" y="4" width="18" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="2"></rect>
<rect x="12" y="11" width="7" height="5" rx="1" fill="currentColor"></rect>
</svg>
</button>
<button id="fullscreenBtn" class="fsBtn" title="Plein écran" aria-label="Plein écran"></button>
</div>
</div>

View file

@ -196,6 +196,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement;
const burnInHint = root.querySelector('#burnInHint')!;
const castBtn = root.querySelector('#castBtn') as HTMLButtonElement;
const pipBtn = root.querySelector('#pipBtn') as HTMLButtonElement;
const fullscreenBtn = root.querySelector('#fullscreenBtn') as HTMLButtonElement;
const bottomBar = root.querySelector('#bottomBar') as HTMLElement;
const progressBar = root.querySelector('#progressBar') as HTMLElement;
@ -996,6 +997,44 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
});
updateFullscreenBtn();
/**
* Picture-in-picture - asked for directly, mobile in particular: the
* one way to keep watching in a small floating window while switching
* to another app, since a phone has no second window/screen to put
* this app in the way a desktop might. Feature-detected once at mount,
* not live like #castBtn's own onCastAvailabilityChanged - unlike a
* Chromecast appearing/disappearing on the network mid-session,
* browser support for this is fixed for the lifetime of the page, so
* there's nothing to react to after this first check. video itself
* needs no new attribute for this to work - unlike disableremoteplayback
* (added specifically to suppress Chrome's own auto cast icon), there's
* no equivalent auto-injected native PiP control this app needs to
* fight with.
*/
if (document.pictureInPictureEnabled && !video.disablePictureInPicture) {
pipBtn.classList.remove('hide');
pipBtn.addEventListener('click', () => {
if (document.pictureInPictureElement) {
document.exitPictureInPicture().catch(() => {});
} else {
video.requestPictureInPicture().catch((err) => {
// Can genuinely fail even when supported (no user gesture in
// some browsers' stricter reading of that, video not yet
// ready) - same "try it, log it, don't crash anything else"
// instinct as every other best-effort browser API this app
// calls (video.play().catch, requestFullscreen().catch, ...).
console.error('[watchparty player] picture-in-picture failed', err);
});
}
});
// Reflects state via the same [aria-pressed] treatment #castBtn
// uses (see Player.css's .fsBtn[aria-pressed="true"]) - covers the
// browser's own PiP window being closed directly (its native close
// button, or the OS), not just this button's own click.
video.addEventListener('enterpictureinpicture', () => pipBtn.setAttribute('aria-pressed', 'true'));
video.addEventListener('leavepictureinpicture', () => pipBtn.setAttribute('aria-pressed', 'false'));
}
async function onItemChanged(msg: ItemChangedMsg) {
itemTitleEl.textContent = msg.itemName || '';
hasNextQueued = !!msg.hasNext;

View file

@ -3343,6 +3343,67 @@ Authorization` Device/Client/Version header a real client would, only
`X-Emby-Token` - is genuinely unconfirmed. Worth watching the real
dashboard the first time a party runs after this ships.
## Picture-in-picture
Asked for directly, mobile in particular: a phone has no second window
to put this app in the way a desktop might, so the standard browser
Picture-in-Picture API - popping the `<video>` out into a small,
OS-managed floating window - is the one way to keep watching while
switching to another app. Standard `HTMLVideoElement
.requestPictureInPicture()`/`document.exitPictureInPicture()`, not the
Chromecast-adjacent `disableremoteplayback` trick or anything WebKit-
prefixed - confirmed directly in a real Chromium build that
`document.pictureInPictureEnabled`/`requestPictureInPicture`/
`exitPictureInPicture` are all present, which is what actually matters
for Android Chrome, the mobile browser this was asked for.
New `#pipBtn`, right next to `#fullscreenBtn` (`.bottomBarZone.right`)
- both are "how is this displayed" toggles, so they share the same
`.fsBtn` sizing, and a new `.fsBtn[aria-pressed="true"]` rule gives PiP
the identical "clearly on" orange treatment `#castBtn` already has
(kept as its own rule rather than combined onto `.iconBtn` - that class
and `.fsBtn` disagree on padding/border-radius/background, so
combining them risked an inconsistent size instead of a clean shared
look). Feature-detected once at mount (`document.pictureInPictureEnabled
&& !video.disablePictureInPicture`), not live the way `#castBtn`'s own
`onCastAvailabilityChanged` is - browser support for this doesn't
appear/disappear mid-session the way a Chromecast on the network does,
so there's nothing to react to after the first check. No new attribute
needed on `<video>` itself either - unlike `disableremoteplayback`
(added specifically to suppress Chrome's own auto-injected cast icon),
there's no equivalent native PiP affordance this app needs to
suppress.
State (`aria-pressed`) is kept in sync via the video's own
`enterpictureinpicture`/`leavepictureinpicture` events rather than the
button's own click handler alone - covers the browser's native PiP
window being closed directly (its own close control, or the OS), not
just a second click back in this app. Requires no cleanup on route
navigation: like every other per-mount element here, `<video>` itself
is destroyed and replaced by the next `root.innerHTML = html` (router.ts
runs this unconditionally on any navigation) - browsers already close
an active PiP window automatically once its source element leaves the
document, matching this app's very deliberate opposite choice for
casting (leaving an active Chromecast session running after
navigating away - see "Casting to a Chromecast" above): PiP has no
independent existence apart from the `<video>` element it mirrors, a
Chromecast session does.
Verified: full `typecheck`/`build` (client only - this is a pure
client-side feature, no server involved). Confirmed directly in a real
browser, not just type-checked: `document.pictureInPictureEnabled` is
`true` here, and running the exact show/hide + click-handling logic
against a real (sourceless) `<video>` correctly shows the button and
catches the resulting `InvalidStateError` from `requestPictureInPicture()`
without it becoming an uncaught exception - the same rejection a real
video would throw for a different reason (already in PiP, or the
document not focused), confirming the error handling path itself
rather than just assuming it. **Not verified**: an actual floating PiP
window on a real mobile browser, playing a real Emby stream - no
device/login reachable from this sandbox, same standing gap as this
project's other browser-native integrations (casting, the intro-skip
buttons).
## Queuing a whole album, or an artist's entire discography, in one action
Asked for directly: `views/album`'s and `views/artist`'s per-track/
@ -3419,6 +3480,183 @@ tokens) rather than eyeballed. **Not verified**: an actual large
discography queuing correctly end to end against a real server - no
Emby login reachable from this sandbox.
## Progressive Web App
Asked for directly, as a follow-up to Picture-in-picture above: since
that feature's automatic-on-backgrounding variant turned out not to be
achievable (browsers require a *fresh* user gesture for
`requestPictureInPicture()` - confirmed empirically, not just from
docs, by calling it from a real `visibilitychange` listener in a real
Chromium tab and getting `NotAllowedError` even with
`navigator.userActivation.hasBeenActive === true` from an earlier real
click), a PWA install was asked for instead. **Worth being explicit
about, since it'd be easy to assume otherwise**: installing this app
does not change that outcome - the transient-activation requirement is
part of the Picture-in-Picture spec itself, not something tied to
being loaded in a browser tab versus an installed, `display: standalone`
window. The one Chrome feature that skips that requirement
(media-session-handler-based automatic PiP) stays desktop-only and
getUserMedia-gated regardless of install state, and nothing above
touches that gate. So the manual `#pipBtn` toggle remains the way to
enter PiP either way - a PWA install is its own, independently useful
feature here (an icon on the home screen, no browser chrome/URL bar
around the player, an OS-level app-switcher entry), not a workaround
for the previous section's limitation.
Three pieces, all standard and Chromium/Android-focused since that's
this app's stated mobile target (see Picture-in-picture above):
- **`client/src/public/manifest.webmanifest`** - `name`/`short_name`,
`display: "standalone"`, and `background_color`/`theme_color` copied
by hand from `base.css`'s `--bg`/`--bg-header` (a manifest can't read
CSS custom properties, so these two are duplicated on purpose - if
that palette ever changes, update both places). `start_url: "/"`,
`lang: "fr"` matching `index.html`'s own `<html lang="fr">`. No
`service worker` - confirmed against MDN's own installability
requirements that Chrome does not require one for the install
prompt, and this app has no real use for one: every page is either
live party/socket state or a proxied Emby catalog, and caching either
offline would mean showing stale library data or a stale party that's
already gone, not a working offline mode - so skipping it avoids a
whole stale-cache-invalidation problem this app doesn't need to
solve.
- **Icons** - hand-authored SVG (this project has no existing
logo/brand asset), rasterized to PNG at the sizes each consumer
needs (192/512 for the manifest, 180 for `apple-touch-icon`, 32 for
the browser-tab favicon) via a `<canvas>` in a real browser rather
than an image-editing tool, since that's what was available here.
Two source SVGs, not one: `icon.svg` fills the canvas (used for the
manifest's `"any"` purpose, the favicon, and `apple-touch-icon` -
none of those get OS-mask-cropped) and `icon-maskable.svg` scales
the same design down (~75%) so its farthest content point sits
inside Android's adaptive-icon safe zone (a circle of ~30.5% of the
icon's radius from center) - checked by computing the actual
farthest-point distance for both versions, not just eyeballed,
since a maskable icon that ignores that zone gets silently clipped
by circular-mask launchers.
- **`index.html`** - `<link rel="manifest">`, a `theme-color` meta
(Chrome/Android chrome), and the separate iOS-only tags Safari needs
instead of reading the manifest for the equivalent behavior
(`apple-touch-icon` link, `apple-mobile-web-app-capable`,
`apple-mobile-web-app-status-bar-style`, `apple-mobile-web-app-title`).
`build.mjs` gained one `cpSync(src/public, dist, { recursive: true })`
call alongside its existing `index.html` copy, rather than naming the
manifest and every icon individually - adding another icon later needs
no build.mjs change. `express.static` (already serving all of
`client/dist` unconditionally) picks up `manifest.webmanifest` and
`icons/*` with no server-side change at all.
Verified: full `typecheck`/`build` (client only). Confirmed directly
against a real running instance of this server, not just assumed:
`manifest.webmanifest` and all three manifest icons resolve `200` with
the correct `application/manifest+json`/`image/png` content types
(express's static middleware infers both from the file extension
correctly on its own), the `apple-touch-icon` link resolves `200`, the
generated PNGs are genuinely the declared dimensions (32/180/192/512),
and the page's own console shows no manifest-related warnings after
loading it. **Not verified**: an actual "Add to Home Screen"/install
prompt, or the installed app's standalone window, on a real device -
no device reachable from this sandbox, same standing gap as every
other browser-native integration in this project (casting, PiP, the
intro-skip buttons).
## Mobile navigation: a bottom sheet, not a reshaped sidebar
Asked for directly: the sidebar's old mobile treatment just reshaped
itself into a horizontally-scrolling top bar under 720px (a single
media query in Shell.css) - functional, but easy to miss (nothing
signals there's more to scroll to) and it buried "Se déconnecter" at
the far end of that scroll. Explored as a set of mockups first (four
patterns compared side by side, including the tradeoffs of each) before
writing any real code - the bottom sheet won out over a bottom tab bar
(too few slots for an unbounded number of Emby libraries), a hamburger
drawer (still the smallest code delta, but two taps for "Accueil" too),
and a merely-polished version of the existing horizontal bar.
**Structure mirrors the real data shape, not a flat list**: `getLibraries()`
already returns each library's Emby `CollectionType` as `collectionType`
on `LibraryDto` (`server/src/services/CatalogService.ts`) - confirmed by
reading that service directly rather than assumed. The sheet uses it to
render three zones instead of one flat list:
- **Accueil** - one fixed row, always first.
- **Bibliothèques** - a section label over a 2-column grid of every
library *except* the one whose `collectionType` is `'playlists'` -
this is the part whose count and contents genuinely depend on what
the connected Emby server has (2 libraries or 10, movies-only or
every type at once).
- **Listes de lecture** - pulled out of that grid into its own fixed
row, same visual weight as Accueil, bookending the group. Only
rendered at all when a `'playlists'`-type library is actually
present - same conditional-presence rule the sidebar already applies
to every library type, just applied one level deeper here.
Zero server-side work for this split - `collectionType` was already
being sent, just never read on the client before now.
**One `createLibraryLink()` builds both trees.** The desktop sidebar's
existing plain-text links and the mobile sheet's icon+text links are
different enough (icon or not, grid cell or plain row) that unifying
them into one shared DOM subtree styled two ways would need more CSS
trickery than it saves - so `Shell.ts` keeps two small render call
sites, both reading the one `getLibraries()` result, sharing the one
link-building function. The two logout buttons (sidebar footer, sheet
footer) are separate DOM nodes for the same reason - same label, same
`click` handler, wired once via `.logoutBtn` rather than `#logout`.
**Icons are new** - the sidebar has never had any. Small inline SVGs,
same "constant string, `currentColor`" convention `views/player/Player.ts`
already uses for its own icons, one per Emby `CollectionType`
(`movies`/`tvshows`/`music`/`musicvideos`/`playlists`) plus a generic
fallback for any future type this app hasn't mapped yet.
**The music bar's stacking, made structural instead of a magic number.**
`MusicBar.css`'s `.musicBar` no longer positions itself - a new
`.bottomStack` (`position: fixed; bottom: 0`, plain flex column) wraps
it together with the new mobile nav trigger button, so the trigger
always sits flush above the bar's *actual* rendered height. That
height isn't constant - `.musicBarRow` wraps onto a second line on a
narrow screen with a long track/artist name plus presence avatars -
so a hardcoded pixel offset would have drifted; normal flow inside one
shared fixed wrapper can't. Transparent to desktop: the trigger is
`display: none` there, so `.bottomStack` just becomes a plain wrapper
around the bar, unchanged from before.
The sheet itself (`.mobileNavSheet`) and its scrim are independent
`position: fixed` overlays above `.bottomStack`'s z-index, sliding up
via `transform: translateY` - closes on: tapping the scrim, tapping any
of the three zones' links (one delegated click listener, not one per
link), or any `hashchange` (covers browser back/forward too, alongside
the existing `highlight()` listener that already ran on that event for
the active-link state - now widened from `#sidebarNav .navItem` to
every `.navItem` on the page, so both trees highlight consistently).
Verified: full `typecheck`/`build` (client only). The interaction logic
- open/close, closing on a link tap, active-state highlighting, the
grouped Accueil/Bibliothèques/Listes-de-lecture split, the conditional
playlists row - was proven first in the mockup artifact (identical
logic), then re-verified here directly against the real shipped
`Shell.ts`/`Shell.html`/compiled `Shell.css`, wired up with fake library
data (no Emby login reachable from this sandbox) since the real Shell
only ever mounts after a real login: the desktop tree still renders
correctly untouched at desktop width, the compiled CSS's media query
and base mobile rules match what was written byte-for-byte, and the
mobile sheet's open/close/navigate/logout interactions were driven
programmatically end to end (`element.click()` still fires listeners on
a `display: none` element, so this didn't need a real narrow viewport
to exercise the logic itself). The music-bar/trigger stacking was
confirmed visually with a synthetic long-track-title music bar sitting
flush above the trigger, no gap or overlap. **Not verified**: the
actual rendered layout at a genuinely narrow viewport - this sandbox's
browser tool's viewport-emulation stopped taking effect partway through
this session (`screen.width` reflected the requested size, `innerWidth`
and `matchMedia` did not - a tooling issue hit repeatedly this session,
not something in this app's code), so the mobile-width visual check
above was done by forcing the same CSS rules through an injected
override instead of a real ≤720px viewport. Worth an early real-phone
check once deployed.
## Music UI rework: circles and squares instead of movie posters, a real now-playing screen
Asked for directly, "inspired by other apps": the music side of this