Merge pull request 'Add a mobile banner nudging an install of the PWA' (#21) from feature/pwa-install-prompt into master
All checks were successful
Publier l'image Docker / test (push) Successful in 25s
Publier l'image Docker / publish (push) Successful in 7s

This commit is contained in:
kyuno 2026-09-17 21:22:35 +02:00
commit 9cb25f4dc0
5 changed files with 223 additions and 0 deletions

View file

@ -389,6 +389,9 @@ Then put it behind your existing reverse proxy like everything else
- [x] Fixed an album's track rows collapsing to 3 lines on mobile — - [x] Fixed an album's track rows collapsing to 3 lines on mobile —
there was always room for one, the breakpoint just assumed there was always room for one, the breakpoint just assumed
otherwise — see docs/architecture.md otherwise — see docs/architecture.md
- [x] A mobile banner nudging an install of the PWA — the real
Chrome/Android install prompt, manual instructions on iOS Safari
(no such prompt exists there) — see docs/architecture.md
- [ ] Try a full party end-to-end with two real accounts (still the main gap) - [ ] Try a full party end-to-end with two real accounts (still the main gap)
- [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is - [ ] Try casting to a real Chromecast (only the SDK's own bootstrap is
confirmed here — no real device/session) confirmed here — no real device/session)

46
client/src/pwaInstall.css Normal file
View file

@ -0,0 +1,46 @@
/* ---- Mobile-only "install this as an app" banner - see pwaInstall.ts.
A normal-flow element at the very top of <body>, before #app, not a
fixed/sticky overlay - pushes the app down by its own height instead
of needing to coordinate an offset with shell/Shell.css's own
.mobileTopBar/.libraryHeader (already stacked on top of each other at
the same breakpoint) or alphaIndex.css's rail. Scrolls away with the
page like any other content above the sticky chrome, same as a
browser's own native install infobar would. ---- */
.pwaInstallBanner {
display: none;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--accent);
color: #fff;
font-size: 13px;
line-height: 1.35;
}
.pwaInstallText { flex: 1; min-width: 0; }
.pwaInstallBtn {
flex-shrink: 0;
background: #fff;
color: var(--accent);
font-size: 12px;
font-weight: 700;
padding: 6px 12px;
}
.pwaInstallBtn:hover { background: rgba(255, 255, 255, .85); }
.pwaInstallClose {
flex-shrink: 0;
background: none;
color: #fff;
font-size: 20px;
line-height: 1;
padding: 0 2px;
}
.pwaInstallClose:hover { background: none; opacity: .8; }
@media (max-width: 720px) {
.pwaInstallBanner { display: flex; }
}

108
client/src/pwaInstall.ts Normal file
View file

