Add a mobile banner nudging an install of the PWA #21
5 changed files with 223 additions and 0 deletions
|
|
@ -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
46
client/src/pwaInstall.css
Normal 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
108
client/src/pwaInstall.ts
Normal 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">×</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">×</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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue