/* ===========================================================================
   player.css - the watch player and everything mounted inside it.

   Loaded ONLY on /videos/{id}, by VideoController::playerAssets(). Holds the
   stage, the control bar, the ±5 seek cues, the play/pause bezel, the brand
   mark, the ad slots (pre-roll, skip, unmute cue, overlay banner), the paid
   gate, the quality and speed menus, the "most replayed" heat graph and the
   processing placeholder - i.e. the component, not the page. Watch-PAGE
   furniture (the action row, the like ratio, the playlist panel) lives in
   watch.css.

   THE MARKUP IS NOT BUILT HERE AND NOT BUILT IN JS: the chrome is one cloned
   <template>, templates/partials/videos/player-chrome.php. The class strings
   are the contract between that partial, this sheet and the four player
   scripts; bin/check-player-css.php holds all of them to it.

   THE STATE VOCABULARY is seven classes the scripts put on .player-wrap:
   `started` (a first play has happened) · `playing` · `paused` · `idle` (no
   recent input) · `buffering` · `scrubbing` · `errored`. Visibility is decided
   by CSS reading those, never by JS writing styles — so there are no timers
   here to fall out of step with the media element.

   CASCADE: every first-party sheet is UNLAYERED and beats the framework at any
   specificity, so selectors are written plainly (`.ad-skip`, never
   `.player-wrap .ad-skip`): a prefix would be arithmetic with nothing left to
   compute. ⚠ Layers invert `!important` — a framework `!important` outranks an
   unlayered one — so an override must avoid the property rather than fight it,
   and this sheet carries zero (the gate enforces that).

   ⚠ EM BASE: `.player-wrap` sets font-size:10px and the whole sheet's em
   arithmetic resolves against it. Changing it moves the big play button, the
   cues and the bar together.

   @see data/docs/architecture/media/player.md
   =========================================================================== */

/* The STAGE. Everything the player draws lives inside this box: the <video>,
   the cloned chrome, the ad plane and the paid gate — so it is also what goes
   fullscreen, and what the CSS-expanded and forced-landscape states transform.
   `container-type` makes the control bar's responsive tier follow the PLAYER's
   width rather than the page's (see the @container rule at the foot of this
   sheet). */
.player-wrap {
    position: relative;
    background: #000;
    overflow: hidden;
    aspect-ratio: 16 / 9;
    container-type: inline-size;
    container-name: player;
    /* The em base the whole sheet is written against. Drop it and every
       em-sized box collapses together — --big-play-fs 4em would render a
       48px button instead of the 120x65 the design calls for. */
    font-size: 10px;
    /* Where a scripted scrollIntoView (the scenes gallery's seek) parks the
       stage: just below the sticky header, whose live height app.js measures. */
    scroll-margin-top: calc(var(--header-h-live, var(--header-h)) + 0.5rem);
}

/* The stage takes focus on every press (player.js) so the keyboard shortcuts
   reach it — a focus ring around the whole picture is noise, not a cue. The
   controls inside keep their own :focus-visible rings. */
.player-wrap:focus {
    outline: none;
}

/* ----- Ads -----
   The pre-roll cover is mounted INSIDE the stage by player-ads.js (like .seek-cue
   and .ad-unmute), so it follows the player into fullscreen — the guard that
   blocks playback and the button that lifts it must never be separated. Its
   z-index 30 tops the ad band: above the overlay banner (20) and the in-player
   cues (10-19). Every ad element mounts inside the player, so the whole band
   follows it into fullscreen — including the banner, whose impression clock
   only means anything while the creative is actually on screen. */
.ad-preroll {
    position: absolute;
    inset: 0;
    z-index: 30;
    background: #000;
    display: flex;
    align-items: center;
    justify-content: center;
}

.ad-preroll .ad-media {
    max-width: 100%;
    max-height: 100%;
    cursor: pointer;
    object-fit: contain;
}

.ad-label {
    position: absolute;
    top: 10px;
    inset-inline-start: 10px;
    z-index: 2;
    background: rgba(0, 0, 0, .7);
    color: #fff;
    font-size: .72rem;
    padding: 3px 8px;
    border-radius: 4px;
}

/* Pre-roll dismiss button, mounted inside the stage by player-ads.js.
   ⚠ rem, NEVER em: the stage sets font-size:10px as the sheet's em base (see
   the header), so an em here would size this label against that instead of
   the document — a 1.5em button rendering at 15px, which reads as a styling
   near-miss rather than as a broken rule. */
.ad-skip {
    position: absolute;
    bottom: 18px;
    inset-inline-end: 18px;
    z-index: 2;
    background: rgba(0, 0, 0, .82);
    color: #fff;
    border: 1px solid rgba(255, 255, 255, .4);
    padding: 12px 22px;
    border-radius: 6px;
    font-family: var(--font);
    /* vw-scaled so it cannot swamp a phone-width player, where the creative
       box is only ~200px tall at 360px wide. */
    font-size: clamp(1.3rem, 2.8vw, 1.5rem);
    line-height: 1.2;
    cursor: pointer;
}

.ad-skip:disabled {
    opacity: .7;
    cursor: default;
}

/* A video creative's own sound toggle (it plays muted — the one autoplay every
   browser allows). Inside the pre-roll box, so positioned against it: the
   bottom inline-START corner, opposite Skip and below the "Ad" label, which
   holds the top one. A tap here is a user gesture, which is also what lets the
   main video start with sound when the ad ends. Same 44px target as .ad-unmute. */
.ad-sound {
    position: absolute;
    bottom: 18px;
    inset-inline-start: 18px;
    z-index: 2;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 44px;
    height: 44px;
    padding: 0;
    background: rgba(0, 0, 0, .8);
    color: #fff;
    border: 1px solid rgba(255, 255, 255, .4);
    border-radius: 50%;
    font-size: 1.25rem;
    line-height: 1;
    cursor: pointer;
}

/* Tap-for-sound, left behind when the video could start after a pre-roll only
   MUTED (the browser refused an unmuted start), so this is the way back. Mounted INSIDE the stage by player-ads.js, so its z-index sits in the
   10-19 band shared with .seek-cue — above the video and the control bar, below
   the overlay banner (20) and the pre-roll cover (30). Pinned to the TOP so it
   never lands on the control bar, and inset-inline so RTL places it correctly. */
