legacy-arrflix/bin/inject-middle-theme.py
s8n 1ed55152b7 fix: drop video z-index hack + heavy comments + doc 31 layer model
Image-12 incident: I'd set <video> z-index:9999, which covers the OSD
scrubber + buttons (Jellyfin's stock OSD controls live at z-index
1100-2000, above the 1000 of .videoPlayerContainer but BELOW our
9999). Drop the lift entirely. Stock z-index hierarchy already has
controls floating on top. The fix for black-screen was always
transparent ancestor backgrounds (L1+L2), never z-index.

Reorganized inject-middle-theme.py CSS string from one-line dense
concat into a triple-quoted multi-line block with header comments
explaining each section + the layer model + DO-NOT rules. Same
output bytes (verified md5 deterministic). Added long-form comment
header to JS too.

Doc 31 (new): "Theme layer model + edit guide" — comprehensive
checklist for any future CSS edit. Covers:
  - Stacking order layer 0..7 with stock Jellyfin z-indexes
  - The two body classes (.arrflix-themed, .arrflix-video-active)
  - Specificity tiers + cascade order (L1 vs L2)
  - CSS load order (inline < bundle < branding.xml)
  - Recurring bug list (6 incidents now, all same anti-pattern)
  - DO NOT DO foot-gun list
  - 4-step smoke verify procedure
  - CI gates still TODO

Snapshot bumped to md5 2da61583. Prod+dev byte-identical.
2026-05-09 22:41:51 +01:00

