feat(web): fix mobile scaling regressions and ship installable PWA

Mobile fixes (all in the embedded SPA):
- Move the mobile media queries to the bottom of the stylesheet — the
  cinematheque design layer was appended after them, so its base rules
  (header padding, video heights) silently overrode the phone layout by
  source order. A comment now pins the ordering invariant.
- The design layer's uiDrop entry animation (fill-mode:both) permanently
  overrode the sidebar's translateX(-100%), leaving the drawer stuck open
  over the content on phones; the mobile block now disables it.
- Lower the single-column breakpoint 380px -> 350px: it was catching
  360/375px phones and blowing every card up to full width.
- viewport-fit=cover so the env(safe-area-inset-*) paddings actually
  resolve on iPhones; notch-safe padding on header/content/modals.
- 16px inputs on small screens (kills iOS Safari's focus auto-zoom),
  same for the login page's password field.
- Mobile rules for the design-layer components that had none: full-height
  command-palette sheet, clamped stats numerals, coarse-pointer touch
  targets for the custom player (taller scrub track, always-visible
  thumb, no volume slider).
- CSP font-src now allows data: — the embedded base64 woff2 fonts were
  being blocked and silently fell back to system faces.

PWA:
- manifest.webmanifest, minimal service worker, and a new Catacomb arch
  icon set (SVG source + rendered PNGs incl. maskable + apple-touch),
  all include_str!/include_bytes!-embedded like the HTML.
- sw.js only intercepts GET navigations to "/" (network-first, cached
  offline fallback) and the static assets; /api, /ws, /files,
  /music-files, /feed are never touched, and it's served no-store so
  binary upgrades keep propagating.
- Routes + auth_middleware allowlist so the browser can fetch the
  statics pre-login; theme-color meta tracks the active theme's panel.
- tests/api.rs covers the new endpoints incl. the ungated-when-password
  invariant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-07-08 00:04:17 -07:00
parent 52c561b47b
commit 891b1e0d3c
No known key found for this signature in database
11 changed files with 435 additions and 70 deletions

View file

@ -0,0 +1,75 @@
# Mobile scaling fixes + PWA support — design
Date: 2026-07-05. Scope: `src/web_ui/` + `src/web.rs` + `tests/api.rs`.
## Problem
1. **Mobile scaling regressions.** The "private cinematheque" design layer was
appended *after* the `@media(max-width:640px)` / `@media(max-width:380px)`
blocks in `src/web_ui/index.html`. Media queries add no specificity, so the
design layer's base rules (header padding, `.vid-wrap video` max-height, …)
override the mobile rules by source order. The design layer also introduced
whole components with **no** mobile rules at all: the custom player
(`.pl-*`), command palette (`.cp-*`), stats dashboard (`.sx-*`) and
maintenance console (`.mx-*`).
2. The viewport meta lacks `viewport-fit=cover`, so every
`env(safe-area-inset-*)` the CSS relies on evaluates to 0 on iPhones.
3. Text inputs are 13px, which triggers iOS Safari's automatic focus-zoom —
the classic "the page scales itself when I tap search" bug.
4. No PWA support: no manifest, no service worker, no installability, and the
bundled `icon.png` is a YouTube-logo lookalike unsuitable for an installed
app icon.
## Approach chosen
**Mobile fixes (index.html only, no JS-visible id/class changes):**
- Add `viewport-fit=cover` to the viewport meta.
- Move both mobile media-query blocks to the **end** of the stylesheet, after
the design layer, with a comment stating the ordering invariant. This is the
structural fix; alternatives (raising specificity, `!important`) were
rejected as fragile.
- Inside the ≤640px block: bump text inputs/selects to 16px (kills iOS
focus-zoom), and add rules for the new components — full-bleed command
palette with `dvh` sizing, clamped stats-hero numerals, safe-area padding.
- New `@media(pointer:coarse)` block for touch ergonomics independent of
width: taller scrub track, always-visible seek thumb, ≥40px player buttons,
hide the hover-oriented volume slider (phones use hardware volume).
**PWA (additive, all static assets embedded like the existing HTML):**
- `src/web_ui/manifest.webmanifest` — name/short_name Catacomb, `display:
standalone`, `start_url: /`, `id: /`, theme/background `#1a1a2e`, icons
192/512 + maskable 512.
- New Catacomb-branded icon: `src/web_ui/icon.svg` (crimson catacomb arch on
the app's dark palette) rendered to `icon-192.png`, `icon-512.png`,
`icon-maskable-512.png`, `apple-touch-icon.png` (180px, opaque). SVG source
checked in; PNGs regenerated via `rsvg-convert`.
- `src/web_ui/sw.js` — deliberately minimal service worker. Intercepts **only**
GET navigations to `/` (network-first, falling back to a cached copy when
offline) and the static icon/manifest paths (cache-first). It never touches
`/api/*`, `/ws/*`, `/files/*`, `/music-files/*`, or `/feed*` — no interference
with auth, streaming ranges, or WebSockets. Served `no-store` so upgrades
propagate on next load, matching the existing "no stale UI" policy.
- `src/web.rs``include_str!`/`include_bytes!` consts + routes for
`/manifest.webmanifest`, `/sw.js`, `/icons/*`, `/apple-touch-icon.png`;
these paths are allowlisted (GET, static, nothing sensitive) in
`auth_middleware` so the browser can fetch them pre-login.
- `index.html` head: manifest link, `theme-color` meta (kept in sync with the
active theme's `--panel` by `applyTheme`), apple-touch-icon link, iOS
standalone metas; SW registration guarded by `'serviceWorker' in navigator`.
## Testing
- `tests/api.rs`: new test asserting `/manifest.webmanifest`, `/sw.js`, and an
icon route return 200 with sane content types, and that they are reachable
without auth when a password is set (follows the existing curl harness).
- `node --check` on the extracted inline script and on `sw.js`.
- Headless Chromium screenshots at phone viewports (375×812, 360×740) before
and after, against a real `--web` instance.
## Out of scope
Offline caching of library data/media, push notifications, background sync,
replacing `icon.png` used by the desktop `.desktop` entry (flagged for a
follow-up since it's a YouTube trademark lookalike).

View file

@ -1160,7 +1160,7 @@ async fn security_headers(req: Request, next: Next) -> Response {
img-src 'self' data: blob: https:; \ img-src 'self' data: blob: https:; \
media-src 'self' blob:; \ media-src 'self' blob:; \
connect-src 'self'; \ connect-src 'self'; \
font-src 'self'; \ font-src 'self' data:; \
object-src 'none'; \ object-src 'none'; \
base-uri 'self'; \ base-uri 'self'; \
frame-ancestors 'none'"; frame-ancestors 'none'";
@ -1187,6 +1187,19 @@ async fn auth_middleware(
if path == "/api/login" { if path == "/api/login" {
return next.run(req).await; return next.run(req).await;
} }
// PWA static assets. Browsers fetch the manifest/icons during install
// (not always with credentials) and must reach the service worker script
// to register it from the login page's origin. All of these are
// compile-time constants with nothing user- or library-specific, so
// serving them pre-auth leaks nothing.
if req.method().as_str() == "GET"
&& (path == "/manifest.webmanifest"
|| path == "/sw.js"
|| path == "/apple-touch-icon.png"
|| path.starts_with("/icons/"))
{
return next.run(req).await;
}
if is_authed(&state, req.headers()) { if is_authed(&state, req.headers()) {
return next.run(req).await; return next.run(req).await;
} }
@ -3303,6 +3316,10 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
let app = Router::new() let app = Router::new()
.route("/", get(get_index)) .route("/", get(get_index))
.route("/manifest.webmanifest", get(get_manifest))
.route("/sw.js", get(get_sw))
.route("/icons/:name", get(get_icon))
.route("/apple-touch-icon.png", get(get_apple_touch_icon))
.route("/api/library", get(get_library)) .route("/api/library", get(get_library))
.route("/api/progress", get(get_progress)) .route("/api/progress", get(get_progress))
.route("/ws/progress", get(ws_progress)) .route("/ws/progress", get(ws_progress))
@ -3439,3 +3456,60 @@ const LOGIN_HTML: &str = include_str!("web_ui/login.html");
/// Main library UI. Single-page app with embedded styles and JS — see the /// Main library UI. Single-page app with embedded styles and JS — see the
/// architecture notes at the top of `web_ui/index.html`. /// architecture notes at the top of `web_ui/index.html`.
const HTML_UI: &str = include_str!("web_ui/index.html"); const HTML_UI: &str = include_str!("web_ui/index.html");
/// PWA assets, embedded like the HTML. The icon PNGs are rendered from
/// `web_ui/icon.svg` — see the comment there for the regen commands.
const MANIFEST: &str = include_str!("web_ui/manifest.webmanifest");
const SW_JS: &str = include_str!("web_ui/sw.js");
const ICON_192: &[u8] = include_bytes!("web_ui/icon-192.png");
const ICON_512: &[u8] = include_bytes!("web_ui/icon-512.png");
const APPLE_TOUCH_ICON: &[u8] = include_bytes!("web_ui/apple-touch-icon.png");
async fn get_manifest() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "application/manifest+json"),
(header::CACHE_CONTROL, "public, max-age=3600"),
],
MANIFEST,
)
}
async fn get_sw() -> impl IntoResponse {
// no-store, same reasoning as the HTML: binary upgrades change the
// embedded worker without changing the URL, and a stale SW would pin
// stale behavior across upgrades.
(
[
(header::CONTENT_TYPE, "text/javascript; charset=utf-8"),
(header::CACHE_CONTROL, "no-store"),
],
SW_JS,
)
}
async fn get_icon(Path(name): Path<String>) -> Response {
let bytes: &'static [u8] = match name.as_str() {
"icon-192.png" => ICON_192,
"icon-512.png" => ICON_512,
_ => return StatusCode::NOT_FOUND.into_response(),
};
(
[
(header::CONTENT_TYPE, "image/png"),
(header::CACHE_CONTROL, "public, max-age=86400"),
],
bytes,
)
.into_response()
}
async fn get_apple_touch_icon() -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "image/png"),
(header::CACHE_CONTROL, "public, max-age=86400"),
],
APPLE_TOUCH_ICON,
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

