feat(mobile): install Muzick to the home screen, and get there quickly
Adds the manifest, icons and service worker that make the app installable, and offers it as a toast once Chrome says it qualifies. Declining snoozes the offer for a month; installing ends it. A waiting service worker never activates on its own. Reloading the page under a listener to swap in a new build would cut the song they are in the middle of, so updates land on the next cold start instead. Audio is kept out of the cache entirely: range requests and multi-megabyte bodies do not belong in a shell cache. Artwork is cached, and the SPA navigation fallback denies /api so it cannot swallow the event stream. Installed on Android the app paints edge to edge, so the transport pads itself past the gesture bar. MediaSession gains setPositionState, which is what gives the notification shade a seek bar that moves. Three things kept the bundle from ever being compressed, each hiding the next: the nginx image ships with gzip off, gzip_proxied defaults to off and skips anything carrying a Via header, and gzip_http_version defaults to 1.1 while the host proxy speaks 1.0. With those fixed and the pages split per route, the first load goes from 555KB to 60KB of app code plus a vendor chunk that survives redeploys. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { LyricsOverlay } from './LyricsOverlay';
|
||||
import { Toaster } from './Toaster';
|
||||
import { CommandPalette } from './CommandPalette';
|
||||
import { KeyboardListener, useKeyboard } from '../hooks/useKeyboard';
|
||||
import { useInstallPrompt } from '../hooks/useInstallPrompt';
|
||||
import { PlaybackSyncProvider } from './PlaybackSyncProvider';
|
||||
|
||||
export default function AppShell() {
|
||||
@@ -19,6 +20,8 @@ export default function AppShell() {
|
||||
|
||||
const togglePalette = useCallback(() => setPaletteOpen((p) => !p), []);
|
||||
|
||||
useInstallPrompt();
|
||||
|
||||
// Ctrl+K — command palette (uses `code` so it works on any keyboard layout)
|
||||
useKeyboard({
|
||||
code: 'KeyK',
|
||||
|
||||
@@ -586,6 +586,40 @@ export const AudioEngine = () => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// --- MediaSession: publish position -----------------------------------------
|
||||
//
|
||||
// Without a position state the Android notification renders a scrubber that is
|
||||
// stuck at zero. setPositionState throws if position exceeds duration, which
|
||||
// happens transiently at a track handover, so both are clamped.
|
||||
useEffect(() => {
|
||||
if (!('mediaSession' in navigator) || !navigator.mediaSession.setPositionState) return;
|
||||
|
||||
const publish = (position: number, duration: number) => {
|
||||
if (!Number.isFinite(duration) || duration <= 0) return;
|
||||
try {
|
||||
navigator.mediaSession.setPositionState({
|
||||
duration,
|
||||
position: Math.min(Math.max(position, 0), duration),
|
||||
playbackRate: 1,
|
||||
});
|
||||
} catch {
|
||||
// Stale position against a just-changed duration — the next tick fixes it.
|
||||
}
|
||||
};
|
||||
|
||||
let lastPosition = -1;
|
||||
let lastDuration = -1;
|
||||
|
||||
return usePlaybackStore.subscribe((state) => {
|
||||
// timeupdate fires ~4x a second; only republish on a whole-second change.
|
||||
const second = Math.floor(state.position);
|
||||
if (second === lastPosition && state.duration === lastDuration) return;
|
||||
lastPosition = second;
|
||||
lastDuration = state.duration;
|
||||
publish(state.position, state.duration);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Drop in-flight fades and any pending handover when the engine unmounts.
|
||||
useEffect(() => () => {
|
||||
for (const timer of fadeTimerRef.current) if (timer !== null) clearInterval(timer);
|
||||
|
||||
@@ -26,7 +26,7 @@ export function PlaybackBar({ queueOpen, lyricsOpen, onToggleQueue, onToggleLyri
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="glass border-t border-border/70 px-3 py-2 shrink-0 z-20 sm:h-20 sm:px-4 sm:py-0">
|
||||
<div className="glass safe-x safe-b border-t border-border/70 pt-2 shrink-0 z-20 [--safe-b-base:16px] [--safe-x-base:24px] sm:h-20 sm:pt-0 sm:[--safe-b-base:0px] sm:[--safe-x-base:32px]">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 sm:flex-nowrap sm:gap-4">
|
||||
{/* Track info */}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2.5 sm:w-64 sm:flex-none sm:gap-3">
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useToastStore, toast } from '../store/useToastStore';
|
||||
|
||||
/**
|
||||
* Chrome fires `beforeinstallprompt` when the app qualifies for installation.
|
||||
* Not in lib.dom yet, so the shape is declared here.
|
||||
*/
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||
}
|
||||
|
||||
const SNOOZE_KEY = 'muzick.install-prompt.snoozed-at';
|
||||
const SNOOZE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Long enough that the suggestion lands after the app has proved useful, not
|
||||
// while the first page is still painting.
|
||||
const DELAY_MS = 20_000;
|
||||
|
||||
function snoozed(): boolean {
|
||||
try {
|
||||
const at = Number(localStorage.getItem(SNOOZE_KEY));
|
||||
return Number.isFinite(at) && at > 0 && Date.now() - at < SNOOZE_MS;
|
||||
} catch {
|
||||
return false; // Private mode or blocked storage — just ask.
|
||||
}
|
||||
}
|
||||
|
||||
function snooze() {
|
||||
try {
|
||||
localStorage.setItem(SNOOZE_KEY, String(Date.now()));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offers "Add to Home screen" as a toast, once the browser says the app is
|
||||
* installable. Declining snoozes the suggestion for a month; installing
|
||||
* silences it for good.
|
||||
*/
|
||||
export function useInstallPrompt() {
|
||||
useEffect(() => {
|
||||
// Already installed — the standalone display mode is the reliable signal.
|
||||
if (window.matchMedia?.('(display-mode: standalone)').matches) return;
|
||||
if (snoozed()) return;
|
||||
|
||||
let deferred: BeforeInstallPromptEvent | null = null;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
const offer = () => {
|
||||
if (!deferred) return;
|
||||
const event = deferred;
|
||||
let accepted = false;
|
||||
|
||||
const id = toast.info('Muzick runs better from your home screen.', {
|
||||
ttl: 0,
|
||||
action: {
|
||||
label: 'Install',
|
||||
onClick: () => {
|
||||
accepted = true;
|
||||
unsubscribe?.();
|
||||
// A prompt can only be shown once per event; drop it either way.
|
||||
deferred = null;
|
||||
void event.prompt().then(() => event.userChoice).then((choice) => {
|
||||
if (choice.outcome === 'dismissed') snooze();
|
||||
}).catch(() => snooze());
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// The toast's own close button calls dismiss() directly, so the only way
|
||||
// to notice a decline is to watch the toast leave the store.
|
||||
unsubscribe = useToastStore.subscribe((state) => {
|
||||
if (accepted) return;
|
||||
if (!state.toasts.some((t) => t.id === id)) {
|
||||
snooze();
|
||||
unsubscribe?.();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onBeforeInstallPrompt = (e: Event) => {
|
||||
// Suppress Chrome's own mini-infobar so only our toast asks.
|
||||
e.preventDefault();
|
||||
deferred = e as BeforeInstallPromptEvent;
|
||||
timer = setTimeout(offer, DELAY_MS);
|
||||
};
|
||||
|
||||
const onInstalled = () => {
|
||||
deferred = null;
|
||||
if (timer) clearTimeout(timer);
|
||||
snooze();
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', onBeforeInstallPrompt);
|
||||
window.addEventListener('appinstalled', onInstalled);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', onBeforeInstallPrompt);
|
||||
window.removeEventListener('appinstalled', onInstalled);
|
||||
if (timer) clearTimeout(timer);
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -112,6 +112,19 @@ input[type='range'] {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* ── Safe area ────────────────────────────────────────────────────────────────
|
||||
Installed on Android the app paints edge to edge (viewport-fit=cover), so the
|
||||
gesture bar and any display cutout sit on top of the layout. These add the
|
||||
inset on top of whatever padding the element already carries. */
|
||||
.safe-b {
|
||||
padding-bottom: calc(var(--safe-b-base, 0px) + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.safe-x {
|
||||
padding-left: calc(var(--safe-x-base, 0px) + env(safe-area-inset-left));
|
||||
padding-right: calc(var(--safe-x-base, 0px) + env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
width: 18px;
|
||||
|
||||
+25
-13
@@ -2,22 +2,29 @@ import {
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
lazyRouteComponent,
|
||||
} from '@tanstack/react-router';
|
||||
import { z } from 'zod';
|
||||
import AppShell from './components/AppShell';
|
||||
import { LoadingState } from './components/LoadingState';
|
||||
import Home from './pages/Home';
|
||||
import Artists from './pages/Artists';
|
||||
import ArtistDetail from './pages/ArtistDetail';
|
||||
import Albums from './pages/Albums';
|
||||
import AlbumDetail from './pages/AlbumDetail';
|
||||
import Tracks from './pages/Tracks';
|
||||
import Genres from './pages/Genres';
|
||||
import Vibe from './pages/Vibe';
|
||||
import Recommendations from './pages/Recommendations';
|
||||
import Search from './pages/Search';
|
||||
import Settings from './pages/Settings';
|
||||
import Quarantine from './pages/Quarantine';
|
||||
import Jobs from './pages/Jobs';
|
||||
|
||||
// Home is imported eagerly: it is the landing route, and deferring it would put
|
||||
// a second round trip in front of the first paint. Every other page is a
|
||||
// separate chunk, fetched when the listener first goes there and then held by
|
||||
// the service worker.
|
||||
const Artists = lazyRouteComponent(() => import('./pages/Artists'));
|
||||
const ArtistDetail = lazyRouteComponent(() => import('./pages/ArtistDetail'));
|
||||
const Albums = lazyRouteComponent(() => import('./pages/Albums'));
|
||||
const AlbumDetail = lazyRouteComponent(() => import('./pages/AlbumDetail'));
|
||||
const Tracks = lazyRouteComponent(() => import('./pages/Tracks'));
|
||||
const Genres = lazyRouteComponent(() => import('./pages/Genres'));
|
||||
const Vibe = lazyRouteComponent(() => import('./pages/Vibe'));
|
||||
const Recommendations = lazyRouteComponent(() => import('./pages/Recommendations'));
|
||||
const Search = lazyRouteComponent(() => import('./pages/Search'));
|
||||
const Settings = lazyRouteComponent(() => import('./pages/Settings'));
|
||||
const Quarantine = lazyRouteComponent(() => import('./pages/Quarantine'));
|
||||
const Jobs = lazyRouteComponent(() => import('./pages/Jobs'));
|
||||
|
||||
// Root route renders the AppShell (NavRail + TopBar + PlaybackBar + NowPlayingPanel)
|
||||
// with an <Outlet/> where the active child route renders.
|
||||
@@ -122,7 +129,12 @@ const routeTree = rootRoute.addChildren([
|
||||
jobsRoute,
|
||||
]);
|
||||
|
||||
export const router = createRouter({ routeTree });
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
// Shown while a route's chunk is in flight. Without it the shell holds the
|
||||
// previous page and the tap reads as a dropped input.
|
||||
defaultPendingComponent: () => <LoadingState />,
|
||||
});
|
||||
|
||||
// Type-safety: register the router instance type globally.
|
||||
declare module '@tanstack/react-router' {
|
||||
|
||||
Reference in New Issue
Block a user