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 —
|
||||
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
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 { 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue