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:
parent
52c561b47b
commit
891b1e0d3c
11 changed files with 435 additions and 70 deletions
|
|
@ -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).
|
||||||
76
src/web.rs
76
src/web.rs
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
BIN
src/web_ui/apple-touch-icon.png
Normal file
BIN
src/web_ui/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7 KiB |
BIN
src/web_ui/icon-192.png
Normal file
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
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
31
src/web_ui/icon.svg
Normal 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 |
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
17
src/web_ui/manifest.webmanifest
Normal file
17
src/web_ui/manifest.webmanifest
Normal 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
55
src/web_ui/sw.js
Normal 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)));
|
||||||
|
}
|
||||||
|
});
|
||||||
40
tests/api.rs
40
tests/api.rs
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue