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
+7 -1
View File
@@ -2,7 +2,13 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- viewport-fit=cover lets the app paint under the Android gesture bar; the
transport pads itself back out with env(safe-area-inset-bottom). -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#14110D" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<title>Muzick</title>
</head>
<body>
+48
View File
@@ -1,12 +1,60 @@
server {
listen 80;
# The nginx image ships with gzip off, so the bundle went out raw: 555KB of
# JS where gzip sends 167KB. Only text formats are listed — images, fonts
# (woff2) and audio are already compressed and would only burn CPU.
gzip on;
gzip_vary on;
gzip_min_length 1024;
# The public URL sits behind another proxy, which adds a Via header. With
# the default gzip_proxied off, nginx refuses to compress those responses.
gzip_proxied any;
# That same proxy speaks HTTP/1.0 upstream (nginx proxy_pass defaults to it),
# and gzip_http_version defaults to 1.1 — so without this nothing here is
# ever compressed, however the client asks.
gzip_http_version 1.0;
gzip_comp_level 6;
gzip_types
application/javascript
application/json
application/manifest+json
image/svg+xml
text/css
text/plain;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}
# A cached service worker or shell would pin the app to an old build, since
# both are the files that point at every hashed asset. Assets themselves are
# content-hashed by Vite, so they can be held forever.
# nginx's stock mime.types has no .webmanifest entry, so it would otherwise
# go out as application/octet-stream.
location = /manifest.webmanifest {
root /usr/share/nginx/html;
default_type application/manifest+json;
add_header Cache-Control "no-cache, must-revalidate";
}
location = /sw.js {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, must-revalidate";
}
location = /index.html {
root /usr/share/nginx/html;
add_header Cache-Control "no-cache, must-revalidate";
}
location /assets/ {
root /usr/share/nginx/html;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Admin endpoints need the admin key, not the regular API key. This prefix
# location is longer than "/api", and nginx picks the longest matching
# prefix location, so it wins for /api/admin/* while /api handles the rest.
+4109 -41
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -35,6 +35,7 @@
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vite": "^5.2.0",
"vite-plugin-pwa": "^1.3.0",
"vitest": "^2.1.9"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+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">
+107
View File
@@ -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?.();
};
}, []);
}
+13
View File
@@ -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
View File
@@ -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' {
+68 -1
View File
@@ -1,8 +1,65 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [react()],
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon-32.png', 'apple-touch-icon.png'],
manifest: {
name: 'Muzick',
short_name: 'Muzick',
description: 'Self-hosted music player and recommendation engine',
start_url: '/',
scope: '/',
display: 'standalone',
orientation: 'portrait',
background_color: '#14110D',
theme_color: '#14110D',
icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', type: 'image/png' },
{
src: '/icon-maskable-512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
},
workbox: {
globPatterns: ['**/*.{js,css,html,woff2}'],
// The SPA fallback must never swallow the API: /api/playback/stream is a
// long-lived SSE channel and the track endpoints stream audio. Both look
// like navigations to Workbox unless they are denied here.
navigateFallback: '/index.html',
navigateFallbackDenylist: [/^\/api\//],
// A waiting service worker would otherwise activate and reload the page
// mid-song. Letting it wait means updates land on the next cold start,
// which is the right trade for a player that runs in the background.
skipWaiting: false,
clientsClaim: false,
runtimeCaching: [
{
// Artwork only. Audio is deliberately absent: range requests and
// multi-megabyte bodies do not belong in the shell cache.
urlPattern: ({ request }) => request.destination === 'image',
handler: 'CacheFirst',
options: {
cacheName: 'muzick-artwork',
expiration: { maxEntries: 300, maxAgeSeconds: 60 * 60 * 24 * 30 },
cacheableResponse: { statuses: [0, 200] },
},
},
],
},
}),
],
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
@@ -18,5 +75,15 @@ export default defineConfig({
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
output: {
// The framework changes on an npm upgrade, the app changes every
// deploy. Splitting them means a redeploy re-downloads app code only,
// which matters here because the service worker precaches the lot.
manualChunks: {
vendor: ['react', 'react-dom', '@tanstack/react-router', '@tanstack/react-query'],
},
},
},
}
})