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:
kami
2026-08-08 23:28:19 +04:00
parent a88ae62db1
commit dea08f9c47
16 changed files with 4416 additions and 57 deletions
+3
View File
@@ -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',
+34
View File
@@ -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);
+1 -1
View File
@@ -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">