BIN
src/web_ui/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
src/web_ui/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

31
src/web_ui/icon.svg Normal file
View file

@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<!-- Catacomb app icon: a crypt archway sheltering a play wedge, in the
UI's default palette (bg #1a1a2e / accent #e94560). Full-bleed
background with all strokes inside the central maskable safe zone,
so the same art serves purpose:any and purpose:maskable.
Regenerate the PNGs after editing:
rsvg-convert -w 512 -h 512 icon.svg -o icon-512.png
rsvg-convert -w 192 -h 192 icon.svg -o icon-192.png
rsvg-convert -w 180 -h 180 icon.svg -o apple-touch-icon.png -->
<defs>
<radialGradient id="bg" cx="30%" cy="12%" r="130%">
<stop offset="0" stop-color="#272747"/>
<stop offset="1" stop-color="#131322"/>
</radialGradient>
<linearGradient id="arch" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#ff5f7d"/>
<stop offset="1" stop-color="#cc2b4d"/>
</linearGradient>
</defs>
<rect width="512" height="512" fill="url(#bg)"/>
<!-- doorway interior: faint accent glow so the opening reads as depth -->
<path d="M192 396 V240 a64 64 0 0 1 128 0 V396 Z" fill="#e94560" opacity="0.16"/>
<!-- arch -->
<path d="M154 396 V240 a102 102 0 0 1 204 0 V396" fill="none"
stroke="url(#arch)" stroke-width="28" stroke-linecap="round"/>
<!-- floor (solid colour: a zero-height bounding box degenerates the
objectBoundingBox gradient and rsvg drops the stroke entirely) -->
<path d="M118 396 H394" stroke="#cc2b4d" stroke-width="28" stroke-linecap="round"/>
<!-- play wedge in the doorway -->
<path d="M233 282 l60 34 -60 34 Z" fill="#ff5f7d"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -1,8 +1,16 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Catacomb</title> <title>Catacomb</title>
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#16213e">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Catacomb">
<!-- Embedded UI fonts (base64 below, offline-safe — no CDN): Instrument Serif <!-- Embedded UI fonts (base64 below, offline-safe — no CDN): Instrument Serif
by Rodrigo Fuenzalida and Hanken Grotesk by Alfredo Marco Pradil, both by Rodrigo Fuenzalida and Hanken Grotesk by Alfredo Marco Pradil, both
under the SIL Open Font License 1.1. --> under the SIL Open Font License 1.1. -->
@ -167,71 +175,9 @@
.dl-new-flags label{display:flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap} .dl-new-flags label{display:flex;align-items:center;gap:4px;cursor:pointer;white-space:nowrap}
.preview-thumb{width:100%;max-width:280px;aspect-ratio:16/9;object-fit:cover;border-radius:4px;background:#000;flex-shrink:0} .preview-thumb{width:100%;max-width:280px;aspect-ratio:16/9;object-fit:cover;border-radius:4px;background:#000;flex-shrink:0}
.empty{grid-column:1/-1;text-align:center;color:var(--muted);padding:40px;font-size:13px} .empty{grid-column:1/-1;text-align:center;color:var(--muted);padding:40px;font-size:13px}
@media(max-width:640px){ /* Mobile layout rules live at the BOTTOM of this stylesheet (after the
body{padding-bottom:calc(env(safe-area-inset-bottom) + 4px)} design layer) — media queries add no specificity, so they must come
#menu-btn{display:block} last in source order to override the design layer's base rules. */
aside{position:fixed;top:0;left:0;height:100%;z-index:50;transform:translateX(-100%);width:240px}
aside.open{transform:translateX(0)}
#sidebar-overlay.open{display:block}
/* Hide non-essential header chrome on mobile. The remaining
buttons — menu, search, ⟳ rescan, 🎲 shuffle, ⚙ settings —
are the ones users actually reach for. */
#hdr-stats,#sort,#agpl-notice{display:none}
header h1{display:none}
header{padding:6px 8px;gap:6px;position:sticky;top:0;z-index:20}
#search{order:3;flex-basis:100%;width:100%;min-width:0}
header button{min-width:34px;min-height:34px;padding:4px 7px;font-size:14px}
.filters{overflow-x:auto;padding-bottom:2px;scrollbar-width:none}
.filters::-webkit-scrollbar{display:none}
.toolbar{gap:6px}
.toolbar > *{min-width:0}
/* Bigger tap targets on cards. The five-button card-foot reflows
to a comfortable height instead of cramming icons together. */
.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}
.card-foot{padding:6px 6px 8px;gap:4px;flex-wrap:wrap}
.card-foot button{font-size:13px;padding:6px 8px;min-height:34px}
.card-foot button:not(.play){min-width:34px}
.card-title{min-height:0}
.card-meta{font-size:10px;line-height:1.3}
/* Modals go full-screen so the cramped 10px page-margin we use on
desktop doesn't eat the iPhone safe area. */
.modal-bg{padding:0}
.modal{max-width:100vw;max-height:100dvh;height:100dvh;border-radius:0;border:none;overflow-y:auto;padding:12px 12px calc(12px + env(safe-area-inset-bottom))}
.modal-body{flex-direction:column;gap:8px;overflow-y:auto}
.modal video{max-height:55vh}
.vctrl{padding:6px;gap:6px}
.vctrl .vseek{order:3;flex-basis:100%;min-width:0}
.vctrl .vtime{margin-left:auto}
.settings-row{flex-direction:column;align-items:flex-start;gap:4px}
.meta-grid{grid-template-columns:1fr}
.det-head{flex-direction:column;gap:4px}
.det-actions{width:100%}
.det-actions button{flex:1 1 calc(50% - 4px)}
#details{max-height:min(45vh,220px);padding:8px 10px}
.chapters-pane{display:none}
.transcript-pane{display:none !important}
section#content{padding:8px}
/* Modal "new download" form stacks vertically on small screens so
the URL + button + quality picker don't squeeze each other. */
.dl-new-row{flex-direction:column;align-items:stretch}
.dl-new-row input[type="url"]{width:100%;min-width:0}
.dl-new-row select{width:100%}
/* Bulk-mode actions stack vertically when active. */
.toolbar{flex-wrap:wrap}
#bulk-actions{flex-wrap:wrap;gap:4px}
#bulk-actions button{padding:6px 8px;min-height:32px;font-size:12px}
/* Job rows: hide the last-line preview on small screens; the
progress bar + label are the only things that fit anyway. */
.job{padding:4px 10px;font-size:11px}
.job .last-line{display:none}
}
@media(max-width:380px){
/* Single-column on very narrow phones. */
.grid{grid-template-columns:1fr;gap:6px}
.card-foot{flex-direction:column;align-items:stretch}
.card-foot button{width:100%;min-width:0}
.det-actions button{flex-basis:100%}
}
/* ─────────────────────────────────────────────────────────────────────── /* ───────────────────────────────────────────────────────────────────────
Design layer — "private cinematheque" reskin. Appended on purpose so it Design layer — "private cinematheque" reskin. Appended on purpose so it
@ -543,6 +489,121 @@
.cp-action-btn{background:color-mix(in srgb,var(--card) 55%,transparent);border:1px solid var(--hair);color:var(--text);padding:5px 10px;border-radius:var(--radius-sm);cursor:pointer;font-size:11px;transition:background .12s,border-color .12s} .cp-action-btn{background:color-mix(in srgb,var(--card) 55%,transparent);border:1px solid var(--hair);color:var(--text);padding:5px 10px;border-radius:var(--radius-sm);cursor:pointer;font-size:11px;transition:background .12s,border-color .12s}
.cp-action-btn:hover{background:color-mix(in srgb,var(--accent) 15%,transparent);border-color:var(--accent);color:var(--accent)} .cp-action-btn:hover{background:color-mix(in srgb,var(--accent) 15%,transparent);border-color:var(--accent);color:var(--accent)}
/* ───────────────────────────────────────────────────────────────────────
Mobile layout. MUST stay at the bottom of the stylesheet: media queries
add no specificity, so these only beat the design-layer rules above by
source order. (They used to sit before the design layer, which silently
re-broke header padding, video heights, etc. on phones.)
─────────────────────────────────────────────────────────────────────── */
@media(max-width:640px){
body{padding-bottom:calc(env(safe-area-inset-bottom) + 4px)}
#menu-btn{display:block}
/* animation:none is load-bearing: the design layer's uiDrop entry
animation has fill-mode:both, and animation fill overrides the static
transform below — leaving the drawer stuck open over the content. */
aside{position:fixed;top:0;left:0;height:100%;z-index:50;transform:translateX(-100%);width:240px;padding-left:env(safe-area-inset-left);animation:none}
aside.open{transform:translateX(0)}
#sidebar-overlay.open{display:block}
/* Hide non-essential header chrome on mobile. The remaining
buttons — menu, search, ⟳ rescan, 🎲 shuffle, ⚙ settings —
are the ones users actually reach for. */
#hdr-stats,#sort,#agpl-notice{display:none}
header h1{display:none}
header{padding:6px max(8px,env(safe-area-inset-right)) 6px max(8px,env(safe-area-inset-left));gap:6px;position:sticky;top:0;z-index:20}
#search{order:3;flex-basis:100%;width:100%;min-width:0}
header button{min-width:34px;min-height:34px;padding:4px 7px;font-size:14px}
/* 16px inputs: anything smaller makes iOS Safari zoom the whole page
when the field is focused — the classic mobile "scaling bug". */
#search,#sort,select,textarea,.t-search,.cp-input,
input[type="url"],input[type="text"],input[type="number"],input[type="search"],input[type="password"]{font-size:16px}
.filters{overflow-x:auto;padding-bottom:2px;scrollbar-width:none}
.filters::-webkit-scrollbar{display:none}
.toolbar{gap:6px}
.toolbar > *{min-width:0}
/* Bigger tap targets on cards. The five-button card-foot reflows
to a comfortable height instead of cramming icons together. */
.grid{grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:8px}
.card-foot{padding:6px 6px 8px;gap:4px;flex-wrap:wrap}
.card-foot button{font-size:13px;padding:6px 8px;min-height:34px}
.card-foot button:not(.play){min-width:34px}
.card-title{min-height:0}
.card-meta{font-size:10px;line-height:1.3}
/* Modals go full-screen so the cramped 10px page-margin we use on
desktop doesn't eat the iPhone safe area. */
.modal-bg{padding:0}
.modal{max-width:100vw;max-height:100dvh;height:100dvh;border-radius:0;border:none;overflow-y:auto;
padding:12px calc(12px + env(safe-area-inset-right)) calc(12px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left))}
.modal-body{flex-direction:column;gap:8px;overflow-y:auto}
.modal video{max-height:55vh}
.modal-hdr h2,.modal h2{font-size:1.2rem}
.vid-wrap{flex:0 0 auto}
.vctrl{padding:6px;gap:6px}
.vctrl .vseek{order:3;flex-basis:100%;min-width:0}
.vctrl .vtime{margin-left:auto}
.settings-row{flex-direction:column;align-items:flex-start;gap:4px}
.meta-grid{grid-template-columns:1fr}
.det-head{flex-direction:column;gap:4px}
.det-actions{width:100%}
.det-actions button{flex:1 1 calc(50% - 4px)}
#details{max-height:min(45vh,220px);padding:8px 10px}
.chapters-pane{display:none}
.transcript-pane{display:none !important}
section#content{padding:8px max(8px,env(safe-area-inset-right)) 8px max(8px,env(safe-area-inset-left))}
/* Modal "new download" form stacks vertically on small screens so
the URL + button + quality picker don't squeeze each other. */
.dl-new-row{flex-direction:column;align-items:stretch}
.dl-new-row input[type="url"]{width:100%;min-width:0}
.dl-new-row select{width:100%}
/* Bulk-mode actions stack vertically when active. */
.toolbar{flex-wrap:wrap}
#bulk-actions{flex-wrap:wrap;gap:4px}
#bulk-actions button{padding:6px 8px;min-height:32px;font-size:12px}
/* Job rows: hide the last-line preview on small screens; the
progress bar + label are the only things that fit anyway. */
.job{padding:4px 10px;font-size:11px}
.job .last-line{display:none}
/* Command palette becomes a full-height sheet instead of a floating
card 60px down a 640px column. */
.cp-container{max-width:none;margin:0;height:100dvh;border-radius:0;border:none;
padding-top:env(safe-area-inset-top)}
.cp-results{flex:1 1 auto;max-height:none}
.cp-input-wrap{padding:12px 14px}
.cp-actions{padding-bottom:calc(10px + env(safe-area-inset-bottom))}
/* Stats dashboard: the display-serif numerals are sized for desktop
and overflow 150px metric cards on phones. */
.sx-hero{grid-column:1/-1}
.sx-hero .sx-v{font-size:2.6rem}
.sx-card .sx-v{font-size:1.7rem}
.sx-cols{gap:4px}
.mx-verdict{font-size:1.6rem}
.mx-hero{padding:12px 14px;gap:12px}
/* Custom player chrome: phones have hardware volume buttons and no
hover — drop the slider, keep the rest. */
.pl-vol{display:none}
.pl-time{font-size:12px;margin:0 3px}
}
@media(max-width:350px){
/* Single-column on genuinely narrow phones (~320px class). Anything
wider — 360px Androids, 375pt iPhones — fits two 150px+ columns;
a 380px breakpoint used to catch ALL of those and blow the cards
up to comical full-width. */
.grid{grid-template-columns:1fr;gap:6px}
.card-foot{flex-direction:column;align-items:stretch}
.card-foot button{width:100%;min-width:0}
.det-actions button{flex-basis:100%}
.sx-metrics{grid-template-columns:1fr 1fr}
}
@media(pointer:coarse){
/* Touch ergonomics regardless of viewport width (tablets too). The
hover-revealed seek thumb is always shown and the scrub strip grows
to a comfortable finger target. */
.pl-controls button{font-size:18px;padding:8px 9px}
.pl-track{height:30px}
.pl-hover{bottom:26px}
.pl-thumb{transform:translateY(-50%) scale(1)}
.vctrl .vbtn{font-size:18px;padding:8px 10px}
.chip,.chip-toggle,.chip-clear{padding:6px 10px}
}
@media(prefers-reduced-motion:reduce){*{animation:none!important;transition-duration:.001ms!important}} @media(prefers-reduced-motion:reduce){*{animation:none!important;transition-duration:.001ms!important}}
</style> </style>
</head> </head>
@ -2242,7 +2303,11 @@ function switchMetaTab(tab,which){tab.parentElement.querySelectorAll('.meta-tab'
/* ── Settings ───────────────────────────────────────────────────── */ /* ── Settings ───────────────────────────────────────────────────── */
const THEMES=[['dark','Dark'],['light','Light'],['dracula','Dracula'],['trans','Trans'],['emo-nocturnal','Emo: Nocturnal'],['emo-coffin','Emo: Coffin'],['emo-scene-queen','Emo: Scene Queen'],['solarized','Solarized'],['nord','Nord'],['amoled','AMOLED']]; const THEMES=[['dark','Dark'],['light','Light'],['dracula','Dracula'],['trans','Trans'],['emo-nocturnal','Emo: Nocturnal'],['emo-coffin','Emo: Coffin'],['emo-scene-queen','Emo: Scene Queen'],['solarized','Solarized'],['nord','Nord'],['amoled','AMOLED']];
function applyTheme(t){document.body.className=t==='dark'?'':'theme-'+t;localStorage.setItem('theme',t)} function applyTheme(t){document.body.className=t==='dark'?'':'theme-'+t;localStorage.setItem('theme',t);
// Keep the browser/OS chrome (Android status bar, installed-PWA title bar)
// in step with the active theme's panel colour.
const tc=document.querySelector('meta[name=theme-color]');
if(tc)tc.content=getComputedStyle(document.body).getPropertyValue('--panel').trim()||'#16213e';}
async function logout(){try{await fetch('/api/logout',{method:'POST'});location.reload()}catch{}} async function logout(){try{await fetch('/api/logout',{method:'POST'});location.reload()}catch{}}
@ -3490,6 +3555,10 @@ loadFilterPresets();
})(); })();
loadRemotes(); loadRemotes();
loadSourceNotice(); loadSourceNotice();
// PWA: register the (deliberately minimal) service worker — see sw.js for
// exactly what it does and does not intercept. Requires a secure context
// (https or localhost); elsewhere navigator.serviceWorker is undefined.
if('serviceWorker' in navigator)navigator.serviceWorker.register('/sw.js').catch(()=>{});
</script> </script>
</body> </body>
</html> </html>