347 lines
19 KiB
Python
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Inject the ARRFLIX middle-theme v6 (logo center, Movies/Series left, search right)
into a Jellyfin web overlay's index.html. Idempotent — run repeatedly without drift.
Markers:
/* ARRFLIX-MIDDLE-THEME-BEGIN */ ... /* ARRFLIX-MIDDLE-THEME-END */ inside <style> and <script>
<!--ARRFLIX-FAVICON-BEGIN--> ... <!--ARRFLIX-FAVICON-END--> between <link> tags
Usage:
python3 bin/inject-middle-theme.py [target.html]
ARRFLIX_OVERLAY_PATH=/opt/docker/jellyfin/web-overrides/index.html python3 bin/inject-middle-theme.py
Default target: <repo_root>/web-overrides/index.html
Assets read from <repo_root>/web-overrides/assets/:
- arrflix-A.b64 favicon base64 (no data: prefix)
- arrflix-wordmark.b64-url center-logo data-URL (with data: prefix)
Doc 29 covers the design, the auth gate, and the video-page hide rule.
"""
import os, re, sys, pathlib, time
ROOT = pathlib.Path(__file__).resolve().parent.parent
DEFAULT_TARGET = ROOT / "web-overrides" / "index.html"
ASSETS = ROOT / "web-overrides" / "assets"
target = pathlib.Path(sys.argv[1]) if len(sys.argv) > 1 else pathlib.Path(os.environ.get("ARRFLIX_OVERLAY_PATH", DEFAULT_TARGET))
if not target.exists():
sys.exit(f"target overlay not found: {target}")
logo_a_b64 = (ASSETS / "arrflix-A.b64").read_text(encoding="utf-8").strip()
wordmark_url = (ASSETS / "arrflix-wordmark.b64-url").read_text(encoding="utf-8").strip()
START = "/* ARRFLIX-MIDDLE-THEME-BEGIN */"
END = "/* ARRFLIX-MIDDLE-THEME-END */"
CSS = r"""
/* ===========================================================================
* ARRFLIX MIDDLE-THEME v6 — CSS layer model
* ===========================================================================
*
* STACKING ORDER (low → high) — DO NOT VIOLATE:
*
* layer 0 <html> — bg #000 (set via JS inline style; see start())
* black letterbox bars on video page come from here
* layer 1 <body> — bg #000 off-video (L1), transparent on-video (L2)
* layer 2 .backgroundContainer — Jellyfin backdrop (poster blur), bg propagated from L1/L2
* .skinBody — main app shell
* #reactRoot
* layer 3 .mainAnimatedPages — page swap container
* .pageContainer — current page
* layer 4 .skinHeader — top nav (HIDDEN during video — see :not(:has(#loginPage)))
* layer 5 .videoPlayerContainer — Jellyfin player wrapper (z:1000 by Jellyfin, fixed inset:0)
* └─ video.htmlvideoplayer — the <video> element (z:auto, inherits container stack)
* layer 6 .osdControls — Jellyfin OSD bar (scrubber, play/pause, settings)
* .videoOsdBottom — bottom controls strip
* .upNextDialog — episode-up-next overlay
* ALL Jellyfin OSD UI must stay above <video>. Jellyfin sets these
* with z-index > 1000 in stock CSS — DO NOT add a higher z-index
* to <video> or .videoPlayerContainer or you cover the controls.
* layer 7 .dialogContainer — modal dialogs (settings menu, subtitle picker)
*
* RULE: never z-index <video> or .videoPlayerContainer above 1000.
* Stock Jellyfin OSD controls float on top because their CSS sets
* z-index in the 11002000 range (depending on dialog vs bar).
*
* BLACK-SCREEN-OVER-VIDEO BUG CLASS — recurring (5+ times in 24h, doc 26/28/30):
* ANY rule that paints opaque bg on layer 04 ancestors of <video> while
* the player is mounted obscures the decoded frames. Two-layer defence:
*
* L1 (off-video): paint #000 on body+ancestors only when
* body lacks .arrflix-video-active class.
* L2 (on-video): paint transparent on every known ancestor when
* body has .arrflix-video-active. JS toggles this
* class via isVideoPage() which checks hash + DOM.
*
* SPECIFICITY NOTE: L1 (`body.arrflix-themed:not(.arrflix-video-active)`)
* and L2 (`body.arrflix-themed.arrflix-video-active`) both score (0,2,1)
* on body. Equal specificity → source order decides. L2 listed AFTER L1
* in this file → L2 wins when video-active. Good.
*
* BEFORE ADDING ANY NEW BG-COLOR RULE: ask "does this paint an ancestor of
* <video>?" If yes, scope it with `:not(.arrflix-video-active)`. Otherwise
* you reopen the black-screen bug. See doc 31 LAYER-MODEL.
* ===========================================================================
*/
/* --- HEADER LAYOUT ------------------------------------------------------ */
/* Three-column flex: nav-left | logo-center (absolute) | search-right */
body.arrflix-themed .skinHeader .headerTop{display:flex!important;align-items:center;position:relative;min-height:48px}
body.arrflix-themed .skinHeader .headerLeft,
body.arrflix-themed .skinHeader .headerRight{flex:1 1 0;display:flex;align-items:center}
body.arrflix-themed .skinHeader .headerLeft{justify-content:flex-start;gap:.4em}
body.arrflix-themed .skinHeader .headerRight{justify-content:flex-end}
/* Hide stock Jellyfin header chrome we don't want */
body.arrflix-themed .skinHeader .headerHomeButton,
body.arrflix-themed .skinHeader .pageTitleWithLogo,
body.arrflix-themed .skinHeader .headerBackButton{display:none!important}
body.arrflix-themed .skinHeader .headerLeft > h3.pageTitle:not(.pageTitleWithLogo){display:none!important}
body.arrflix-themed .skinHeader .headerCastButton,
body.arrflix-themed .skinHeader .headerSyncButton{display:none!important}
body.arrflix-themed .headerTabs.sectionTabs{display:none!important}
/* Hide 'My Media' row (.section0) — Continue Watching=section1, Next Up=section5, Recently Added=section6 unaffected */
body.arrflix-themed .homePage .homeSectionsContainer .verticalSection.section0{display:none!important}
/* Header itself disappears during video — :not(:has(#loginPage)) keeps login pre-arrflix-themed render unaffected */
body.arrflix-video-active:not(:has(#loginPage:not(.hide))) .skinHeader,
body.arrflix-video-active .arrflix-headerLogo,
body.arrflix-video-active .arrflix-nav{display:none!important}
/* Center wordmark logo — absolute pos, dead-center of headerTop */
.arrflix-headerLogo{
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
width:120px;height:38px;
background:center/contain no-repeat url('__WORDMARK_URL__');
z-index:1;display:block;text-indent:-9999px;overflow:hidden;
}
.arrflix-headerLogo:hover{filter:brightness(1.15)}
/* Movies / Series nav links */
.arrflix-nav{
text-transform:uppercase;letter-spacing:.08em;font-weight:600;
padding:0 .9em;color:#fff!important;text-decoration:none;
display:inline-flex;align-items:center;height:100%;font-size:.85em;
transition:color .18s ease,text-shadow .18s ease,font-weight .18s ease;
}
.arrflix-nav:hover{color:#E50914!important}
/* Variant E: cinematic glow on active route — toggled by JS on hashchange */
.arrflix-nav.active{
color:#E50914!important;font-weight:700;
text-shadow:0 0 12px rgba(229,9,20,0.55),0 0 24px rgba(229,9,20,0.25);
}
/* --- L1: PURE-BLACK BG (off-video only) -------------------------------- */
/* Fires when body does NOT have .arrflix-video-active.
* Specificity (0,2,1) on body / (0,3,1) on descendants.
* <html> selector has no :not() so html stays #000 always. */
html,
body.arrflix-themed:not(.arrflix-video-active),
body.arrflix-themed:not(.arrflix-video-active) .backgroundContainer,
body.arrflix-themed:not(.arrflix-video-active) .skinBody,
body.arrflix-themed:not(.arrflix-video-active) .mainAnimatedPage,
body.arrflix-themed:not(.arrflix-video-active) .mainAnimatedPages,
body.arrflix-themed:not(.arrflix-video-active) .pageContainer,
body.arrflix-themed:not(.arrflix-video-active) #reactRoot{background-color:#000!important}
body.arrflix-themed:not(.arrflix-video-active) .backgroundContainer.withBackdrop{background-color:rgba(0,0,0,.86)!important}
/* --- L2: TRANSPARENT ANCESTORS (during video playback) ----------------- */
/* Fires when JS sets body.arrflix-video-active. Specificity matched to L1
* (0,2,1 on body, 0,3,1 on descendants). L2 wins on source order — listed
* AFTER L1 in this file. Without this, opaque ancestor bg paints over <video>.
*
* NOTE: <html> bg is pinned via JS inline style (start()) so letterbox bars
* stay BLACK even though html selector is in L1's gated rule above.
*
* Targets every known ancestor of <video.htmlvideoplayer>:
* body, .backgroundContainer, .skinBody, .mainAnimatedPage(s), .pageContainer,
* #reactRoot, .videoPlayerContainer (Jellyfin wrapper, z:1000),
* .videoPlayerContainer-onTop, #videoOsdPage + descendants, .libraryPage,
* <video> itself.
*
* DO NOT add z-index to anything in this block. OSD controls (layer 6) sit
* above <video> via Jellyfin's stock z-index 1100+. Lifting <video> z-index
* obscures controls — see image #12 incident. */
body.arrflix-themed.arrflix-video-active,
body.arrflix-themed.arrflix-video-active .backgroundContainer,
body.arrflix-themed.arrflix-video-active .skinBody,
body.arrflix-themed.arrflix-video-active .mainAnimatedPage,
body.arrflix-themed.arrflix-video-active .mainAnimatedPages,
body.arrflix-themed.arrflix-video-active .pageContainer,
body.arrflix-themed.arrflix-video-active #reactRoot,
body.arrflix-themed.arrflix-video-active .videoPlayerContainer,
body.arrflix-themed.arrflix-video-active .videoPlayerContainer-onTop,
body.arrflix-themed.arrflix-video-active #videoOsdPage,
body.arrflix-themed.arrflix-video-active #videoOsdPage .pageContainer,
body.arrflix-themed.arrflix-video-active #videoOsdPage .mainAnimatedPage,
body.arrflix-themed.arrflix-video-active #videoOsdPage .layout-desktop,
body.arrflix-themed.arrflix-video-active .libraryPage,
body.arrflix-themed.arrflix-video-active video.htmlvideoplayer{
background-color:transparent!important;
background:transparent!important;
background-image:none!important;
}
/* --- SEARCH INPUT (cyan ring → red underline) ------------------------- */
/* Stock Jellyfin theme.css:262-272 sets blue focus ring (#00a4dc).
* Replace with borderless slab + red bottom border + soft red glow.
* Cineplex/Netflix-faithful. */
body.arrflix-themed .searchFields .emby-input,
body.arrflix-themed input.searchfields-txtSearch,
body.arrflix-themed #searchTextInput{
background:#141414!important;border:0!important;
border-bottom:2px solid transparent!important;border-radius:2px!important;
color:#fff!important;padding:.55em .8em!important;
transition:border-color .18s ease,box-shadow .18s ease,background-color .18s ease;
}
body.arrflix-themed .searchFields .emby-input::placeholder,
body.arrflix-themed input.searchfields-txtSearch::placeholder,
body.arrflix-themed #searchTextInput::placeholder{color:rgba(255,255,255,.4);letter-spacing:.02em}
body.arrflix-themed .searchFields .emby-input:hover,
body.arrflix-themed input.searchfields-txtSearch:hover,
body.arrflix-themed #searchTextInput:hover{background:#1a1a1a!important}
body.arrflix-themed .searchFields .emby-input:focus,
body.arrflix-themed input.searchfields-txtSearch:focus,
body.arrflix-themed #searchTextInput:focus{
background:#1a1a1a!important;border:0!important;
border-bottom:2px solid #E50914!important;
box-shadow:0 1px 0 0 rgba(229,9,20,.35),0 0 14px -2px rgba(229,9,20,.35)!important;
outline:none!important;
}
""".replace("__WORDMARK_URL__", wordmark_url)
JS = """
/* ARRFLIX middle-theme JS shim — runtime DOM mutations + body-class toggles.
*
* BODY CLASSES we manage:
* .arrflix-themed — set when isAuthed() = true. Gates the entire theme.
* Removed on logout/login route; CSS rules disable.
* .arrflix-video-active — set when isVideoPage() = true. Gates L2 transparency
* and hides .skinHeader. Toggled live on hashchange
* + every 1.5s tick + on every body-mutation.
*
* INLINE STYLE on <html>:
* We force background-color:#000 via setProperty(...,'important') because
* getComputedStyle(html).backgroundColor inexplicably returned rgba(0,0,0,0)
* on details/video pages despite 5 stylesheet rules saying #000 !important.
* Inline style is the highest specificity short of !important user-agent.
* This guarantees the canvas behind any transparent body stays BLACK
* (so video-page letterbox bars are black, not browser-default white).
*/
(function(){
function isVideoPage(){
/* Returns true if the user is currently on a video-playback page.
* Three signals (any one is enough):
* 1. URL hash contains '/video' (Jellyfin's video route)
* 2. #videoOsdPage element is visible (the OSD page id Jellyfin mounts)
* 3. video.htmlvideoplayer (lowercase!) element is visible
* NOTE: '.htmlVideoPlayer' (camelCase) does NOT exist in Jellyfin 10.10.
* The real class is lowercase 'htmlvideoplayer'.
*/
try{
var h=(location.hash||'').toLowerCase();
if (h.indexOf('/video') !== -1) return true;
var osd = document.querySelector('#videoOsdPage:not(.hide)');
if (osd) return true;
var v = document.querySelector('video.htmlvideoplayer:not(.hide)');
if (v && getComputedStyle(v).display !== 'none') return true;
}catch(e){}
return false;
}
function isAuthed(){
try{
if (document.querySelector('.pageContainer.loginPage:not(.hide)')) return false;
if (document.querySelector('#loginPage:not(.hide)')) return false;
var h = (location.hash || '').toLowerCase();
if (h.indexOf('/login') !== -1 || h.indexOf('/wizard') !== -1 || h.indexOf('/forgotpassword') !== -1 || h.indexOf('/selectserver') !== -1) return false;
if (window.ApiClient && typeof window.ApiClient.isLoggedIn === 'function' && !window.ApiClient.isLoggedIn()) return false;
var raw = localStorage.getItem('jellyfin_credentials');
if (!raw) return false;
var creds = JSON.parse(raw);
if (!creds || !creds.Servers || !creds.Servers.length || !creds.Servers[0].AccessToken) return false;
return true;
} catch(e){ return false; }
}
function teardown(){
document.body.classList.remove('arrflix-themed');
var top = document.querySelector('.skinHeader .headerTop'); if (!top) return;
var logo = top.querySelector('.arrflix-headerLogo'); if (logo) logo.remove();
Array.prototype.forEach.call(document.querySelectorAll('.arrflix-nav'), function(n){ n.remove(); });
}
function relayoutHeader(){
document.body.classList.toggle('arrflix-video-active', isVideoPage());
if (!isAuthed()) { teardown(); return; }
var top=document.querySelector('.skinHeader .headerTop'); if(!top) return;
document.body.classList.add('arrflix-themed');
var left=top.querySelector('.headerLeft');
if(left && !left.querySelector('[data-arrflix-nav=\"movies\"]')){
left.insertAdjacentHTML('beforeend',
'<a is=\"emby-linkbutton\" class=\"emby-button arrflix-nav\" data-arrflix-nav=\"movies\" href=\"#/movies.html\">Movies</a>'+
'<a is=\"emby-linkbutton\" class=\"emby-button arrflix-nav\" data-arrflix-nav=\"series\" href=\"#/tv.html\">Series</a>'
);
}
if(!top.querySelector('.arrflix-headerLogo')){
var a=document.createElement('a');
a.className='arrflix-headerLogo';
a.href='#/home.html';
a.setAttribute('aria-label','ARRFLIX home');
a.textContent='ARRFLIX';
var right=top.querySelector('.headerRight');
top.insertBefore(a, right || null);
}
var hash=(location.hash||'').toLowerCase();
var movieMatch=(hash==='#/movies.html'||hash==='#/movies');
var seriesMatch=(hash==='#/tv.html'||hash==='#/tv');
Array.prototype.forEach.call(document.querySelectorAll('[data-arrflix-nav=\"movies\"]'),function(n){ n.classList.toggle('active',movieMatch); });
Array.prototype.forEach.call(document.querySelectorAll('[data-arrflix-nav=\"series\"]'),function(n){ n.classList.toggle('active',seriesMatch); });
}
function start(){
try{ document.documentElement.style.setProperty('background-color','#000','important'); }catch(e){}
relayoutHeader();
try{ new MutationObserver(relayoutHeader).observe(document.body,{childList:true,subtree:true}); }catch(e){}
window.addEventListener('hashchange', relayoutHeader);
setInterval(relayoutHeader,1500);
}
if(document.readyState==='loading') document.addEventListener('DOMContentLoaded',start,{once:true}); else start();
})();
"""
FAVICON_LINKS = (
"<!--ARRFLIX-FAVICON-BEGIN-->"
"<link rel=\"icon\" type=\"image/png\" sizes=\"180x180\" data-arrflix-icon=\"A\" href=\"data:image/png;base64," + logo_a_b64 + "\">"
"<link rel=\"apple-touch-icon\" sizes=\"180x180\" data-arrflix-icon=\"A\" href=\"data:image/png;base64," + logo_a_b64 + "\">"
"<!--ARRFLIX-FAVICON-END-->"
)
FAVICON_HIJACK_JS = (
"<script>/* ARRFLIX-FAVICON-HIJACK-BEGIN */"
"(function(){"
"var A_URL='data:image/png;base64," + logo_a_b64 + "';"
"function pin(){"
"Array.prototype.forEach.call(document.querySelectorAll('link[rel=\"shortcut icon\"], link[rel=\"icon\"], link[rel=\"apple-touch-icon\"]'),function(l){"
"if(l.getAttribute('data-arrflix-icon')==='A')return;"
"if((l.href||'').indexOf('data:image/png')!==-1 && l.href.length>200 && l.getAttribute('data-arrflix-icon')!=='A'){l.parentNode&&l.parentNode.removeChild(l);}"
"});"
"Array.prototype.forEach.call(document.querySelectorAll('link[data-arrflix-icon=\"A\"]'),function(l){if(l.href!==A_URL) l.href=A_URL;});"
"}"
"function start(){pin();try{new MutationObserver(pin).observe(document.head||document.documentElement,{childList:true,subtree:true,attributes:true,attributeFilter:['href']});}catch(e){}setInterval(pin,1000);}"
"if(document.readyState==='loading')document.addEventListener('DOMContentLoaded',start,{once:true});else start();"
"})();"
"/* ARRFLIX-FAVICON-HIJACK-END */</script>"
)
src = target.read_text(encoding="utf-8")
src = re.sub(re.escape("<style>" + START) + r".*?" + re.escape(END + "</style>"), "", src, flags=re.DOTALL)
src = re.sub(re.escape("<script>" + START) + r".*?" + re.escape(END + "</script>"), "", src, flags=re.DOTALL)
src = re.sub(r"<!--ARRFLIX-FAVICON-BEGIN-->.*?<!--ARRFLIX-FAVICON-END-->", "", src, flags=re.DOTALL)
src = re.sub(r"<script>/\* ARRFLIX-FAVICON-HIJACK-BEGIN \*/.*?/\* ARRFLIX-FAVICON-HIJACK-END \*/</script>", "", src, flags=re.DOTALL)
PATCH = "<style>" + START + CSS + END + "</style>" + "<script>" + START + JS + END + "</script>" + FAVICON_LINKS + FAVICON_HIJACK_JS
if "</head>" not in src:
sys.exit("no </head> in target")
src2 = src.replace("</head>", PATCH + "</head>", 1)
backup = target.with_suffix(target.suffix + f".bak.pre-middle-v6.{int(time.time())}")
backup.write_text(target.read_text(encoding="utf-8"), encoding="utf-8")
target.write_text(src2, encoding="utf-8")
print(f"OK v6 wrote {len(src2)} bytes to {target}; backup at {backup}")