Rework the music UI: circles/squares instead of movie posters, a real now-playing screen #11
10
README.md
|
|
@ -344,6 +344,16 @@ Then put it behind your existing reverse proxy like everything else
|
||||||
action — resolved server-side (one atomic operation, not one
|
action — resolved server-side (one atomic operation, not one
|
||||||
request per track) the same way launching a playlist already
|
request per track) the same way launching a playlist already
|
||||||
expands into everything it contains — see docs/architecture.md
|
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
|
- [x] Music UI reworked to look like a music app instead of reusing
|
||||||
movie-poster tiles — circular artist tiles, square album tiles,
|
movie-poster tiles — circular artist tiles, square album tiles,
|
||||||
a real hero header, and a mobile "now playing" full-screen player
|
a real hero header, and a mobile "now playing" full-screen player
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
// npm run watch -> rebuilds on save, for local dev (see README's
|
// npm run watch -> rebuilds on save, for local dev (see README's
|
||||||
// Running locally section)
|
// Running locally section)
|
||||||
import * as esbuild from 'esbuild';
|
import * as esbuild from 'esbuild';
|
||||||
import { copyFileSync, mkdirSync } from 'fs';
|
import { copyFileSync, cpSync, mkdirSync } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
|
@ -20,6 +20,11 @@ const outdir = path.join(here, 'dist');
|
||||||
|
|
||||||
mkdirSync(outdir, { recursive: true });
|
mkdirSync(outdir, { recursive: true });
|
||||||
copyFileSync(path.join(here, 'src', 'index.html'), path.join(outdir, 'index.html'));
|
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 = {
|
const buildOptions = {
|
||||||
entryPoints: [path.join(here, 'src', 'app.ts')],
|
entryPoints: [path.join(here, 'src', 'app.ts')],
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,24 @@
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Watch Party</title>
|
<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" />
|
<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/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>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.7.5/socket.io.min.js"></script>
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,13 @@
|
||||||
able to browse/add tracks while it plays). Sits above .mainContent, at
|
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
|
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
|
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 {
|
.musicBar {
|
||||||
position: fixed;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
z-index: 15;
|
|
||||||
background: var(--bg-header);
|
background: var(--bg-header);
|
||||||
border-top: 1px solid rgba(255, 255, 255, .08);
|
border-top: 1px solid rgba(255, 255, 255, .08);
|
||||||
box-shadow: 0 -4px 16px rgba(0, 0, 0, .3);
|
box-shadow: 0 -4px 16px rgba(0, 0, 0, .3);
|
||||||
|
|
|
||||||
BIN
client/src/public/icons/apple-touch-icon.png
Normal file
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
BIN
client/src/public/icons/favicon.png
Normal file
|
Before Width: | Height: | Size: 1,003 B After Width: | Height: | Size: 1,003 B |
BIN
client/src/public/icons/icon-192.png
Normal file
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
BIN
client/src/public/icons/icon-512-maskable.png
Normal file
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
BIN
client/src/public/icons/icon-512.png
Normal file
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
16
client/src/public/manifest.webmanifest
Normal 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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -62,20 +62,162 @@
|
||||||
padding: 24px clamp(16px, 3vw, 48px) 80px;
|
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) {
|
@media (max-width: 720px) {
|
||||||
.appShell { flex-direction: column; }
|
.appShell { flex-direction: column; }
|
||||||
.sidebar {
|
.sidebar { display: none; }
|
||||||
width: 100%;
|
.mobileTopBar { display: flex; align-items: center; }
|
||||||
height: auto;
|
.mobileNavTrigger { display: flex; }
|
||||||
flex-direction: row;
|
.mobileNavScrim { display: block; }
|
||||||
align-items: center;
|
.mobileNavSheet { display: block; }
|
||||||
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; }
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,37 @@
|
||||||
<div class="navSectionLabel">Bibliothèques</div>
|
<div class="navSectionLabel">Bibliothèques</div>
|
||||||
<p class="hint" id="libStatus"><span class="spinner"></span>Chargement...</p>
|
<p class="hint" id="libStatus"><span class="spinner"></span>Chargement...</p>
|
||||||
</nav>
|
</nav>
|
||||||
<button class="linklike sidebarLogout" id="logout"></button>
|
<button class="linklike sidebarLogout logoutBtn" id="logout"></button>
|
||||||
</aside>
|
</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>
|
<main id="mainContent" class="mainContent"></main>
|
||||||
<div id="musicBar" class="musicBar"></div>
|
|
||||||
|
<!-- 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>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,41 +5,97 @@ import './Shell.css';
|
||||||
import html from './Shell.html';
|
import html from './Shell.html';
|
||||||
import { auth, getLibraries, logout } from '../api/embyApi';
|
import { auth, getLibraries, logout } from '../api/embyApi';
|
||||||
import { initMusicBar } from '../musicBar/MusicBar';
|
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> {
|
export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
|
||||||
appEl.innerHTML = html;
|
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
|
// Mounted once, outside #mainContent, so a listen party survives
|
||||||
// navigating the router - see musicBar/MusicBar.ts's header comment.
|
// navigating the router - see musicBar/MusicBar.ts's header comment.
|
||||||
initMusicBar(appEl.querySelector('#musicBar') as HTMLElement);
|
initMusicBar(appEl.querySelector('#musicBar') as HTMLElement);
|
||||||
|
|
||||||
appEl.querySelector('#logout')!.addEventListener('click', async () => {
|
// Both the sidebar's and the mobile sheet's logout button - same
|
||||||
await logout();
|
// label, same handler, one line each instead of a shared DOM node
|
||||||
// Simplest reliable way back to a bare login screen: a real reload,
|
// fought over by two very differently-laid-out containers.
|
||||||
// rather than hand-tearing-down every shell/router listener in place.
|
appEl.querySelectorAll<HTMLButtonElement>('.logoutBtn').forEach((btn) => {
|
||||||
location.hash = '';
|
btn.textContent = `Se déconnecter (${auth.userName || ''})`;
|
||||||
location.reload();
|
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 nav = appEl.querySelector('#sidebarNav')!;
|
||||||
const status = appEl.querySelector('#libStatus')!;
|
const status = appEl.querySelector('#libStatus')!;
|
||||||
|
const mobileLibGrid = appEl.querySelector('#mobileLibGrid')!;
|
||||||
|
const mobilePlaylistsSlot = appEl.querySelector('#mobilePlaylistsSlot')!;
|
||||||
try {
|
try {
|
||||||
const libraries = await getLibraries();
|
const libraries = await getLibraries();
|
||||||
status.remove();
|
status.remove();
|
||||||
libraries.forEach((lib) => {
|
libraries.forEach((lib) => {
|
||||||
const a = document.createElement('a');
|
nav.appendChild(createLibraryLink(lib, 'navItem', false));
|
||||||
a.href = `#/library/${lib.id}`;
|
// 'playlists' gets pulled out of the "Bibliothèques" group into
|
||||||
a.className = 'navItem';
|
// its own fixed row - see docs/architecture.md. Everything else
|
||||||
a.textContent = lib.name;
|
// (movies/tvshows/music/musicvideos, whatever mix this server
|
||||||
nav.appendChild(a);
|
// 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 {
|
} catch {
|
||||||
status.textContent = 'Bibliothèques indisponibles.';
|
status.textContent = 'Bibliothèques indisponibles.';
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlight = () => {
|
const highlight = () => {
|
||||||
nav.querySelectorAll('.navItem').forEach((a) => {
|
appEl.querySelectorAll('.navItem').forEach((a) => {
|
||||||
const isHome = a.getAttribute('href') === '#/home';
|
const isHome = a.getAttribute('href') === '#/home';
|
||||||
a.classList.toggle('active', a.getAttribute('href') === location.hash || (isHome && !location.hash));
|
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();
|
highlight();
|
||||||
window.addEventListener('hashchange', 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;
|
return appEl.querySelector('#mainContent') as HTMLElement;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,15 @@
|
||||||
|
|
||||||
.fsBtn:hover { background: rgba(255, 255, 255, .18); }
|
.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
|
/* Replaces the video content while casting - the local <video> is
|
||||||
paused/torn down (see Player.ts's startCasting/cast.ts), so leaving
|
paused/torn down (see Player.ts's startCasting/cast.ts), so leaving
|
||||||
its last frame frozen with no explanation would just look stuck. */
|
its last frame frozen with no explanation would just look stuck. */
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,17 @@
|
||||||
<span id="ambientNotice" class="ambientNotice hide"></span>
|
<span id="ambientNotice" class="ambientNotice hide"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bottomBarZone right">
|
<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>
|
<button id="fullscreenBtn" class="fsBtn" title="Plein écran" aria-label="Plein écran"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,7 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement;
|
const qualitySelect = root.querySelector('#qualitySelect') as HTMLSelectElement;
|
||||||
const burnInHint = root.querySelector('#burnInHint')!;
|
const burnInHint = root.querySelector('#burnInHint')!;
|
||||||
const castBtn = root.querySelector('#castBtn') as HTMLButtonElement;
|
const castBtn = root.querySelector('#castBtn') as HTMLButtonElement;
|
||||||
|
const pipBtn = root.querySelector('#pipBtn') as HTMLButtonElement;
|
||||||
const fullscreenBtn = root.querySelector('#fullscreenBtn') as HTMLButtonElement;
|
const fullscreenBtn = root.querySelector('#fullscreenBtn') as HTMLButtonElement;
|
||||||
const bottomBar = root.querySelector('#bottomBar') as HTMLElement;
|
const bottomBar = root.querySelector('#bottomBar') as HTMLElement;
|
||||||
const progressBar = root.querySelector('#progressBar') as HTMLElement;
|
const progressBar = root.querySelector('#progressBar') as HTMLElement;
|
||||||
|
|
@ -996,6 +997,44 @@ function renderVideoPlayer(root: HTMLElement, partyId: string, party: PartyPubli
|
||||||
});
|
});
|
||||||
updateFullscreenBtn();
|
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) {
|
async function onItemChanged(msg: ItemChangedMsg) {
|
||||||
itemTitleEl.textContent = msg.itemName || '';
|
itemTitleEl.textContent = msg.itemName || '';
|
||||||
hasNextQueued = !!msg.hasNext;
|
hasNextQueued = !!msg.hasNext;
|
||||||
|
|
|
||||||
|
|
@ -3343,6 +3343,67 @@ Authorization` Device/Client/Version header a real client would, only
|
||||||
`X-Emby-Token` - is genuinely unconfirmed. Worth watching the real
|
`X-Emby-Token` - is genuinely unconfirmed. Worth watching the real
|
||||||
dashboard the first time a party runs after this ships.
|
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
|
## 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/
|
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
|
discography queuing correctly end to end against a real server - no
|
||||||
Emby login reachable from this sandbox.
|
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
|
## 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
|
Asked for directly, "inspired by other apps": the music side of this
|
||||||
|
|
|
||||||