Compare commits

...

3 commits

Author SHA1 Message Date
9cb25f4dc0 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
2026-09-17 21:22:35 +02:00
d79b49f34d Merge remote-tracking branch 'origin/master' into feature/pwa-install-prompt
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
# Conflicts:
#	README.md
#	docs/architecture.md
2026-09-17 21:22:16 +02:00
7f0dbc28e3 Add a mobile banner nudging an install of the PWA
Some checks failed
Publier l'image Docker / test (pull_request) Failing after 4s
Publier l'image Docker / publish (pull_request) Has been skipped
The PWA already existed but nothing told a mobile visitor it could be
installed - Chrome own default mini-infobar is easy to miss, and
Safari has no browser-driven prompt at all. New pwaInstall.ts (self-
mounting like toast.ts) captures beforeinstallprompt as early as
possible (a top-level side effect of importing the module from
Shell.ts, live before boot() even starts its async login check, since
that event never replays for a listener that attached too late) and
shows a banner with a real Installer button on Chrome/Edge/Android, or
manual Share-then-home-screen instructions on iOS Safari where no such
event exists at all. Mobile-only, suppressed once already running
standalone, and a close click remembers not to ask again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 23:29:27 +02:00
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 —
there was always room for one, the breakpoint just assumed
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 casting to a real Chromecast (only the SDK's own bootstrap is
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 { auth, getLibraries, logout } from '../api/embyApi';
import { initMusicBar } from '../musicBar/MusicBar';
import { initPwaInstallPrompt } from '../pwaInstall';
import type { LibraryDto } from '../types';
// 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
// navigating the router - see musicBar/MusicBar.ts's header comment.
initMusicBar(appEl.querySelector('#musicBar') as HTMLElement);
initPwaInstallPrompt();
// Both the sidebar's and the mobile sheet's logout button - same
// 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
(`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
- Playback always goes through `hls.js` against whatever Emby's