.ad-unmute {
    position: absolute;
    top: 12px;
    inset-inline-start: 12px;
    z-index: 16;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 44px;
    height: 44px;
    padding: 0;
    background: rgba(0, 0, 0, .8);
    color: #fff;
    border: 1px solid rgba(255, 255, 255, .4);
    border-radius: 50%;
    font-size: 1.25rem;
    line-height: 1;
    cursor: pointer;
}

/* The paid-video gate (partials/videos/paid-gate, mounted by
   player-paid.js): covers the whole picture, above the control bar, while the next
   minute needs a sign-in, a consent click or more credit. A positioned overlay
   inside the player — no Bootstrap equivalent. */
.paid-gate {
    position: absolute;
    inset: 0;
    z-index: 30;
    background: rgba(0, 0, 0, .78);
}

.paid-gate-box {
    max-width: 22rem;
}

/* While the gate is up its buttons are the only way on, so both play affordances
   — the poster's big play button and the paused-picture play cue, separate
   layers the gate does not cover — are withdrawn. The gate pauses the video, so
   without the second line the paused cue paints over the gate's own message. */
.paid-gated .player-bigplay,
.paid-gated .play-cue-play {
    display: none;
}

.ad-overlay {
    position: absolute;
    bottom: 54px;
    inset-inline: 0;
    z-index: 20;
    display: flex;
    justify-content: center;
    align-items: flex-end;
    pointer-events: none;
}

.ad-overlay a {
    pointer-events: auto;
    max-width: 90%;
}

.ad-overlay img {
    max-height: 90px;
    border-radius: 6px;
    box-shadow: 0 4px 16px rgba(0, 0, 0, .6);
}

.ad-overlay-close {
    pointer-events: auto;
    background: rgba(0, 0, 0, .8);
    color: #fff;
    border: 0;
    width: 24px;
    height: 24px;
    border-radius: 50%;
    cursor: pointer;
    margin-inline-start: 6px;
    align-self: flex-start;
}

/* ----- ±5 button press feedback -----
   The circular-arrow glyph spins one full turn on a press (player-chrome.js
   spinSkipIcon). The button needs feedback of its own: without it a press
   reads as nothing having happened — the seek is real, but the only evidence
   is a seekbar thumb that may have moved a few pixels. The glyph already
   carries the direction (an arrow curving back or forward), so ONE keyframe
   set serves both buttons and still reads correctly either way.

   The rule itself lives with the other .player-btn states below; this is the
   keyframe it animates. */
@keyframes seek-spin {
    from { transform: rotate(0deg); }
    to   { transform: rotate(360deg); }
}

/* ----- Seek cue -----
   The ±5 flash EVERY seek surface paints (player-chrome.js flashSeek) — the
   buttons, the arrow keys and the double-tap zones alike. In-video feedback is
   what a viewer watching the picture actually sees; the button's own glyph spin
   is the second, local half of the same confirmation, not a replacement for it.
   Custom CSS for
   the same reason as the rest of the player skin: Bootstrap has no positioned
   in-media overlay. Borrows the .ad-label look above — dark pill, same radius —
   so the player's two transient labels read as one family.

   Mounted INSIDE the stage, so it still paints in
   fullscreen, where only the player element and its descendants render. z-index
   sits in the 10-19 band: above the video and the control bar, below the
   overlay banner (20) and the pre-roll cover (30) — an ad must never be
   obscured. pointer-events:none so it can never swallow the next tap, and
   sides are PHYSICAL (left = backward) because the video timeline is LTR in
   both directions, like the frame strip's dir="ltr" island. */
.seek-cue {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    z-index: 15;
    display: flex;
    align-items: center;
    gap: 4px;
    /* LTR island, like the control bar (the partial stamps dir="ltr" on it), the
       frame strip and the heat graph: this is a TIMELINE affordance, so the
       glyph-then-interval order must not flip with the page's direction. */
    direction: ltr;
    padding: 10px 14px;
    border-radius: 999px;
    /* .85, not the .ad-label pill's .7: these flash over a moving frame for a
       third of a second, and a lighter surface loses the glyph on a bright
       scene exactly when the viewer looks for it (owner's call, 2026-09-02).
       Shared with .play-cue below — the two cues are one family. */
    background: rgba(0, 0, 0, .85);
    color: #fff;
    font-family: var(--font);
    font-size: .95rem;
    font-weight: 600;
    pointer-events: none;
    opacity: 0;
    transition: opacity .12s ease-out;
}

.seek-cue.is-on {
    opacity: 1;
}

.seek-cue-back {
    left: 12%;
}

.seek-cue-fwd {
    right: 12%;
}

/* The play/pause bezel (player-chrome.js flashBezel) — the ±5 cue's centred
   sibling: same dark surface, same z-index band, same pointer-events:none,
   mounted the same way. A CIRCLE rather than a pill because it carries one
   glyph and no number, and it sits dead-centre because that is where the tap
   that toggled playback landed.

   ⚠ The reveal is an ANIMATION, not the pills' transition — the glyph blooms
   outward while it fades, and a transition cannot run two properties to an end
   state and then vanish. Its 0.5s is MIRRORED by PLAY_CUE_MS in player.js,
   which only decides when `is-on` comes off; the keyframe is the whole visual.
   Restart mid-flash works because flashCue reflows between remove and add.

   `display: grid` + place-items centres the icon glyph optically: Bootstrap
   Icons' play/pause SVGs carry their own internal margin, and a flex baseline
   would sit them a pixel low. The size is rem — the player root is 10px, the
   .ad-skip trap. */
.play-cue {
    position: absolute;
    top: 50%;
    left: 50%;
    z-index: 15;
    display: grid;
    place-items: center;
    width: 4.5rem;
    height: 4.5rem;
    border-radius: 50%;
    background: rgba(0, 0, 0, .85);   /* matches .seek-cue — see its note */
    color: #fff;
    font-size: 2.25rem;
    pointer-events: none;
    opacity: 0;
    transform: translate(-50%, -50%) scale(1);
}

.play-cue.is-on {
    animation: play-cue-flash .5s ease-out forwards;
}

/* ----- The big play button's size, in ONE place -----
   Both faces of that button — `.player-bigplay` (the poster) and
   `.play-cue-play` (the paused picture) — size themselves from
   this single token, so they can never be bumped apart. Everything else about
   the box is em arithmetic hanging off it: the vendor writes width `3em`,
   height `1.63332em`, radius `.3em` and its centring margins in em, and both
   rules restate exactly those numbers. Change the token, both buttons move,
   their glyphs with them.

   It is a FONT-SIZE, not a width, because every dimension of the box is em
   arithmetic hanging off it: width 3em, height 1.63332em, radius .3em. `em`
   resolves against the stage's 10px (see the `.ad-skip` note), so 4em is a
   measured 120x65px button at a 12px radius.

   The phone step is not a step BACK: 3.4em still beats the vendor's 3em, it
   just refuses to eat a third of the picture. Below `sm` the player IS the
   viewport width, so a fixed-px button that reads as confident on a desktop
   reads as a takeover on a handset — measured in a 386px viewport, 4em spans
   34% of the player against 3.4em's 29%. */
.player-wrap {
    --big-play-fs: 4em;
}

@media (max-width: 575.98px) {
    .player-wrap {
        --big-play-fs: 3.4em;
    }
}

/* The PLAY bezel wears the face of the big play button, because it is the
   same affordance: the viewer pressed a crimson button on the poster,
   and the button they find when they pause must be that button rather than a
   second, differently-shaped one. (The PAUSE bezel keeps the dark circle — it
   is a transient confirmation in the .seek-cue HUD family and never sits still
   long enough to read as a button.)

   The SHAPE is `.player-bigplay`'s arithmetic restated in the same unit, so
   the two boxes cannot drift: width/height/radius resolve against THIS
   element's own font-size exactly as they do there, and the font-size itself
   is `--big-play-fs`, the one number both faces read. That is
   also why the em here is not the `.ad-skip` trap the rem note above warns
   about — the font-size is re-established on the same element.

   The crimson is the SAME `var(--accent)` `.player-bigplay` paints with, so
   a rebrand still moves both together.

   ⚠ It is DELIBERATELY larger than its `.play-cue` sibling's 4.5rem circle:
   they are different objects. This one is a button a viewer looks for and
   aims at; the pause bezel is a half-second HUD flash nobody aims at. */
.play-cue-play {
    width: 3em;
    height: 1.63332em;
    border-radius: .3em;
    background: var(--accent);
    font-size: var(--big-play-fs);
}

/* The GLYPH carries its own size, because the box numbers above are the
   vendor's em arithmetic — raising the element's font-size to enlarge the
   triangle would grow the button along with it. `.player-bigplay .bi` carries
   the SAME 1.45em (same glyph, same box), so the poster and paused triangles
   match. Re-tune them together or they drift apart. */
.play-cue-play > .bi {
    font-size: 1.45em;
    line-height: 1;
}

/* The twin of `.player-wrap:hover .player-bigplay` below — same button, same
   lift. The bezel is pointer-events:none, so the hover that matters is the
   PLAYER's, which is where the pointer is when the picture is what you click
   to resume. */
.player-wrap:hover .play-cue-play {
    background: var(--accent-hover);
}

/* The play bezel's STEADY state: lit for as long as the video sits paused
   after a real start (`started` keeps it off the poster, which has the big
   play button itself). Read from the stage's state classes, the
   brand-mark rule — no JS state to desync.

   ⚠ The .5s DELAY equals the pause flash's length on purpose: a pause paints
   the pause bezel blooming out of this same spot, and the play glyph fading in
   underneath it during those frames reads as two icons fighting. So it waits
   for the flash to finish and then fades in. The reverse needs no delay —
   pressing play adds `is-on`, whose animation outranks this opacity and blooms
   the glyph out from the steady position it is already holding, and the class
   below is gone by the time `is-on` comes off. */
.player-wrap.started.paused .play-cue-play {
    opacity: 1;
    transition: opacity .15s ease-out .5s;
}

@keyframes play-cue-flash {
    from {
        opacity: 1;
        transform: translate(-50%, -50%) scale(1);
    }

    to {
        opacity: 0;
        transform: translate(-50%, -50%) scale(1.6);
    }
}

@media (prefers-reduced-motion: reduce) {
    .seek-cue {
        transition: none;
    }

    /* Keep the confirmation, drop the growth: a plain fade in place. */
    @keyframes play-cue-flash {
        from {
            opacity: 1;
            transform: translate(-50%, -50%);
        }

        to {
            opacity: 0;
            transform: translate(-50%, -50%);
        }
    }

    /* The spin is decoration, but it is also the button's ONLY confirmation —
       so drop the rotation and keep a flat opacity pulse rather than removing
       the feedback altogether. */
    .player-btn.is-seeking .bi {
        animation: seek-pulse .18s ease-out;
    }

    @keyframes seek-pulse {
        from { opacity: .35; }
        to   { opacity: 1; }
    }
}

/* ----- Brand mark (partials/videos/player-chrome.php) -----
   The site's own name, in the active locale, inside the player. It exists to
   teach the HEBREW spelling (בייבס) of a brand whose only other visual mark is
   the Latin BABES wordmark in the header — so the string comes from
   site_name() through the `site-name` meta, never from an icon or the
   brand-sprite outlines, which carry the Latin form only.

   TEXT, not a background pill: a dark rounded label is exactly what .ad-label
   above looks like, and a viewer who reads this as advertising tunes it out —
   which is the one outcome the feature cannot afford. The shadow is what keeps
   it legible over a bright frame instead.

   Both marks live INSIDE the stage (like .seek-cue and .ad-unmute), so
   they follow the player into fullscreen — where the header is gone and this is
   the only brand on screen. z-index 8 keeps the whole family BELOW the ad band
   (10-19 cues, 20 overlay banner, 30 pre-roll cover): a house mark must never
   sit on paid inventory. The pre-roll needs no rule of its own — .ad-preroll is
   inset:0 at z-index 30, so it covers this outright.

   pointer-events:none is load-bearing, not tidiness: the player surface carries
   the double-tap seek zones (GESTURE_ZONE in player.js), and a mark that
   swallowed a tap would break seeking on exactly the devices that use it most. */
.player-brand {
    position: absolute;
    top: 10px;
    /* Logical, so it lands physically top-LEFT on the RTL site and top-RIGHT on
       the five LTR locales. The opposite corner is deliberate: .ad-label and
       .ad-unmute are both inset-inline-start, so this one is always free. */
    inset-inline-end: 10px;
    z-index: 8;
    /* rem, never em: the player root is 10px (the trap .ad-skip documents
       above), so an em here would render this at a fraction of its size. */
    font-family: var(--font);
    font-size: clamp(1.5rem, 3vw, 1.9rem);
    font-weight: 600;
    line-height: 1;
    color: #fff;
    text-shadow: 0 1px 4px rgba(0, 0, 0, .9);
    opacity: .5;
    pointer-events: none;
    transition: opacity .25s ease-out, font-size .25s ease-out;
    /* Two stacked lines — the name, then .player-brand-host under it — centred on
       each other. The bare name text is an anonymous flex item. */
    display: flex;
    flex-direction: column;
    align-items: center;
}

/* The site's address under the name (babes.co.il), so the URL is learned on the
   most-watched surface. em — NOT rem — on purpose: this is relative to
   .player-brand (itself rem-sized, so the 10px player-root trap does not reach
   it), which makes the line follow the paused-state font-size promotion above
   with no rule of its own. Opacity is inherited from the parent's ranking.
   --brand-host-fit is written by player-chrome.js once the webfont is in:
   the ratio that makes this line exactly as wide as the name above it. Both
   lines scale by the same em, so one measurement holds across every state and
   viewport; unset (no JS measurement yet), the line falls back to plain .6em. */
.player-brand-host {
    margin-top: .2em;
    font-size: calc(.6em * var(--brand-host-fit, 1));
    font-weight: 500;
    letter-spacing: .02em;
}

/* ----- Origin label (partials/videos/player-chrome.php) -----
   The video card's Israeli label, mirrored into the corner the brand leaves
   free: inset-inline-START, so top-RIGHT on the RTL site. Drawn exactly as
   .player-brand is — bare text on its shadow, no badge plate — with the same
   opacity ranking (the idle/paused rules below), so the two corners read as
   one family. Same z-index and the same load-bearing pointer-events:none (the
   seek zones). rem, never em (the 10px player-root trap).

   ⚠ That corner belongs to the ADS while one runs: .ad-label rides the
   pre-roll and .ad-unmute (a real, tappable control) is left after it. Both are
   appended to the stage by player-ads.js and REMOVED when done, so :has() ties
   the hide to their presence with no JS state to desync. The overlay banner
   sits at the bottom and needs no rule. */
.player-origin {
    position: absolute;
    top: 10px;
    inset-inline-start: 10px;
    z-index: 8;
    display: flex;
    align-items: center;
    gap: .35em;
    font-family: var(--font);
    font-size: clamp(1.1rem, 2.2vw, 1.4rem);
    font-weight: 600;
    line-height: 1;
    color: #fff;
    text-shadow: 0 1px 4px rgba(0, 0, 0, .9);
    opacity: .5;
    pointer-events: none;
    transition: opacity .25s ease-out;
}

/* text-shadow never reaches the flag (a flag-icons BACKGROUND), so it carries
   the same shadow as a filter to sit on the frame like the letters do. */
.player-origin .fi {
    filter: drop-shadow(0 1px 3px rgba(0, 0, 0, .9));
}

.player-wrap:has(.ad-preroll, .ad-unmute) .player-origin {
    display: none;
}

/* Lifted once the controls auto-hide: the mark is quietest while the screen is
   busy with chrome, and clearest on an uninterrupted frame. Scoped to
   `playing` on purpose — that is what makes the paused rule below
   unconditional, since a player is never both playing and paused.
   ⚠ The three opacities are a RANKING, not three independent numbers: playing
   must stay legible without ever competing with the paused state, so raising
   one means checking it still sits below the next. */
.player-wrap.playing.idle .player-brand,
.player-wrap.playing.idle .player-origin {
    opacity: .75;
}

/* Paused, after a real start (`started` keeps the poster on the base
   treatment). Attention is fully available here, so the mark is promoted —
   but by FONT-SIZE, never transform:scale(). A scale would need a
   transform-origin naming a physical corner, which cannot mirror with the
   logical inset above; growing the type instead lets the box expand inward
   from whichever corner it is anchored to, correctly in both directions.

   ⚠ SIZE is what separates this state from playing, not opacity. The two sit
   only .15 apart, which is nearly indistinguishable on its own — so a future
   edit that drops the font-size bump "because the opacity already says it"
   would silently collapse the two states into one. */
.player-wrap.started.paused .player-brand {
    opacity: .9;
    font-size: clamp(2.1rem, 4.2vw, 2.6rem);
}

/* The origin label follows the brand's opacity ranking but keeps its size:
   the promotion is the brand's moment, not a second label growing beside it. */
.player-wrap.started.paused .player-origin {
    opacity: .9;
}

/* The buffering half: the brand under the spinner, which is left in place.

   ⚠ THE 0.3s DELAY IS THE WHOLE RULE. `buffering` is set by every seek this
   player offers — the ±5 buttons, the arrow keys, the double-tap zones and the
   frame strip — so an undelayed mark strobes on every tap and reads as broken.
   The spinner's own reveal carries the identical delay, and matching it exactly
   is what makes the two appear as ONE event; 100ms off and they read as two.

   The KEYFRAME is this sheet's own, though the
   shape is identical: reusing a vendor keyframe NAME would couple us to a
   private identifier that a bundle upgrade may rename, and the failure would be
   silent — the mark simply stops appearing. The timing is the contract worth
   copying; the name is not. */
.player-brand-load {
    position: absolute;
    top: 50%;
    left: 50%;
    /* Centred horizontally, then pushed clear of the spinner's lower edge (5em
       tall against the player root's 10px, so ~25px of half-height). Physical
       centring, not logical: the box is symmetric, so there is nothing to
       mirror. */
    transform: translate(-50%, 2.4rem);
    z-index: 7;
    font-family: var(--font);
    font-size: clamp(.85rem, 1.8vw, 1.05rem);
    font-weight: 600;
    line-height: 1;
    color: #fff;
    text-shadow: 0 1px 4px rgba(0, 0, 0, .9);
    opacity: .75;
    pointer-events: none;
    visibility: hidden;
}

.player-wrap.buffering .player-brand-load,
.player-wrap.buffering .player-brand-load {
    animation: player-brand-show 0s linear .3s forwards;
}

@keyframes player-brand-show {
    to { visibility: visible; }
}

/* The spinner is withdrawn on the error path for the same reason this is.
   Nothing clears `buffering` when playback fails, so without this the
   brand would sit centred and alone on a broken player, with the spinner it is
   supposed to caption already gone. */
.player-wrap.errored .player-brand-load {
    display: none;
}

/* ----- Slim progress line (player-chrome.js paints its fill) -----
   A 3px bar along the bottom edge that stands in for the seekbar while the
   control bar is auto-hidden. Inside the stage, so it follows the
   player into fullscreen; pointer-events:none for the same reason as
   .player-brand — the double-tap seek zones sit under it.

   REVEAL IS THE BAR'S OWN STATE. The control bar fades out on
   `.started.idle.playing` over 1s and back in over
   0.1s; this rule reads the SAME selector with the SAME two durations, which is
   what makes the line and the bar read as one cross-fade rather than two
   events. Change either timing there and this one must follow.

   Physical left/right, NEVER inset-inline: the seekbar is an LTR island (the
   stamps dir="ltr" on the control bar) and the fill grows from the LEFT on the
   RTL site too, so a logical origin here would run the line the opposite way
   from the bar it stands in for. The fill is scaled, not widthed — player.js
   writes a scaleX transform per timeupdate so each tick is paint-only. */
.player-progress-line {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    height: 3px;
    z-index: 8;
    background: rgba(255, 255, 255, .25);
    pointer-events: none;
    opacity: 0;
    transition: opacity .1s;
}

.player-wrap.started.idle.playing .player-progress-line {
    opacity: 1;
    transition: opacity 1s;
}

/* Gone in the one state that has no position to stand in for. */
.player-wrap.errored .player-progress-line {
    display: none;
}

.player-progress-line-fill {
    height: 100%;
    width: 100%;
    background: var(--accent);
    transform: scaleX(0);
    transform-origin: left center;
}

@media (prefers-reduced-motion: reduce) {
    /* The delay is not decoration — it is what stops the mark strobing on every
       seek — so only the easing goes. */
    .player-brand,
    .player-progress-line {
        transition: none;
    }
}

/* ----- The chrome layer -----
   One cloned subtree (partials/videos/player-chrome.php) covering the stage.

   ⚠ The layer takes NO pointer events, and only the three INTERACTIVE children
   opt back in BY NAME. A blanket `.player-chrome > * { pointer-events: auto }`
   is the trap here: it ties `.play-cue`/`.seek-cue`/`.player-spinner` on
   specificity and, sitting later in the sheet, wins — so a decorative overlay
   that is invisible most of the time silently swallows the click that pauses
   the video, and the half-second pause bezel sits on exactly the spot the
   viewer taps. Measured: the bezel intercepted every press on the big play
   button. Anything new in here that needs a press is added to this list. */
.player-chrome {
    position: absolute;
    inset: 0;
    pointer-events: none;
}

.player-zones,
.player-bar {
    pointer-events: auto;
}
/* The big play button is deliberately NOT in that list: it opts in and out with
   its own visibility below, so it takes a press only while it is on screen. */

/* ----- Double-tap seek zones -----
   Real elements, not a clientX measurement against the stage's box. In
   forced-landscape fullscreen the stage is rotated 90deg, where
   getBoundingClientRect() reports the axis-aligned SCREEN box (measured:
   390x844 while the layout box is 844x390) and any ratio computed from it
   reads the wrong axis. Hit testing follows a transform for free, so
   `closest('[data-zone]')` is correct at every rotation — and a press on the
   bar matches no zone at all, which is also what keeps the bar's own controls
   from doubling as a seek. */
.player-zones {
    position: absolute;
    inset: 0;
    display: grid;
    grid-template-columns: 3fr 4fr 3fr;   /* GESTURE_ZONE = .3 either side */
    z-index: 5;
    /* ⚠ AN LTR ISLAND, like the control bar and the frame strip. These are
       TIMELINE affordances — back is physically left and forward physically
       right in every locale, because that is the direction the seekbar beside
       them runs. Grid lays its columns out in the writing direction, so on
       this RTL site the three cells place right-to-left without this and the
       zones mirror: measured, a double-tap on the right seeked -5 and on the
       left +5, with the cue animation confirming the wrong direction. */
    direction: ltr;
}

.player-zone {
    /* No paint of its own — these exist purely to be hit. */
    background: transparent;
}

/* ----- The big play button -----
   Colors stay on the vars — crimson from var(--accent) — so the player still
   recolors from the one canonical place. The BOX is the arithmetic the shipped
   player measured at 120x65 with a 12px radius, restated here against the
   stage's own 10px root (see --big-play-fs). */
.player-bigplay {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    z-index: 12;
    display: grid;
    place-items: center;
    width: 3em;
    height: 1.63332em;
    border-radius: .3em;
    background: var(--accent);
    border: none;
    color: #fff;
    cursor: pointer;
    font-size: var(--big-play-fs);
    opacity: 0;
    pointer-events: none;
    transition: opacity .2s ease-out;
}

/* Visible whenever there is nothing playing — the poster before the first play
   and every pause after it. Two conditions, one rule: the viewer is looking at
   a still frame either way and needs the same affordance. */
.player-wrap:not(.playing) .player-bigplay {
    opacity: 1;
    pointer-events: auto;
}

/* An error replaces the picture's affordance with the message. */
.player-wrap.errored .player-bigplay {
    opacity: 0;
    pointer-events: none;
}

/* The glyph keeps its share of a button of any size: an em against the
   button's own font-size, which IS --big-play-fs. A fixed px glyph would read
   as a triangle adrift in crimson at 4em and crowd the box at 3.4em.
   1.45em is `.play-cue-play > .bi`'s number on purpose — the same bi-play-fill
   in the same box, so the poster's triangle and the paused one match (the
   owner asked for a big triangle in an unchanged button, 2026-10-13). */
.player-bigplay .bi {
    font-size: 1.45em;
    line-height: 1;
}

.player-wrap:hover .player-bigplay,
.player-bigplay:focus-visible {
    background: var(--accent-hover);
}

/* ----- The control bar -----
   An LTR island on this RTL page (the partial stamps dir="ltr"): a timeline
   runs left-to-right everywhere, and so do the transport controls that read
   against it. Fades with the idle state, on the same timing as the progress
   line that stands in for it. */
.player-bar {
    position: absolute;
    inset-inline: 0;
    bottom: 0;
    z-index: 10;
    padding: 0 10px 6px;
    background: linear-gradient(to top, rgba(var(--bs-body-bg-rgb), .92), rgba(var(--bs-body-bg-rgb), 0));
    opacity: 1;
    transition: opacity .3s ease-out;
}

/* Hidden while playing with no recent input, and whenever an error has taken
   the picture. The progress line takes over in the first case — see its rule. */
.player-wrap.playing.idle .player-bar,
.player-wrap.errored .player-bar {
    opacity: 0;
    pointer-events: none;
}

.player-row {
    display: flex;
    align-items: center;
    gap: 2px;
    height: var(--bar-h, 48px);
}

.player-spacer {
    flex: 1 1 auto;
}

/* ----- Bar controls -----
   Not ui_button(): these are icon affordances inside a media surface, not the
   site's button family, and `.btn`'s padding/radius/focus ring all fight a
   48px control row. bin/check-buttons.php inspects the standalone `btn` token
   only, so `player-btn` is deliberately a different name rather than a variant
   of it. */
.player-btn {
    display: grid;
    place-items: center;
    flex: 0 0 auto;
    width: 44px;
    height: 44px;
    padding: 0;
    border: 0;
    border-radius: 6px;
    background: transparent;
    color: var(--ink, #f2f2f2);
    font-size: 1.4rem;
    line-height: 1;
    cursor: pointer;
}

.player-btn:hover {
    color: #fff;
    background: rgba(255, 255, 255, .12);
}

.player-btn:focus-visible {
    outline: 2px solid #fff;
    outline-offset: -2px;
}

/* The two menu buttons show a LABEL (1.5x, 720p) rather than a glyph, so they
   size to their text instead of the 44px icon box. */
.player-btn-text {
    width: auto;
    min-width: 44px;
    padding: 0 8px;
    /* Longhands: `font: … inherit` is invalid (inherit cannot stand in for
       the family inside the shorthand), so the whole declaration drops. */
    font-size: 14px;
    font-weight: 700;
    line-height: 1;
}

/* ----- ±5 press feedback ----- */
.player-btn.is-seeking .bi {
    display: inline-block;
    animation: seek-spin .26s ease-out;
}

/* ----- Time readout -----
   REMAINING time, which is what the shipped player shows. Tabular figures so
   the bar does not twitch as the digits change. */
.player-time {
    flex: 0 0 auto;
    padding: 0 6px;
    color: var(--dim, rgba(255, 255, 255, .72));
    font-size: .8rem;
    font-variant-numeric: tabular-nums;
}

/* ----- Seek bar ----- */
.player-seek {
    position: relative;
    height: 18px;
    display: flex;
    align-items: center;
    cursor: pointer;
    touch-action: none;   /* the drag is ours; never let it scroll the page */
}

.player-track {
    position: relative;
    width: 100%;
    height: 4px;
    border-radius: 2px;
    background: rgba(255, 255, 255, .25);
}

.player-buffered,
.player-played {
    position: absolute;
    inset-block: 0;
    left: 0;
    width: 0;
    border-radius: 2px;
}

.player-buffered {
    background: rgba(255, 255, 255, .35);
}

.player-played {
    background: var(--accent);
}

.player-knob {
    position: absolute;
    top: 50%;
    left: 0;
    width: 12px;
    height: 12px;
    margin-left: -6px;
    border-radius: 50%;
    background: var(--accent);
    transform: translateY(-50%) scale(0);
    transition: transform .12s ease-out;
}

.player-seek:hover .player-knob,
.player-wrap.scrubbing .player-knob {
    transform: translateY(-50%) scale(1);
}

/* ----- Scrub preview -----
   SpriteCues crops the sheet into this box; the caption carries the moment. */
.player-preview {
    position: absolute;
    bottom: 22px;
    left: 0;
    width: 160px;
    aspect-ratio: 16 / 9;
    border-radius: 6px;
    border: 1px solid rgba(255, 255, 255, .25);
    background: #000 center / cover no-repeat;
    transform: translateX(-50%);
    pointer-events: none;
    opacity: 0;
    transition: opacity .12s ease-out;
}

.player-seek:hover .player-preview,
.player-wrap.scrubbing .player-preview {
    opacity: 1;
}

.player-preview-time {
    position: absolute;
    bottom: 4px;
    left: 50%;
    transform: translateX(-50%);
    padding: 1px 6px;
    border-radius: 4px;
    background: rgba(0, 0, 0, .85);
    color: #fff;
    font-size: .7rem;
    font-variant-numeric: tabular-nums;
}

/* ----- Volume ----- */
.player-volwrap {
    display: flex;
    align-items: center;
    flex: 0 0 auto;
}

.player-vol {
    width: 0;
    overflow: hidden;
    transition: width .18s ease-out;
}

.player-volwrap:hover .player-vol,
.player-volwrap:focus-within .player-vol {
    width: 70px;
}

.player-vol input {
    width: 70px;
    accent-color: var(--accent);
}

/* ----- Menus (speed, quality) ----- */
.player-menu {
    position: relative;
    flex: 0 0 auto;
}

.player-list {
    position: absolute;
    bottom: 100%;
    inset-inline-end: 0;
    min-width: 92px;
    margin-bottom: 6px;
    padding: 4px;
    border-radius: 8px;
    background: rgba(0, 0, 0, .92);
    display: none;
}

.player-list.open {
    display: block;
}

/* An open menu must paint over the big play button (12): before the first
   play the bar is already on screen, and the speed list rises into the
   stage's centre on a short phone player. 12 rather than higher, winning the
   tie by DOM order (the bar follows the button in player-chrome.php), so the
   error box (13) and the ad band (15+) still cover it. */
.player-bar:has(.player-list.open) {
    z-index: 12;
}

/* …and over the PAUSED picture's play cue, which sits at 15 (the bezel
   family) and is lit whenever a started video is paused — exactly when a
   viewer opens a menu. It takes no pointer events, so the options still
   worked under it, but the row was painted over. Withdrawn while a menu is
   open, the .paid-gated precedent; it fades back when the menu closes. */
.player-wrap:has(.player-list.open) .play-cue-play {
    opacity: 0;
    transition: opacity .15s ease-out;
}

/* The menu's name above its options (the speed menu's "Playback speed"):
   a bare "1×" does not say what it changes. nowrap lets the longest locale
   (ru/es, ~24 characters) widen the list instead of wrapping to two lines;
   on a wide player the list grows away from its inline-end anchor, into the
   bar (the narrow tier spans the whole bar instead — see RESPONSIVE). */
.player-list-title {
    padding: 6px 10px 7px;
    margin-bottom: 2px;
    border-bottom: 1px solid rgba(255, 255, 255, .14);
    color: rgba(255, 255, 255, .7);
    font-size: 13px;
    font-weight: 600;
    line-height: 1.2;
    white-space: nowrap;
}

.player-list button {
    display: block;
    width: 100%;
    padding: 7px 10px;
    border: 0;
    border-radius: 5px;
    background: transparent;
    color: var(--ink, #f2f2f2);
    font-size: 12px;
    font-weight: 600;
    line-height: 1;
    text-align: start;
    cursor: pointer;
}

.player-list button:hover {
    background: rgba(255, 255, 255, .14);
}

.player-list button[aria-checked="true"] {
    color: var(--accent-hover);
}

/* A menu option (speed and quality alike) is two lines: the value, large
   enough to read at a glance over the picture, and a muted hint naming what
   it means. Centred, so the value (LTR) and the hint (the UI direction) share
   one axis in every locale instead of starting from opposite edges. */
.player-list button {
    padding: 8px 10px;
    text-align: center;
}

/* nowrap: the quality menu's Auto value carries the live height
   ("Automático (1080p)") and must widen its list, never break mid-label. */
.player-opt-value {
    display: block;
    white-space: nowrap;
    font-size: 15px;
    font-weight: 700;
}

.player-opt-hint {
    display: block;
    margin-top: 4px;
    color: rgba(255, 255, 255, .55);
    font-size: 10px;
    font-weight: 500;
    line-height: 1.2;
    white-space: nowrap;
}

/* ----- Buffering spinner -----
   Decorative, so it never takes pointer events: it sits dead-centre, which is
   exactly where the play/pause bezel and the tap that toggles playback land
   (measured on the shipped player: with the vendor spinner up, elementFromPoint
   at the centre returned IT, and a click there toggled nothing). */
.player-spinner {
    position: absolute;
    top: 50%;
    left: 50%;
    z-index: 11;
    width: 48px;
    height: 48px;
    margin: -24px 0 0 -24px;
    border: 3px solid rgba(255, 255, 255, .25);
    border-top-color: #fff;
    border-radius: 50%;
    pointer-events: none;
    opacity: 0;
    animation: player-spin 1s linear infinite;
}

.player-wrap.buffering .player-spinner {
    /* The same 0.3s delay the brand's buffering twin uses, so the two appear
       as one event rather than two — see .player-brand-load. */
    opacity: 1;
    transition: opacity .2s ease-out .3s;
}

@keyframes player-spin {
    to { transform: rotate(360deg); }
}

/* ----- Error surface ----- */
.player-error {
    position: absolute;
    inset: 0;
    z-index: 13;
    display: grid;
    place-items: center;
    padding: 1rem;
    background: rgba(0, 0, 0, .8);
    color: #fff;
    text-align: center;
}

/* ----- The media element ----- */
.player-wrap video {
    display: block;
    width: 100%;
    height: 100%;
    background: #000;
    /* contain, never cover: 5.7% of the catalogue is portrait and a cover fit
       would crop the subject out of its own frame. The stage is 16:9, so a
       non-16:9 source letterboxes against the black ground. */
    object-fit: contain;
}

/* ===========================================================================
   PRESENTATION — one ladder, three outcomes
   ---------------------------------------------------------------------------
   Container fullscreen wherever the platform has it (Android, desktop): the
   stage IS the fullscreen element, so our chrome, the ad plane and the brand
   all survive. iPhone has no element fullscreen at all (measured: no
   requestFullscreen on any element, document.fullscreenEnabled false), so
   there the stage is PINNED over the page instead — Safari's URL bar and
   toolbar stay visible, which is the honest and accepted cost of keeping our
   own chrome rather than handing the video to Apple's player.
   =========================================================================== */
.player-wrap:fullscreen {
    width: 100vw;
    height: 100dvh;
    aspect-ratio: auto;
}

/* ⚠ `inset: 0` ALONE — never alongside an explicit width/height. The pair is
   over-constrained, and on this RTL site the browser drops `left` and anchors
   the box from the right edge, which shifts it by the scrollbar width
   (measured: 390x844 at @-15,0 in RTL against @0,0 in LTR, because 100vw
   includes a scrollbar the layout viewport does not). Phones use overlay
   scrollbars, so this is invisible on the device it was written for and
   visible on every desktop RTL render. */
.player-wrap.expanded {
    position: fixed;
    inset: 0;
    z-index: 1060;   /* over Bootstrap's modal (1055); under a toast (1090) */
    aspect-ratio: auto;
}

body.player-expanded {
    overflow: hidden;
}

/* ----- Forced landscape -----
   The viewer asked for fullscreen, so they get a landscape picture whatever
   way the phone is held. Android locks the SCREEN (screen.orientation.lock,
   which works there and overrides an engaged device rotation lock); iPhone
   exposes no lock API at all, so the only way to honour the same intent is to
   rotate our own stage.

   The swap is why this cannot use `inset: 0`: the box must be viewport-HEIGHT
   wide and viewport-WIDTH tall, which an inset shorthand cannot express. The
   right/bottom resets undo the `.expanded` inset above.

   ⚠ A transformed ancestor becomes the containing block for `position: fixed`
   descendants (measured: a fixed child lands at -89,0 instead of 0,0), so
   NOTHING inside the stage may be position:fixed. Everything mounted in there
   is position:absolute today and bin/check-player-css.php keeps it that way. */
.player-wrap.expanded.rotated {
    top: 0;
    left: 0;
    right: auto;
    bottom: auto;
    width: 100dvh;
    height: 100dvw;
    transform: rotate(90deg) translateY(-100%);
    transform-origin: top left;
}

/* ===========================================================================
   RESPONSIVE — a CONTAINER query, not a viewport one
   ---------------------------------------------------------------------------
   The tier must follow the PLAYER's width, not the page's: the same player is
   360px in a phone's page and the full screen a moment later, and a viewport
   query would keep reporting the in-page width in fullscreen. That is exactly
   why the tier must come from the stage's own box. A container query is the
   platform's way of saying exactly that, with no JS measuring anything.

   What goes: the time readout, the volume SLIDER and picture-in-picture. The
   mute BUTTON stays — only its slider goes.

   The SPEED and QUALITY menus deliberately stay: most viewers are on a phone,
   and a viewer must be able to see and choose what they are being streamed.

   ⚠ Under forced-landscape rotation the container's inline size becomes the
   viewport HEIGHT (844px on a phone), so the full control set returns. That is
   correct, not a leak: rotated, the player really is 844px wide in its own
   layout space and has the room. */
@container player (max-width: 560px) {
    .player-time,
    .player-vol,
    [data-player="pip"] {
        display: none;
    }

    /* Phone-width players keep the 40px box and 12px labels: the row's width
       budget (see the 330px tier below) is counted at that size. The glyph
       keeps the base 1.4rem — it fits the 40px box. */
    .player-btn {
        width: 40px;
        height: 40px;
    }

    .player-btn-text {
        width: auto;
        min-width: 40px;
        font-size: 12px;
    }

    /* Each menu list turns into ONE ROW of options under its title, spanning
       the whole bar: a portrait phone's player is ~160-200px tall, so a
       stacked column (title + four or five rows) would rise past the stage's
       top edge and be clipped. A static menu box hands the list's containing
       block to `.player-bar` (absolute; `.player-row` is not positioned), so
       `inset-inline` matches the bar's own 10px gutters and `bottom: 100%`
       keeps the row above the seekbar rather than over it. */
    .player-menu {
        position: static;
    }

    .player-list {
        inset-inline: 10px;
        margin-bottom: 2px;
    }

    .player-list.open {
        display: flex;
        flex-wrap: wrap;
    }

    .player-list .player-list-title {
        flex: 1 0 100%;
        padding: 3px 8px 4px;
    }

    /* The spacing is budgeted against the SHORTEST player: at 320px the stage
       is ~159px tall and leaves ~89px above the seekbar (with the 2px gap
       below), and title + a two-line option measure ~83px here. A hint may
       wrap onto a second line in the longest locales rather than widen its
       column, so each single word of a hint must fit its column. */
    .player-list button {
        min-width: 0;
        width: auto;
        padding: 6px 2px;
    }

    .player-list .player-opt-value {
        font-size: 14px;
    }

    .player-list .player-opt-hint {
        margin-top: 2px;
        line-height: 1.1;
        white-space: normal;
    }

    /* Speed: flex-basis 0 = five equal columns whatever each hint's length,
       so the values line up; each hint word fits a ~49px column. */
    [data-player="rate-list"] button {
        flex: 1 1 0;
    }

    /* Quality: NATURAL widths — the Auto row's value carries the live
       height ("Automático (1080p)") and is far wider than a bare "720p", so
       equal columns would clip it. Its value never wraps; its hint may. */
    [data-player="quality-list"] button {
        flex: 1 1 auto;
        padding-inline: 4px;
    }
}

/* The SMALLEST phones. With both menus showing a label ("1.25×", "1080p" —
   widest in zh, whose system face sets digits wider) the bar needs ~327px at
   40px controls, so below a 330px player the fullscreen button is pushed out
   of the stage. 36px boxes, no inter-control gap and 4px label padding bring
   that worst case to ~289px — inside the ~298px player of a 320px viewport —
   while keeping every control (the ±5 buttons stay, though double-tap also
   seeks). A 360px phone (~338px player) keeps the full-size bar. */
@container player (max-width: 330px) {
    .player-row {
        gap: 0;
    }

    .player-btn {
        width: 36px;
        height: 36px;
    }

    /* width restated: the rule above would otherwise pin a LABEL button
       (1.25×, 1080p) to 36px too, clipping its text. */
    .player-btn-text {
        width: auto;
        min-width: 36px;
        padding: 0 4px;
    }
}

/* "Most replayed" density graph (player.js initHeatGraph) — custom CSS:
   no Bootstrap primitive draws over a seekbar. Mounted inside `.player-seek`
   (position:relative), an LTR context; revealed while hovering the seek bar
   AND while scrubbing (`.scrubbing` — the only reveal touch devices have, since they
   never :hover). The graph is translucent white (YouTube-style) so the crimson
   play-progress stays the focal color. */
.heat-graph {
    position: absolute;
    left: 0;
    bottom: 100%;
    width: 100%;
    height: 24px;
    margin-bottom: 4px;
    pointer-events: none;
    opacity: 0;
    transition: opacity .15s;
    fill: rgba(255, 255, 255, .4);
}

.heat-peak-label {
    position: absolute;
    bottom: calc(100% + 30px);
    transform: translateX(-50%);
    background: rgba(0, 0, 0, .8);
    color: #fff;
    font-size: .7rem;
    padding: 2px 8px;
    border-radius: 4px;
    white-space: nowrap;
    pointer-events: none;
    opacity: 0;
    transition: opacity .15s;
}

.player-seek:hover .heat-graph,
.player-seek:hover .heat-peak-label,
.player-wrap.scrubbing .heat-graph,
.player-wrap.scrubbing .heat-peak-label {
    opacity: 1;
}

.player-placeholder {
    width: 100%;
    height: 100%;
    background-size: cover;
    background-position: center;
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
}

.player-placeholder::after {
    content: '';
    position: absolute;
    inset: 0;
    background: rgba(0, 0, 0, .62);
}

/* Seekbar hover preview: sprite tiles are 320x180, which would cover most of a
   phone-width player during a seekbar touch-drag. Hidden on small viewports
   (there is no mouse hover there, and the frame strip provides the same
   affordance); a transform/scale cap is not possible — the plugin positions
   the box via an inline translateX that would override or mis-scale it. */
@media (max-width: 767.98px) {
    .player-wrap .player-preview {
        display: none;
    }
}