@ -0,0 +1,108 @@
// Mobile-only "install this as an app" nudge - see docs/architecture.md.
// Self-mounting like toast.ts (no per-route/per-party state to attach),
// but the beforeinstallprompt listener below is attached the moment
// this module is first imported - app.ts does that at the very top,
// well before boot()'s async login check - rather than lazily on first
// use like toast.ts: the browser only ever fires this event once per
// page load, often before any UI exists to react to it, and there's no
// way to ask for a replay if a listener attaches too late to catch it.
import './pwaInstall.css';
/** Chrome/Edge/Android-WebView's own install-prompt event - not in
* lib.dom.d.ts (Safari/Firefox never implement it at all). */
interface BeforeInstallPromptEvent extends Event {
prompt(): Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
const DISMISSED_KEY = 'watchparty:pwaInstallDismissed';
const MOBILE_QUERY = '(max-width: 720px)';
let deferredPrompt: BeforeInstallPromptEvent | null = null;
let bannerEl: HTMLElement | null = null;
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault(); // suppress the browser's own mini-infobar - this banner replaces it
deferredPrompt = e as BeforeInstallPromptEvent;
renderIfEligible();
});
window.addEventListener('appinstalled', () => {
deferredPrompt = null;
removeBanner();
});
function isStandalone(): boolean {
return window.matchMedia('(display-mode: standalone)').matches
|| (navigator as unknown as { standalone?: boolean }).standalone === true; // iOS Safari's own flag - no display-mode query there
}
/** No feature-detection exists for "is this iOS Safari" - beforeinstallprompt
* simply never fires there, which is the actual fact this module cares
* about, but by the time that's observable it's too late to show
* install instructions instead. Checked only as a fallback once
* deferredPrompt has had a real chance to arrive - see renderIfEligible. */
function isIOS(): boolean {
return /iphone|ipad|ipod/i.test(navigator.userAgent)
|| (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // iPadOS 13+ reports as a Mac
}
function removeBanner(): void {
bannerEl?.remove();
bannerEl = null;
}
function dismiss(): void {
localStorage.setItem(DISMISSED_KEY, '1');
removeBanner();
}
async function install(): Promise<void> {
if (!deferredPrompt) return;
const prompt = deferredPrompt;
deferredPrompt = null; // a captured prompt can only ever be used once
await prompt.prompt();
const { outcome } = await prompt.userChoice;
// 'dismissed' here means the user closed the browser's own native
// picker without installing - not the same signal as our own close
// button (dismiss() above), so this banner stays up rather than
// remembering "never ask again" over a single not-right-now.
if (outcome === 'accepted') removeBanner();
}
function renderIfEligible(): void {
if (bannerEl) return;
if (isStandalone()) return;
if (localStorage.getItem(DISMISSED_KEY)) return;
if (!window.matchMedia(MOBILE_QUERY).matches) return;
const ios = isIOS();
if (!deferredPrompt && !ios) return; // no install path this module actually knows how to help with
bannerEl = document.createElement('div');
bannerEl.className = 'pwaInstallBanner';
bannerEl.innerHTML = ios
? `<span class="pwaInstallText">Installe Watch Party : appuie sur <strong>Partager</strong> puis « Sur l'écran d'accueil ».</span>
<button type="button" class="pwaInstallClose" aria-label="Fermer">&times;</button>`
: `<span class="pwaInstallText">Installe Watch Party sur ton écran d'accueil pour y accéder plus vite.</span>
<button type="button" class="pwaInstallBtn">Installer</button>
<button type="button" class="pwaInstallClose" aria-label="Fermer">&times;</button>`;
bannerEl.querySelector('.pwaInstallClose')!.addEventListener('click', dismiss);
bannerEl.querySelector('.pwaInstallBtn')?.addEventListener('click', () => { install().catch(() => {}); });
document.body.insertBefore(bannerEl, document.body.firstChild);
}
/**
* Called once by Shell.ts after a successful login - covers both the
* case where beforeinstallprompt already fired before Shell existed to
* react to it (deferredPrompt sitting ready, banner never actually
* shown yet) and iOS, which has no event at all to trigger this from.
* Also re-checked on resize: crossing the 720px breakpoint live (an
* unfolding or rotated device) shouldn't need a reload to notice.
*/
export function initPwaInstallPrompt(): void {
renderIfEligible();
window.addEventListener('resize', renderIfEligible);
}

View file

@ -5,6 +5,7 @@ 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 { initPwaInstallPrompt } from '../pwaInstall';
import type { LibraryDto } from '../types'; import type { LibraryDto } from '../types';
// One icon per Emby CollectionType - only used by the mobile bottom // One icon per Emby CollectionType - only used by the mobile bottom
@ -56,6 +57,7 @@ export async function renderShell(appEl: HTMLElement): Promise<HTMLElement> {
// 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);
initPwaInstallPrompt();
// Both the sidebar's and the mobile sheet's logout button - same // Both the sidebar's and the mobile sheet's logout button - same
// label, same handler, one line each instead of a shared DOM node // label, same handler, one line each instead of a shared DOM node

View file

@ -4266,6 +4266,70 @@ ranges genuinely overlapping, not just similar by coincidence), left to
right in that order, with the long names confirmed actually truncated right in that order, with the long names confirmed actually truncated
(`scrollWidth > clientWidth`) rather than merely happening to fit. (`scrollWidth > clientWidth`) rather than merely happening to fit.
## A mobile install nudge for the PWA
Asked for directly: the PWA (see "Progressive Web App" above) existed,
but nothing ever told a mobile visitor it could be installed at all -
Chrome's own default mini-infobar is easy to miss/dismiss, and Safari
has no browser-driven prompt whatsoever.
New `pwaInstall.ts`, self-mounting like `toast.ts` (no per-route/
per-party state to attach) but with one real difference from that
module's own lazy-init pattern: `beforeinstallprompt` (Chrome/Edge/
Android WebView's event for this - not in `lib.dom.d.ts`, Safari/
Firefox never implement it) can fire before any UI exists to react to
it, and the browser never replays it for a listener that attached too
late. The listener itself is a top-level side effect of importing the
module - `shell/Shell.ts` does that (and calls the module's own
`initPwaInstallPrompt()`, after `initMusicBar`), and since ES module
imports all evaluate before an entry file's own top-level code runs,
that listener is live well before `app.ts`'s `boot()` even starts its
async login check, regardless of how soon the event actually fires.
Two real install paths exist, and the banner speaks to whichever one
applies:
- **Chrome/Edge/Android WebView**: `event.preventDefault()` stashes the
captured event (this banner replaces the browser's own default one);
clicking "Installer" calls the real `prompt()` and reads back
`userChoice` - only an outright 'accepted' removes the banner, since
the picker's own 'dismissed' just means "not right now", not "stop
asking" (that's what the banner's own close button is for).
- **iOS Safari**: no such event exists at all, ever - detected instead
by user-agent sniffing (there's no clean feature-detection for "is
this iOS Safari"), showing manual instructions ("appuie sur Partager
puis Sur l'écran d'accueil") with no button, since there's nothing to
programmatically trigger.
Mobile-only (`matchMedia('(max-width: 720px)')`, this app's own
existing breakpoint, re-checked on resize for a live rotate/unfold) and
suppressed entirely once already running installed
(`display-mode: standalone`, or `navigator.standalone` for iOS Safari's
own equivalent flag, which has no `display-mode` query at all). A
close click remembers "don't ask again" in `localStorage` - not
re-shown even against a fresh `beforeinstallprompt` on a later visit.
Rendered as a plain normal-flow banner at the very top of `<body>`,
before `#app` - not a fixed/sticky overlay - specifically to avoid
needing to coordinate yet another offset with `shell/Shell.css`'s own
already-stacked `.mobileTopBar`/`.libraryHeader` pair (see the sticky-
header sections above) or `alphaIndex.css`'s rail. It simply pushes the
app down by its own height and scrolls away with the page, the same as
a browser's own native install infobar would.
Verified: full `typecheck`/`build` (client only). No real device
install flow is triggerable in this sandbox (nothing can fabricate a
genuine browser installability signal), so checked everything around
that boundary directly instead: a synthetic `beforeinstallprompt`-
shaped event (real `Event`, stubbed `prompt`/`userChoice`) dispatched
into real compiled CSS/JS confirmed the banner appears with the
install button, that button calls the stub `prompt()` and removes the
banner only on an 'accepted' outcome, and that the close button both
removes it and blocks a *second* dispatched event from reopening it.
Overriding `navigator.userAgent` to an iPhone string confirmed the
iOS-flavored copy with no install button. A same-event dispatch into a
real ≥900px-wide `<iframe>` confirmed the banner never renders there
at all, regardless of the event firing.
## Known limitations ## Known limitations
- Playback always goes through `hls.js` against whatever Emby's - Playback always goes through `hls.js` against whatever Emby's