legacy-arrflix/bin/inject-middle-theme.py
s8n 755088e7fc selector v4 hairline ring + reconcile prod drift + skin alt v2
Stock Jellyfin paints .listItem.selected and .focused in cyan
(#00a4dc) on actionSheet/selectionList/dialogContainer dropdowns.
Clashes with Cineplex red on audio + subtitle pickers.

Pick: variant 04 "Hairline ring" — 1px #E50914 outline (offset -1px
inside row) + dark near-black bg + white text. Architectural,
quietly on-brand. Applied via body.arrflix-themed scope.

Saved alternative: web-overrides/skins/selector-variant-02-red-
underline.css — variant 02 "Red underline" (matches search-input
focus). Future skin/swap option per owner. Not currently active.

Reconciled prod drift: prod overlay had been externally modified
since last snapshot (md5 2da61583 -> c62898). Pulled prod's current
overlay as new repo baseline + snapshot. Then applied variant 04 on
top -> dev md5 0ab8b258 (= prod + v4). Prod still c62898 untouched.
2026-05-10 00:28:55 +01:00

372 lines
20 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;
}
/* --- ACTION-SHEET SELECTOR (audio/subtitle dropdowns) ----------------- *
* Stock Jellyfin theme.css paints `.listItem.selected` and `.focused` in
* cyan (#00a4dc) — clashes with Cineplex red. Override to "Hairline ring"
* variant: 1px red outline (offset -1px so it sits inside the row) + dark
* near-black bg + white text. Architectural, quietly on-brand.
*
* Targets every Jellyfin picker/dropdown form:
* .actionSheet .listItem(.selected|.focused) — modal action sheets (audio/sub)
* .selectionList .listItem.selected — legacy selection lists
* .dialogContainer .listItem.selected — dialog-scoped selectors
*
* Future swap: see web-overrides/skins/selector-variant-02-red-underline.css
* for the alternative "red underline" design (matches search-input focus).
*/
body.arrflix-themed .actionSheet .listItem.selected,
body.arrflix-themed .actionSheet .listItem-button.selected,
body.arrflix-themed .actionSheet .listItem.focused,
body.arrflix-themed .selectionList .listItem.selected,
body.arrflix-themed .dialogContainer .listItem.selected{
outline:1px solid #E50914!important;
outline-offset:-1px;
background:rgba(15,15,15,.7)!important;
color:#fff!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}")