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.
This commit is contained in:
parent
4f13db63f9
commit
1ed55152b7
4 changed files with 786 additions and 78 deletions
|
|
@ -33,49 +33,218 @@ wordmark_url = (ASSETS / "arrflix-wordmark.b64-url").read_text(encoding="utf-8")
|
||||||
START = "/* ARRFLIX-MIDDLE-THEME-BEGIN */"
|
START = "/* ARRFLIX-MIDDLE-THEME-BEGIN */"
|
||||||
END = "/* ARRFLIX-MIDDLE-THEME-END */"
|
END = "/* ARRFLIX-MIDDLE-THEME-END */"
|
||||||
|
|
||||||
CSS = (
|
CSS = r"""
|
||||||
"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"
|
* ARRFLIX MIDDLE-THEME v6 — CSS layer model
|
||||||
"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"
|
* STACKING ORDER (low → high) — DO NOT VIOLATE:
|
||||||
"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"
|
* layer 0 <html> — bg #000 (set via JS inline style; see start())
|
||||||
"body.arrflix-themed .headerTabs.sectionTabs{display:none!important}\n"
|
* black letterbox bars on video page come from here
|
||||||
"/* 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"
|
* layer 1 <body> — bg #000 off-video (L1), transparent on-video (L2)
|
||||||
"body.arrflix-themed .homePage .homeSectionsContainer .verticalSection.section0{display:none!important}\n"
|
* layer 2 .backgroundContainer — Jellyfin backdrop (poster blur), bg propagated from L1/L2
|
||||||
"/* Hide entire header during video playback */\n"
|
* .skinBody — main app shell
|
||||||
"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"
|
* #reactRoot
|
||||||
".arrflix-headerLogo{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:120px;height:38px;"
|
* layer 3 .mainAnimatedPages — page swap container
|
||||||
"background:center/contain no-repeat url('" + wordmark_url + "');"
|
* .pageContainer — current page
|
||||||
"z-index:1;display:block;text-indent:-9999px;overflow:hidden}\n"
|
* layer 4 .skinHeader — top nav (HIDDEN during video — see :not(:has(#loginPage)))
|
||||||
".arrflix-headerLogo:hover{filter:brightness(1.15)}\n"
|
* layer 5 .videoPlayerContainer — Jellyfin player wrapper (z:1000 by Jellyfin, fixed inset:0)
|
||||||
".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"
|
* └─ video.htmlvideoplayer — the <video> element (z:auto, inherits container stack)
|
||||||
".arrflix-nav:hover{color:#E50914!important}\n"
|
* layer 6 .osdControls — Jellyfin OSD bar (scrubber, play/pause, settings)
|
||||||
".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"
|
* .videoOsdBottom — bottom controls strip
|
||||||
"/* Pure-black background — L1: gated by :not(.arrflix-video-active). Self-disables when JS sets body.arrflix-video-active. Doc 30 lesson — recurring black-screen-over-video bug class. */\n"
|
* .upNextDialog — episode-up-next overlay
|
||||||
"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}\n"
|
* ALL Jellyfin OSD UI must stay above <video>. Jellyfin sets these
|
||||||
"body.arrflix-themed:not(.arrflix-video-active) .backgroundContainer.withBackdrop{background-color:rgba(0,0,0,.86)!important}\n"
|
* with z-index > 1000 in stock CSS — DO NOT add a higher z-index
|
||||||
"/* Video isolation L2: when arrflix-video-active is set, ancestors become transparent so <video> renders unobscured. html stays #000 from the rule above so letterbox shows black. videoPlayerContainer (the actual Jellyfin wrapper, NOT .htmlVideoPlayer) + child video.htmlvideoplayer covered. */\n"
|
* to <video> or .videoPlayerContainer or you cover the controls.
|
||||||
"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}\n"
|
* layer 7 .dialogContainer — modal dialogs (settings menu, subtitle picker)
|
||||||
"/* 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 .videoPlayerContainer{z-index:9999!important;isolation:isolate}\n"
|
* RULE: never z-index <video> or .videoPlayerContainer above 1000.
|
||||||
"/* 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"
|
* Stock Jellyfin OSD controls float on top because their CSS sets
|
||||||
"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"
|
* z-index in the 1100–2000 range (depending on dialog vs bar).
|
||||||
"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"
|
* BLACK-SCREEN-OVER-VIDEO BUG CLASS — recurring (5+ times in 24h, doc 26/28/30):
|
||||||
"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"
|
* ANY rule that paints opaque bg on layer 0–4 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 = """
|
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(){
|
||||||
function isVideoPage(){
|
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{
|
try{
|
||||||
var h=(location.hash||'').toLowerCase();
|
var h=(location.hash||'').toLowerCase();
|
||||||
if (h.indexOf('/video') !== -1) return true;
|
if (h.indexOf('/video') !== -1) return true;
|
||||||
var osd = document.querySelector('#videoOsdPage:not(.hide)');
|
var osd = document.querySelector('#videoOsdPage:not(.hide)');
|
||||||
if (osd) return true;
|
if (osd) return true;
|
||||||
var v = document.querySelector('.htmlVideoPlayer:not(.hide), video.htmlvideoplayer:not(.hide)');
|
var v = document.querySelector('video.htmlvideoplayer:not(.hide)');
|
||||||
if (v && getComputedStyle(v).display !== 'none') return true;
|
if (v && getComputedStyle(v).display !== 'none') return true;
|
||||||
}catch(e){}
|
}catch(e){}
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
195
docs/31-theme-layer-model-and-edit-guide.md
Normal file
195
docs/31-theme-layer-model-and-edit-guide.md
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
# 31 — ARRFLIX theme layer model + edit guide (2026-05-09)
|
||||||
|
|
||||||
|
> **Read this before editing any CSS in `bin/inject-middle-theme.py`, `web-overrides/index.html`, or `branding.xml`.** Five black-screen-over-video incidents in 24 hours (doc 26 INC1–INC5, doc 28 INC7, doc 30 v6-stable, plus this latest one) all came from the same anti-pattern: an opaque `background-color` rule painted on an ancestor of `<video>` while the player is mounted. This doc maps the layer hierarchy and gives a checklist that catches the bug before it ships.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR — checklist before adding a CSS rule
|
||||||
|
|
||||||
|
1. Does my rule paint `background-color`, `background`, or `background-image` on **any** of: `html`, `body`, `.backgroundContainer`, `.skinBody`, `.mainAnimatedPage(s)`, `.pageContainer`, `#reactRoot`, `.videoPlayerContainer`, `#videoOsdPage`, `.libraryPage`, `video.htmlvideoplayer`?
|
||||||
|
- **Yes** → scope it with `body.arrflix-themed:not(.arrflix-video-active)`. Test on a video page after deploy.
|
||||||
|
- **No** → safe to paint any color.
|
||||||
|
2. Does my rule set a `z-index` on `<video>`, `.videoPlayerContainer`, or any element claiming to be "the player"?
|
||||||
|
- **Yes** → STOP. Don't. OSD controls (scrubber, buttons, settings panel) sit above `<video>` via Jellyfin's stock z-indexes (1100–2000). Lifting the player above that obscures the controls — see image #12 incident, this very doc.
|
||||||
|
- **No** → safe to z-index whatever.
|
||||||
|
3. Did I add a `<video>` literal in a CSS comment? (e.g. `/* video element... */`).
|
||||||
|
- If the rule lives in `branding.xml` `<CustomCss>`: ESCAPE it. `<video>` → `<video>`. Otherwise XML parser chokes, branding silently fails to load, theme disappears site-wide. See doc 30.
|
||||||
|
- If the rule lives in `web-overrides/index.html` `<style>`: safe (HTML doesn't parse content of `<style>`).
|
||||||
|
4. After deploying, hard-refresh and play any video. If you see a black/white frame instead of decoded pixels: revert and re-read this doc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The layer model
|
||||||
|
|
||||||
|
Stacking order, low → high. Ancestors of `<video>` listed first.
|
||||||
|
|
||||||
|
| Layer | Element | Stock z-index | ARRFLIX bg | Notes |
|
||||||
|
|------:|---------|---------------|------------|-------|
|
||||||
|
| 0 | `<html>` | n/a (root) | `#000` (JS inline-style pinned) | Shows behind transparent body during video — black letterbox bars come from here. |
|
||||||
|
| 1 | `<body>` | n/a | `#000` off-video (L1) / `transparent` on-video (L2) | Toggled by JS body class `.arrflix-video-active`. |
|
||||||
|
| 2 | `.backgroundContainer` | `-1` (Jellyfin) | follows L1/L2 | Holds the poster blur backdrop on detail pages. |
|
||||||
|
| | `.skinBody` | `auto` | follows L1/L2 | Main app shell. |
|
||||||
|
| | `#reactRoot` | `auto` | follows L1/L2 | React mount root. |
|
||||||
|
| 3 | `.mainAnimatedPages` | `auto` | follows L1/L2 | Page swap container (animates between pages). |
|
||||||
|
| | `.pageContainer` | `auto` | follows L1/L2 | Current page. |
|
||||||
|
| 4 | `.skinHeader` | `1` | `#000` off-video, **HIDDEN** on-video | Top nav. Hidden when `body.arrflix-video-active` (and not on login). |
|
||||||
|
| 5 | `.videoPlayerContainer` (`.videoPlayerContainer-onTop`) | `1000` (Jellyfin) | `transparent` on-video | The player wrapper. **NEVER override this z-index.** |
|
||||||
|
| | └─ `<video class="htmlvideoplayer">` | `auto` (inherits) | `transparent` | Class is **lowercase** `htmlvideoplayer`. There is no `.htmlVideoPlayer` (camelCase). Don't confuse them. |
|
||||||
|
| 6 | `.osdControls`, `.videoOsdBottom`, `.upNextDialog` | `~1100–1500` (Jellyfin) | varies per element | Scrubber, play/pause, fullscreen, captions, settings. **MUST stay above `<video>`.** |
|
||||||
|
| 7 | `.dialogContainer`, `.dialog` | `~2000+` (Jellyfin) | varies | Modals (settings menu, audio/subtitle picker, info dialog). |
|
||||||
|
|
||||||
|
**Hard rule**: any z-index between 1000 and 2000 is owned by Jellyfin. Don't touch it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The two body classes
|
||||||
|
|
||||||
|
JS toggles two body classes on every `relayoutHeader()` tick (every page mutation + 1.5s interval + hashchange + DOMContentLoaded).
|
||||||
|
|
||||||
|
### `body.arrflix-themed`
|
||||||
|
|
||||||
|
- **Set when** `isAuthed()` returns true. Conditions: `ApiClient.isLoggedIn()`, `localStorage.jellyfin_credentials.Servers[0].AccessToken` exists, no visible `#loginPage`, hash not on `/login | /wizard | /forgotpassword | /selectserver`.
|
||||||
|
- **Removed on** logout, login route, server picker.
|
||||||
|
- **Effect**: gates the entire theme. Without this class, the page renders stock-Jellyfin (so login looks like Jellyfin's default sign-in form, not the rearranged Cineplex layout).
|
||||||
|
|
||||||
|
### `body.arrflix-video-active`
|
||||||
|
|
||||||
|
- **Set when** `isVideoPage()` returns true. Conditions (any one):
|
||||||
|
- `location.hash` contains `/video`
|
||||||
|
- `#videoOsdPage:not(.hide)` exists in DOM
|
||||||
|
- `video.htmlvideoplayer:not(.hide)` exists and is `display:flex/block`
|
||||||
|
- **Removed when** none of those signals match.
|
||||||
|
- **Effect**: switches CSS from L1 (opaque #000 ancestors) to L2 (transparent ancestors), hides `.skinHeader`, hides `.arrflix-headerLogo`, hides `.arrflix-nav`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cascade rules to know
|
||||||
|
|
||||||
|
### Specificity tiers used
|
||||||
|
|
||||||
|
| Selector form | Specificity (a,b,c) |
|
||||||
|
|---------------|---------------------|
|
||||||
|
| `body` | (0,0,1) |
|
||||||
|
| `body.arrflix-themed` | (0,1,1) |
|
||||||
|
| `body.arrflix-themed:not(.arrflix-video-active)` | (0,2,1) |
|
||||||
|
| `body.arrflix-themed.arrflix-video-active` | (0,2,1) |
|
||||||
|
| `body.arrflix-themed.arrflix-video-active .pageContainer` | (0,3,1) |
|
||||||
|
| `body.arrflix-themed.arrflix-video-active #videoOsdPage` | (0,2,1) + ID = (1,2,1) |
|
||||||
|
| `#videoOsdPage .pageContainer` (Cineplex/INC7) | (1,1,0) |
|
||||||
|
|
||||||
|
L1 (off-video) and L2 (on-video) both score (0,2,1) on body. Equal specificity → **source order decides**. L2 is listed AFTER L1 in `inject-middle-theme.py`, so during video L2 wins. If you reorder these blocks you reopen the bug.
|
||||||
|
|
||||||
|
### `!important` doesn't override specificity
|
||||||
|
|
||||||
|
Both L1 and L2 use `!important`. Among `!important` rules, specificity still decides. Adding `!important` to a low-specificity rule won't beat a high-specificity `!important` rule.
|
||||||
|
|
||||||
|
### Inline style beats stylesheets
|
||||||
|
|
||||||
|
`<html style="background-color:#000">` (set via `element.style.setProperty('background-color','#000','important')`) beats every stylesheet rule. We use this on `<html>` because `getComputedStyle(html).backgroundColor` inexplicably returned `rgba(0,0,0,0)` on details/video pages despite 5 stylesheet rules saying `#000 !important`. Likely a Chromium root-canvas-propagation quirk.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS load order (top → bottom = first → last applied)
|
||||||
|
|
||||||
|
1. **`web-overrides/index.html` `<style>` (top, lines 1–63)** — critical CSS, painted before bundle. Includes the original `html, body, .preload, ... { background-color: #000 !important }` rule.
|
||||||
|
2. **`<style>ARRFLIX-MIDDLE-THEME-BEGIN/END</style>`** — our middle-theme rules. Inserted just before `</head>`. After the critical CSS.
|
||||||
|
3. **Jellyfin web bundle CSS** (`main.jellyfin.<hash>.css`, `themes/dark/theme.css`, lazy-loaded chunks). Loaded via `<link>`. Comes after our `<style>` block in the DOM but typically lower specificity, so we still win.
|
||||||
|
4. **`branding.xml` `CustomCss`** — fetched at SPA boot, injected as a `<style>` element AFTER everything else. Includes `@import url('/web/cineplex.css')` which pulls in the Cineplex theme. Wins over inline `<style>` on equal specificity.
|
||||||
|
|
||||||
|
If you see a CSS rule from `branding.xml` overriding ours, increase specificity, not load order.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recurring bug list (the things this doc exists to prevent)
|
||||||
|
|
||||||
|
| Date | Bug | Cause | Fix |
|
||||||
|
|------|-----|-------|-----|
|
||||||
|
| 2026-05-09 INC1 | Backdrop band black | `BLACK-PASS` paints `.backdropContainer` opaque | scope `:has(.itemDetailPage)` transparent |
|
||||||
|
| 2026-05-09 INC4 | "More from Season N" carousel hidden | `.emby-scroller{bg:#000}` unscoped | add `.emby-scroller` to transparent-scope |
|
||||||
|
| 2026-05-09 INC7 | Video black-screen during playback | `.libraryPage{bg:#000}` paints over `<video>` | `#videoOsdPage{bg:transparent}` |
|
||||||
|
| 2026-05-09 v6-stable | All Cineplex CSS missing site-wide | `<video>` literal in branding.xml comment broke XML parse | escape `<video>` → `<video>` |
|
||||||
|
| 2026-05-09 a6cf925 | Body opaque during playback | `:has(.htmlVideoPlayer)` (camelCase) never matched | use `:not(.arrflix-video-active)` instead |
|
||||||
|
| 2026-05-09 image-12 | Video covers OSD scrubber + buttons | We forced `<video> z-index: 9999` | revert; rely on Jellyfin's stock z-index hierarchy |
|
||||||
|
|
||||||
|
The pattern is the same every time: **a wrapper got an opaque background, and the negation didn't catch every wrapper Jellyfin uses on the video page.** This doc is the negation list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to add a new theme rule safely
|
||||||
|
|
||||||
|
### Adding a NEW bg-color rule
|
||||||
|
|
||||||
|
1. Read the layer table above. If your selector lands on layer 0–4, scope with `body.arrflix-themed:not(.arrflix-video-active)`.
|
||||||
|
2. Put the rule in `bin/inject-middle-theme.py`, in the section that matches its purpose (header layout, search input, etc.).
|
||||||
|
3. Run `python3 bin/inject-middle-theme.py` to re-emit `web-overrides/index.html`.
|
||||||
|
4. scp to dev only (don't touch prod yet).
|
||||||
|
5. Hard-refresh dev login → confirm no visual regression.
|
||||||
|
6. Hard-refresh dev → click any video → play 5+ seconds → confirm video pixels visible. If black: revert.
|
||||||
|
7. Only then push to prod (`docker run --userns=host -v /opt/docker/jellyfin/web-overrides:/d:rw alpine cp /tmp/idx.html /d/index.html && chown root:root && docker restart jellyfin`).
|
||||||
|
|
||||||
|
### Adding a NEW z-index
|
||||||
|
|
||||||
|
Don't. Period. If you think you need to z-index `<video>` higher to "be on top": you don't. Stock Jellyfin already gives you the right stacking. The bug was always opaque ancestor backgrounds, never z-index.
|
||||||
|
|
||||||
|
If you absolutely need z-index for a non-player element: stay below 1000 (everything below the player wrapper) or above 2000 (above all dialogs). Anything in between is a Jellyfin OSD/dialog territory.
|
||||||
|
|
||||||
|
### Adding a NEW selector to L2's transparent list
|
||||||
|
|
||||||
|
1. Confirm the selector lands on a real ancestor of `<video>` (open DevTools, navigate up the DOM tree from the video element).
|
||||||
|
2. Add it to BOTH L1 (opaque) and L2 (transparent) selector lists. Always paired so the off-video state stays black.
|
||||||
|
3. Same deploy flow as above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DO NOT DO list (the foot-guns)
|
||||||
|
|
||||||
|
| Don't | Reason |
|
||||||
|
|-------|--------|
|
||||||
|
| `body.arrflix-themed { background:#000 }` (unscoped) | Reopens black-screen bug. Always use `:not(.arrflix-video-active)`. |
|
||||||
|
| `<video> { z-index: 9999 }` | Covers OSD scrubber and buttons. Image #12. |
|
||||||
|
| `:has(.htmlVideoPlayer)` (camelCase) | Class doesn't exist. Use `.htmlvideoplayer` lowercase or just `:not(.arrflix-video-active)`. |
|
||||||
|
| `<video>...</video>` literal in `branding.xml` `CustomCss` comment | XML parser chokes, branding silently fails. Escape with `<` `>`. |
|
||||||
|
| Reorder L1 / L2 blocks in `inject-middle-theme.py` | Equal specificity → source order decides. L2 must come after L1. |
|
||||||
|
| Add `!important` to "fix" a cascade conflict without understanding specificity | `!important` doesn't change specificity ordering among `!important` rules. Increase specificity instead. |
|
||||||
|
| Hot-patch the deployed overlay (write directly to `/opt/docker/jellyfin/web-overrides/index.html`) | Drift between repo and prod. INC1 root cause per doc 26. Always edit `bin/inject-middle-theme.py`, regen, scp. |
|
||||||
|
| `cp` to swap the prod overlay without `docker restart jellyfin` | bind-mount inode swap doesn't refresh container view. The new file lives at a different inode; container still serves the old one. Always restart prod after `cp`. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CI gates that would have caught past bugs (still TODO)
|
||||||
|
|
||||||
|
| Gate | Catches | Status |
|
||||||
|
|------|---------|--------|
|
||||||
|
| `xmllint --noout branding.xml` on every push | The v6-stable XML-parse-silent-failure (doc 30 lesson) | NOT IMPLEMENTED |
|
||||||
|
| `darkPct` assertion in `bin/headless-test-v2.py` | Every black-screen-over-video incident (5 of them) | NOT IMPLEMENTED (per doc 30 + agent 4 history) |
|
||||||
|
| Forgejo CI runner triggering headless test on push to `main` | Both above, automatically | NOT IMPLEMENTED |
|
||||||
|
| Headless test asserts `.osdControls` is visible during playback | The image-12 z-index-too-high regression | NOT IMPLEMENTED |
|
||||||
|
|
||||||
|
If you implement any of these, mark it here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick verify after any theme change
|
||||||
|
|
||||||
|
Run this 4-step manual smoke on dev before pushing to prod:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Login still works (theme disabled pre-auth, no Cineplex breakage)
|
||||||
|
curl -s https://dev.arrflix.s8n.ru/web/index.html | grep -c ARRFLIX-MIDDLE-THEME-BEGIN # = 1
|
||||||
|
docker exec jellyfin-dev curl -s http://127.0.0.1:8096/Branding/Css.css | wc -c # ~36000
|
||||||
|
|
||||||
|
# 2. Home page renders pure black (no #101010 stripe at bottom)
|
||||||
|
# 3. Play any video → frames visible (no black/white overlay) → OSD scrubber + buttons clickable
|
||||||
|
# 4. Hard-refresh and repeat 3 to catch first-paint regressions
|
||||||
|
|
||||||
|
# Headless equivalent (until darkPct lands in v2):
|
||||||
|
docker run --rm --userns=host --network container:jellyfin-dev \
|
||||||
|
mcr.microsoft.com/playwright/python:v1.49.0-noble \
|
||||||
|
bash -c 'pip install --quiet playwright==1.49.0 && python -c "..."'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The theme is fragile in one specific way: any new opaque background rule on a wrapper class can hide the video. The two-class system (`arrflix-themed` + `arrflix-video-active`) plus the L1/L2 paired rules is the structural defence. The CI gates above would be the automated safety net. Until those exist, this checklist + the layer model is the best you have.
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue