Two-layer defense for the recurring "black screen during playback" bug class (5+ occurrences in 24h per doc 26/28/30): L1 (prevention): scope every black-bg rule with :not(:has(.htmlVideoPlayer)):not(:has(#videoOsdPage)) so the rules self-disable while a player is in the DOM. Covers body, #reactRoot, .skinBody, .backgroundContainer, .mainAnimatedPage, .mainAnimatedPages, .pageContainer. L2 (override): when JS-toggled body.arrflix-video-active is set, high-specificity (0,3,2 + tag) transparent rule wins against any ancestor opaque-bg rule (including future regressions someone adds without scoping). Covers all known wrappers, the videoPlayerContainer + videoPlayerContainer-onTop, #videoOsdPage, .libraryPage, .htmlVideoPlayer. L3 (z-index lift): force .htmlVideoPlayer + child <video> to z-index:9999 + isolation:isolate during playback so it floats above any opaque ancestor that still leaks through. Verified in playwright: with arrflix-video-active + .htmlVideoPlayer mounted, all 7 ancestors return rgba(0,0,0,0). Without — all 7 return rgb(0,0,0). Self-disabling works. Lesson reinforced (doc 30 roadmap open): add darkPct assertion to bin/headless-test-v2.py + xmllint CI gate. Five recurrences without those gates says we keep relearning this. TODO next.
177 lines
14 KiB
Python
Executable file
177 lines
14 KiB
Python
Executable file
#!/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 = (
|
|
"body.arrflix-themed .skinHeader .headerTop{display:flex!important;align-items:center;position:relative;min-height:48px}\n"
|
|
"body.arrflix-themed .skinHeader .headerLeft,body.arrflix-themed .skinHeader .headerRight{flex:1 1 0;display:flex;align-items:center}\n"
|
|
"body.arrflix-themed .skinHeader .headerLeft{justify-content:flex-start;gap:.4em}\n"
|
|
"body.arrflix-themed .skinHeader .headerRight{justify-content:flex-end}\n"
|
|
"body.arrflix-themed .skinHeader .headerHomeButton,body.arrflix-themed .skinHeader .pageTitleWithLogo,body.arrflix-themed .skinHeader .headerBackButton{display:none!important}\n"
|
|
"body.arrflix-themed .skinHeader .headerLeft > h3.pageTitle:not(.pageTitleWithLogo){display:none!important}\n"
|
|
"body.arrflix-themed .skinHeader .headerCastButton,body.arrflix-themed .skinHeader .headerSyncButton{display:none!important}\n"
|
|
"body.arrflix-themed .headerTabs.sectionTabs{display:none!important}\n"
|
|
"/* Hide the 'My Media' library row on home page (first vertical section, .section0). Continue Watching=section1, Continue Listening=section2, Continue Reading=section3, Next Up=section5, Recently Added=section6 — leave those untouched. */\n"
|
|
"body.arrflix-themed .homePage .homeSectionsContainer .verticalSection.section0{display:none!important}\n"
|
|
"/* Hide entire header during video playback */\n"
|
|
"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}\n"
|
|
".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}\n"
|
|
".arrflix-headerLogo:hover{filter:brightness(1.15)}\n"
|
|
".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}\n"
|
|
".arrflix-nav:hover{color:#E50914!important}\n"
|
|
".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)}\n"
|
|
"/* Pure-black background — L1: scope rules to non-video pages so they self-disable when player mounts. Skips when .htmlVideoPlayer or #videoOsdPage is in the DOM. Doc 30 lesson — recurring black-screen-over-video bug class. */\n"
|
|
"html,body.arrflix-themed:not(:has(.htmlVideoPlayer)):not(:has(#videoOsdPage)),body.arrflix-themed:not(:has(.htmlVideoPlayer)):not(:has(#videoOsdPage)) .backgroundContainer,body.arrflix-themed:not(:has(.htmlVideoPlayer)):not(:has(#videoOsdPage)) .skinBody,body.arrflix-themed:not(:has(.htmlVideoPlayer)):not(:has(#videoOsdPage)) .mainAnimatedPage,body.arrflix-themed:not(:has(.htmlVideoPlayer)):not(:has(#videoOsdPage)) .mainAnimatedPages,body.arrflix-themed:not(:has(.htmlVideoPlayer)):not(:has(#videoOsdPage)) .pageContainer,body.arrflix-themed:not(:has(.htmlVideoPlayer)):not(:has(#videoOsdPage)) #reactRoot{background-color:#000!important}\n"
|
|
"body.arrflix-themed:not(:has(.htmlVideoPlayer)):not(:has(#videoOsdPage)) .backgroundContainer.withBackdrop{background-color:rgba(0,0,0,.86)!important}\n"
|
|
"/* Video isolation L2: high-specificity transparent override gated by JS-toggled body.arrflix-video-active. Beats any ancestor opaque-bg rule (specificity 0,3,2 plus tag) by chaining 2 body classes + the wrapper class. */\n"
|
|
"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 .htmlVideoPlayer{background-color:transparent!important;background:transparent!important;background-image:none!important}\n"
|
|
"/* Video element on top of all stacking contexts during playback. */\n"
|
|
"body.arrflix-themed.arrflix-video-active video.htmlvideoplayer,body.arrflix-themed.arrflix-video-active .htmlVideoPlayer video,body.arrflix-themed.arrflix-video-active .htmlVideoPlayer{z-index:9999!important;isolation:isolate}\n"
|
|
"/* Search input: replace stock cyan focus ring (jellyfin-web/themes/dark/theme.css:262-272) with a borderless slab + red underline on focus, true to the Cineplex/Netflix accent */\n"
|
|
"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;transition:border-color .18s ease,box-shadow .18s ease,background-color .18s ease;padding:.55em .8em!important}\n"
|
|
"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}\n"
|
|
"body.arrflix-themed .searchFields .emby-input:hover,body.arrflix-themed input.searchfields-txtSearch:hover,body.arrflix-themed #searchTextInput:hover{background:#1a1a1a!important}\n"
|
|
"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}\n"
|
|
)
|
|
|
|
JS = """
|
|
(function(){
|
|
function isVideoPage(){
|
|
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('.htmlVideoPlayer:not(.hide), 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(){
|
|
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}")
|