diff --git a/README.md b/README.md index 1f6d9a1..e2493f2 100644 --- a/README.md +++ b/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 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 diff --git a/client/build.mjs b/client/build.mjs index 0290eef..55fb725 100644 --- a/client/build.mjs +++ b/client/build.mjs @@ -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')], diff --git a/client/src/index.html b/client/src/index.html index e57676f..ecb359c 100644 --- a/client/src/index.html +++ b/client/src/index.html @@ -4,6 +4,24 @@ Watch Party + + + + + + + + + + + + diff --git a/client/src/musicBar/MusicBar.css b/client/src/musicBar/MusicBar.css index fccbd56..c802df8 100644 --- a/client/src/musicBar/MusicBar.css +++ b/client/src/musicBar/MusicBar.css @@ -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); diff --git a/client/src/public/icons/apple-touch-icon.png b/client/src/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..c8262c7 Binary files /dev/null and b/client/src/public/icons/apple-touch-icon.png differ diff --git a/client/src/public/icons/favicon.png b/client/src/public/icons/favicon.png new file mode 100644 index 0000000..bcc545f Binary files /dev/null and b/client/src/public/icons/favicon.png differ diff --git a/client/src/public/icons/icon-192.png b/client/src/public/icons/icon-192.png new file mode 100644 index 0000000..e533df5 Binary files /dev/null and b/client/src/public/icons/icon-192.png differ diff --git a/client/src/public/icons/icon-512-maskable.png b/client/src/public/icons/icon-512-maskable.png new file mode 100644 index 0000000..b20de6e Binary files /dev/null and b/client/src/public/icons/icon-512-maskable.png differ diff --git a/client/src/public/icons/icon-512.png b/client/src/public/icons/icon-512.png new file mode 100644 index 0000000..983626f Binary files /dev/null and b/client/src/public/icons/icon-512.png differ diff --git a/client/src/public/manifest.webmanifest b/client/src/public/manifest.webmanifest new file mode 100644 index 0000000..f6a2eaa --- /dev/null +++ b/client/src/public/manifest.webmanifest @@ -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" } + ] +} diff --git a/client/src/shell/Shell.css b/client/src/shell/Shell.css index ac083cb..e2ae3d1 100644 --- a/client/src/shell/Shell.css +++ b/client/src/shell/Shell.css @@ -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; } } diff --git a/client/src/shell/Shell.html b/client/src/shell/Shell.html index 9fa156a..4d2d356 100644 --- a/client/src/shell/Shell.html +++ b/client/src/shell/Shell.html @@ -6,8 +6,37 @@

Chargement...

- + + + +
+ Watch Party +
+
-
+ + +
+
+ +
+ +
+
+
+ Accueil + +
+
+ +
diff --git a/client/src/shell/Shell.ts b/client/src/shell/Shell.ts index f10797d..cc06086 100644 --- a/client/src/shell/Shell.ts +++ b/client/src/shell/Shell.ts @@ -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 = ''; +const MOVIES_ICON_SVG = ''; +const SHOWS_ICON_SVG = ''; +const MUSIC_ICON_SVG = ''; +const MUSICVIDEOS_ICON_SVG = ''; +const PLAYLISTS_ICON_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 = ''; + +const COLLECTION_ICONS: Record = { + movies: MOVIES_ICON_SVG, + tvshows: SHOWS_ICON_SVG, + music: MUSIC_ICON_SVG, + musicvideos: MUSICVIDEOS_ICON_SVG, + playlists: PLAYLISTS_ICON_SVG, +}; + +/** One `` 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 { 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 () => { - 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(); + // 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('.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 { 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; } diff --git a/client/src/views/player/Player.css b/client/src/views/player/Player.css index 04fce66..54412fe 100644 --- a/client/src/views/player/Player.css +++ b/client/src/views/player/Player.css @@ -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