View file

@ -1,8 +1,12 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Catacomb — Sign in</title> <title>Catacomb — Sign in</title>
<link rel="manifest" href="/manifest.webmanifest">
<meta name="theme-color" content="#16213e">
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<style> <style>
/* Standalone page (the SPA's embedded fonts aren't available here), so the /* Standalone page (the SPA's embedded fonts aren't available here), so the
display face is a system serif stack — editorial without extra bytes. The display face is a system serif stack — editorial without extra bytes. The
@ -34,7 +38,7 @@
.tag{font-family:var(--font-display);font-style:italic;font-size:.95rem;color:var(--muted);text-align:center;margin:-4px 0 6px} .tag{font-family:var(--font-display);font-style:italic;font-size:.95rem;color:var(--muted);text-align:center;margin:-4px 0 6px}
.field{position:relative;display:flex;flex-direction:column;gap:0} .field{position:relative;display:flex;flex-direction:column;gap:0}
input{width:100%;background:color-mix(in srgb,var(--card) 70%,#000);border:1px solid var(--border);color:var(--text); input{width:100%;background:color-mix(in srgb,var(--card) 70%,#000);border:1px solid var(--border);color:var(--text);
padding:11px 13px;border-radius:9px;font-size:14px;font-family:var(--font-body);transition:border-color .15s,box-shadow .15s} padding:11px 13px;border-radius:9px;font-size:16px;font-family:var(--font-body);transition:border-color .15s,box-shadow .15s}
input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 45%,transparent)} input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 45%,transparent)}
button{background:var(--accent);border:1px solid var(--accent);color:#fff;padding:11px;border-radius:9px;cursor:pointer; button{background:var(--accent);border:1px solid var(--accent);color:#fff;padding:11px;border-radius:9px;cursor:pointer;
font-size:14px;font-weight:600;font-family:var(--font-body);letter-spacing:.02em; font-size:14px;font-weight:600;font-family:var(--font-body);letter-spacing:.02em;

View file

@ -0,0 +1,17 @@
{
"name": "Catacomb",
"short_name": "Catacomb",
"description": "Self-hosted video archive — browse, stream and download your yt-dlp library.",
"id": "/",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#1a1a2e",
"theme_color": "#16213e",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

55
src/web_ui/sw.js Normal file
View file

@ -0,0 +1,55 @@
// Catacomb service worker — deliberately minimal.
//
// Scope: installability + an offline shell, nothing else. It intercepts ONLY
// * GET navigations to "/" (network-first, cached copy as offline fallback)
// * the static PWA assets (manifest + icons, cache-first)
// Everything else — /api/*, /ws/*, /files/*, /music-files/*, /feed* — is left
// to the network untouched, so auth, byte-range video streaming and
// WebSockets behave exactly as they do without a service worker.
//
// The shell mirrors the server's no-store policy on "/": every online
// navigation refetches the HTML, and the cache is only consulted when the
// network is unreachable (offline launch of the installed app). Binary
// upgrades therefore reach clients on the next online load, same as before.
const CACHE = 'catacomb-shell-v1';
const STATIC = ['/manifest.webmanifest', '/icons/icon-192.png', '/icons/icon-512.png'];
self.addEventListener('install', (e) => {
e.waitUntil(
caches.open(CACHE).then((c) => c.addAll(STATIC)).then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (e) => {
const req = e.request;
if (req.method !== 'GET') return;
const url = new URL(req.url);
if (url.origin !== self.location.origin) return;
if (req.mode === 'navigate' && url.pathname === '/') {
e.respondWith(
fetch(req)
.then((res) => {
if (res.ok) {
const copy = res.clone();
caches.open(CACHE).then((c) => c.put('/', copy));
}
return res;
})
.catch(() => caches.match('/').then((hit) => hit || Response.error()))
);
return;
}
if (STATIC.includes(url.pathname)) {
e.respondWith(caches.match(req).then((hit) => hit || fetch(req)));
}
});

View file

@ -473,3 +473,43 @@ fn perceptual_dedup_groups_reencodes() {
assert!(body.contains("bbb"), "group should contain the re-encode: {body}"); assert!(body.contains("bbb"), "group should contain the re-encode: {body}");
assert!(!body.contains("ccc"), "unrelated video must not be grouped: {body}"); assert!(!body.contains("ccc"), "unrelated video must not be grouped: {body}");
} }
#[test]
fn pwa_assets_served_and_ungated() {
if !have_curl() { eprintln!("skip: no curl"); return; }
let s = Server::start();
s.wait_ready();
// The SPA advertises the manifest + registers the service worker.
let (_, idx) = s.get("/");
assert!(idx.contains("manifest.webmanifest"), "index links the manifest");
assert!(idx.contains("serviceWorker"), "index registers the service worker");
let (code, body) = s.get("/manifest.webmanifest");
assert_eq!(code, 200);
assert!(body.contains("\"name\": \"Catacomb\""), "manifest body: {body}");
let (code, body) = s.get("/sw.js");
assert_eq!(code, 200);
assert!(body.contains("catacomb-shell"), "sw body: {body}");
assert_eq!(s.get("/icons/icon-192.png").0, 200);
assert_eq!(s.get("/icons/icon-512.png").0, 200);
assert_eq!(s.get("/apple-touch-icon.png").0, 200);
assert_eq!(s.get("/icons/nope.png").0, 404);
// Setting a password must NOT gate the PWA statics: the browser fetches
// the manifest/icons during install without a session, and the login
// page itself links them.
let (code, _) = s.post("/api/settings", &format!(
r#"{{"transcode":false,"scheduler_enabled":false,"scheduler_interval_hours":24,
"max_concurrent":3,"use_bundled_ytdlp":false,"use_pot_provider":false,
"subtitles_enabled":false,"subtitles_auto":false,"subtitles_embed":false,
"subtitle_langs":"","subtitle_format":"","youtube_player_clients":"",
"sponsorblock_mode":"mark","convert_mode":"","convert_crf":23,"convert_preset":"",
"convert_audio_format":"","convert_keep_original":false,
"new_download_password":"hunter2"}}"#));
assert_eq!(code, 200);
assert_eq!(s.get("/api/library").0, 401, "API must be gated once a password is set");
assert_eq!(s.get("/manifest.webmanifest").0, 200, "manifest stays public");
assert_eq!(s.get("/sw.js").0, 200, "service worker stays public");
assert_eq!(s.get("/icons/icon-512.png").0, 200, "icons stay public");
}