ARRFLIX/docs/10-spa-runtime-shim.md

10 KiB

10 - SPA Runtime Branding Shim

Why the static <title>ARRFLIX</title> patch wasn't enough, what the shim does, and how to extend it when Jellyfin starts overwriting more state on upgrade.

Last verified: 2026-05-08 against Jellyfin 10.10.3 web bundle.


TL;DR

/web/index.html already says <title>ARRFLIX</title> and embeds our logo as the favicon (data URL). The browser tab still showed "Jellyfin" + the Jellyfin teal triangle because Jellyfin's SPA overwrites document.title at runtime as it hydrates. Two compounding effects:

  1. SPA runtime overwrite (primary cause). Bundle code does document.title = d.Ay.translateHtml(document.title ...) and per-route updates via LibraryMenu.setTitle / Page.setTitle. Whatever the static <title> is, the SPA will replace it on first hydrate and again on every navigation.
  2. Service worker churn (secondary). serviceworker.js registers and calls self.clients.claim(). The shipped SW does NOT actually cache index.html (it's a notification-only worker), but its presence still pins old clients to whatever they had at first paint. Killing the SW once forces a clean reload.

Fix: a tiny self-contained shim in <head>, INLINE, BEFORE the bundle scripts. It enforces the title and favicon both on hydrate and on every later DOM mutation.


What the shim does

Lives in web-overrides/index.html between <!-- ARRFLIX-SHIM-BEGIN --> / -END markers. Insertion is idempotent via bin/inject-shim.py (re-running the script REPLACES the existing block instead of stacking duplicates).

Behaviour:

Step When What
1 Page parse <script> runs first thing inside <head>, before bundle.
2 DOM ready lockTitle() strips/replaces "Jellyfin"; lockFavicon() re-pins icon hrefs to the data URL already in the page.
3 Mutation A MutationObserver on document.head re-runs both lockers when the SPA tries to change <title> text or any <link rel*="icon"> href.
4 Interval A 1s setInterval is the safety net for late-binding navigations / route changes that bypass the head observer.
5 Once serviceWorker.getRegistrations() finds anything pointing at serviceworker.js and calls r.unregister() so old clients reload fresh. caches.keys() is also flushed.

The shim does NOT re-encode the logo. It reads the existing data:image/png;base64,... href from the <link rel="shortcut icon"> already in the page. So there's exactly one copy of the logo data URL in the file.


Files in play

/tmp/ARRFLIX/
  bin/inject-shim.py          # idempotent injector; the source of truth for shim content
  web-overrides/index.html    # bind-mounted to /jellyfin/jellyfin-web/index.html in the container
  docs/10-spa-runtime-shim.md # this file

Server bind-mount (compose):

volumes:
  - /opt/docker/jellyfin/web-overrides/index.html:/jellyfin/jellyfin-web/index.html:ro

The container reads the bind-mounted file fresh on each request - no docker restart needed when you re-deploy index.html.


Deploying changes

# 1. Edit bin/inject-shim.py (NOT index.html directly - the script is the source of truth)
# 2. Re-run injector locally
python3 /tmp/ARRFLIX/bin/inject-shim.py

# 3. Copy to nullstone
scp /tmp/ARRFLIX/web-overrides/index.html user@192.168.0.100:/opt/docker/jellyfin/web-overrides/index.html

# 4. Verify
curl -ks https://arrflix.s8n.ru/web/index.html | grep -oE "ARRFLIX-SHIM-(BEGIN|END)"
curl -ks https://arrflix.s8n.ru/web/index.html | grep -oE "<title>[^<]*</title>"

No container recreate is needed; the file is read on every HTTP request.


First-deploy: forcing existing clients to reload

The shim's r.unregister() block runs unconditionally. The first time a user with an old SW + old index.html cached visits the site after this deploy:

  1. They get the new index.html (bind-mount serves it).
  2. The shim runs, unregisters the SW, flushes caches.
  3. The current page still shows the old behaviour (because the SW already intercepted this navigation). They need ONE hard reload (Ctrl+Shift+R or Cmd+Shift+R) to get a fully clean session.
  4. Subsequent visits are clean.

If you want to STOP unregistering the SW after the wide deploy (e.g. to let Jellyfin reinstate notification-click handling), edit the script section in bin/inject-shim.py:

if ('serviceWorker' in navigator) {
  // disabled after 2026-05-08 wide rollout
  // navigator.serviceWorker.getRegistrations().then(...);
}

Then re-run the injector and redeploy. There is no harm in leaving it on - the shipped Jellyfin SW only handles notificationclick, which we don't use on a private invite-only service.


Caveats

  • Owner's first browser session must hard-reload once after deploy to evict the previously-registered service worker. Subsequent reloads, and all first-time visitors, get the clean experience.
  • CustomCss (logo replacement on the in-app drawer) is owned by a sibling agent / /Branding/Configuration. The shim does NOT touch CustomCss. If the in-app drawer logo regresses, that's the CSS branch, not this one.
  • The shim must remain self-contained. No external src=. If it ever needs more code, add it to the IIFE in bin/inject-shim.py and re-run.
  • First-paint flash. Because the SPA still loads its own bundle, you may briefly see "Jellyfin" in the tab title before the observer kicks in. Sub-100ms on a fast connection - acceptable. For the colour flash (dark blue / grey before Cineplex CSS arrives), see "Pre-bundle critical-path styles" below.

Pre-bundle critical-path styles

A second inline block — a <style> tag — sits immediately AFTER <head> and BEFORE the shim <script>. It exists to kill the ~500ms-1s flash of Jellyfin default chrome (dark blue + grey + MUI blue submit button) that was visible on first paint at https://arrflix.s8n.ru before the Cineplex @import from /Branding/Configuration (CustomCss) finished arriving.

Why CustomCss alone is too slow

CustomCss is fetched via the SPA bundle's call to /Branding/Configuration — i.e. AFTER the JS bundle parses, executes, and hydrates. So the network sequence is:

  1. HTML parses → first paint uses Jellyfin built-in CSS (dark blue / grey).
  2. JS bundle downloads, parses, executes.
  3. Bundle calls /Branding/Configuration, gets CustomCss body.
  4. CustomCss does @import url("...jsdelivr.net/...cineplex.css").
  5. jsDelivr round-trip → Cineplex CSS arrives → re-paint to ARRFLIX brand.

Steps 1-4 are dead time. The inline <style> runs at step 1 and pre-paints the black background + Netflix-red submit button so the gap is invisible.

What's in the block

Lives between <head> and the <script> shim. Contents:

  • :root overrides for --primary-background-color / --background-color to #000000.
  • html, body, .preload, .skinBody, .skinHeader, #reactRoot, .mainAnimatedPages forced to black bg / white text.
  • .raised, .button-submit, .emby-button[type=submit], button[type=submit] forced to Netflix red #E50914 so the login submit button doesn't flash MUI blue before the bundle skins it.

It is INTENTIONALLY tiny. It only handles the "kill the wrong colours" critical-path goal. All real branding (logos, fonts, posters, header layout, hover states, etc.) still comes from Cineplex + ARRFLIX CustomCss as before.

Maintenance warning on Jellyfin upgrade

This block targets specific Jellyfin class names. If a future Jellyfin web release renames .skinBody, .skinHeader, .preload, #reactRoot, .mainAnimatedPages, .emby-button, or the --primary-background-color CSS variable, the pre-bundle paint will regress to defaults until the selectors here are updated.

After every jellyfin/jellyfin image bump:

  1. Open https://arrflix.s8n.ru in incognito with throttled 3G in DevTools.
  2. Confirm the page goes from blank → black (not blue/grey).
  3. Confirm the login submit button is red (not blue) before bundle finishes.
  4. If either regresses, inspect the new bundle's body class names and update web-overrides/index.html selectors accordingly.

The block does NOT live in bin/inject-shim.py — it's a static <style>, not a script. Edit web-overrides/index.html directly and redeploy via the scp step in "Deploying changes" above.


Extending the shim on Jellyfin upgrade

If a future Jellyfin version starts overwriting MORE state (e.g. the manifest href, the apple-touch-icon, the theme-color meta), extend start() in bin/inject-shim.py:

function lockManifest(){
  var m = document.querySelector('link[rel="manifest"]');
  if (m && m.href.indexOf('fd4301fdc170fd202474.json') === -1) {
    m.href = 'fd4301fdc170fd202474.json';
  }
}
function lockMeta(){
  var n = document.querySelector('meta[name="application-name"]');
  if (n && n.content !== 'ARRFLIX') n.content = 'ARRFLIX';
  var t = document.querySelector('meta[id="themeColor"]');
  if (t && t.content !== '#202020') t.content = '#202020';
}

Then call them in start() and from the MutationObserver callback.

The MutationObserver is already configured with attributes:true, attributeFilter:['href'], so href changes on existing nodes trigger a re-lock. For new attributes, extend the filter list.


Why not just patch the bundle?

Tried. The minified bundle has 30+ document.title= sites, plus per-page pageManager.setTitle() calls. Patching all of them would produce a brittle diff that breaks on every Jellyfin upgrade (bundle hashes change). A 70-line runtime shim sitting on top of the unmodified bundle is the lower-maintenance path.


Verification checklist

  • curl -ks https://arrflix.s8n.ru/web/index.html | grep ARRFLIX-SHIM-BEGIN returns 1 hit
  • curl -ks https://arrflix.s8n.ru/web/index.html | grep -oE "<title>[^<]*</title>" returns <title>ARRFLIX</title>
  • In a fresh incognito browser, tab title is "ARRFLIX" on /web/index.html
  • After login, navigate to a library or item page - tab title stays "ARRFLIX" (or " - ARRFLIX") and never reverts to bare "Jellyfin"
  • DevTools > Application > Service Workers shows no active worker for arrflix.s8n.ru after the first hard reload