/* Custom player UI — control bar, settings menu, overlays, fullscreen.
 * The native <video controls> is removed; everything visual lives inside
 * .video-container so it survives fullscreen.
 *
 * Ported from SegmentPlayer. The vendored CSS references SegmentPlayer's
 * --color-* / --radius-* tokens; the :root block below bridges them to
 * meta-watch's theme tokens (defined in styles.css) so the file stays a
 * near-verbatim copy.
 */
:root {
    --color-primary: var(--accent);
    --color-text: var(--fg);
    --color-text-muted: var(--muted);
    --color-text-dim: var(--muted-2);
    --color-border: #2a2d3d;
    --color-surface: var(--bg-card);
    --color-success: var(--ok);
    --color-warning: var(--warn);
    --color-cyan: #4dd0e1;
    --radius-md: 9px;
    --radius-lg: 12px;
    --radius-xl: 14px;
}

/* Container needs positioning context for absolute overlays + the bar, and it
 * is the fullscreen element — so every piece of player chrome is a child of it.
 *
 * It used to be a rounded 16:9 box sized by the page around it. The watch view
 * is immersive now (body.mw-watching, styles.css): this fills the viewport,
 * square-cornered, and the <video> letterboxes inside it with `object-fit:
 * contain`. Windowed and fullscreen are therefore the same layout, which is why
 * the `:fullscreen` block at the bottom of this file has shrunk to the couple
 * of things that genuinely scale up. */
.video-container {
    position: relative;
    background: #000;
    overflow: hidden;
    width: 100%;
    height: 100%;
}
.video-container video {
    width: 100%;
    height: 100%;
    object-fit: contain;
    display: block;
}

/* Delegated-playback stage (js/player/yt-backend.js). Covers the <video>
 * rather than replacing it: the element is a permanent fixture of the host that
 * every other path (and the vendored engine) expects to find, so the delegated
 * path hides it and paints over the same box. z-index 1 puts it above the
 * <video> and below the now-playing overlay (back + title), which is the one
 * piece of our chrome that stays useful here. */
.yt-stage {
    position: absolute;
    inset: 0;
    z-index: 1;
    background: #000;
}
.yt-stage iframe {
    width: 100%;
    height: 100%;
    border: 0;
    display: block;
}

/* While a delegated source is on the stage, our own transport chrome is not
 * merely useless — it is actively wrong: the click shield would swallow clicks
 * meant for the iframe, and the control bar would show a paused, zero-length
 * player next to a video that is playing. YouTube owns transport here.
 *
 * Done as one class on the container rather than `hidden` on each child,
 * because the vendored UI sets `hidden` on those same elements itself (idle
 * fade, settings open/close) and the two would fight. */
.video-container.is-delegated video { display: none; }
.video-container.is-delegated .player-controls,
.video-container.is-delegated .player-click-shield,
.video-container.is-delegated .player-settings-menu { display: none !important; }

/* Legacy hidden control block — overrides .controls's display:flex during
 * the phased refactor so the old <select>s stay invisible while we build
 * the new UI. Removed in phase 6.
 */
.legacy-controls[hidden] {
    display: none !important;
}

.video-container:focus {
    outline: none;
}

/* libass subtitle overlay (client-ass.js). Since JASSUB 2.x this element is the
 * CANVAS itself, inserted right after the <video> (1.x inserted a wrapper div
 * with the canvas inside). JASSUB writes its own inline
 * position/top/left/width/height on every resize(), computed letterbox-aware
 * from the video's intrinsic dimensions — so geometry is still not ours, and we
 * must not fight it: no `inset`, no width/height here, or an over-constrained
 * box drifts out of alignment with the picture.
 * What remains ours is stacking. z-index puts it above the video and below the
 * click shield, so subtitles never swallow a click-to-play; `position` is
 * !important only to keep it out of flow in .video-container:fullscreen below,
 * which is display:flex — an in-flow child there becomes a flex item sitting
 * BESIDE the video instead of over it. */
.JASSUB {
    position: absolute !important;
    pointer-events: none;
    z-index: 1;
}

/* Click-to-play target sits between the video and the controls. */
.player-click-shield {
    position: absolute;
    inset: 0;
    cursor: pointer;
    background: transparent;
}

/* ─── Top-left now-playing overlay (back button + title) ───
 * Hosts the leave-the-player affordance as well as the title. It sits inside
 * .video-container, so unlike the page chrome above the video it survives
 * fullscreen — which is the whole reason back moved here. */
.now-playing-overlay {
    position: absolute;
    top: 1rem;
    left: 1rem;
    right: 1rem;
    /* Shrink to the back button + text column instead of spanning to `right`,
     * which left a wide empty slab of gradient after short titles. `right` is
     * kept so the box still has something to clamp against on long ones.
     * The cap is narrower than the old 60% because the column is now several
     * lines tall — a half-screen-wide slab of text over the picture reads as a
     * page, not as an overlay. */
    width: fit-content;
    max-width: min(520px, 46%);
    padding: 0.5rem 1rem 0.6rem 0.4rem;
    /* Sized against a WHITE frame, which is the worst case and a real one — a
     * snow scene, a lab, a title card. The old gradient faded to 28% black at
     * the bottom, which over white left the synopsis and the strip as pale grey
     * on pale grey. It does not fall below 52% any more, and the blur behind it
     * turns the panel into a surface rather than a smudge: it destroys the
     * detail that fights the text without pretending the picture isn't there.
     * Do not thin this back out without re-checking it on white. */
    background: linear-gradient(180deg, rgba(0, 0, 0, 0.80), rgba(0, 0, 0, 0.52));
    backdrop-filter: blur(6px) saturate(0.9);
    -webkit-backdrop-filter: blur(6px) saturate(0.9);
    border-radius: var(--radius-lg);
    color: var(--color-text);
    font-size: 0.95rem;
    font-weight: 500;
    /* Click-through by default so the bare overlay never steals a play/pause
     * tap from .player-click-shield; only the back button opts back in. */
    pointer-events: none;
    /* Same two-layer shadow as the ⓘ panel: a tight drop to cut each glyph
     * from the frame, a wide soft one to darken the ground around it. */
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9), 0 0 14px rgba(0, 0, 0, 0.6);
    opacity: 1;
    transition: opacity 0.25s ease;
    z-index: 5;
    display: flex;
    align-items: flex-start;
    gap: 0.4rem;
    overflow: hidden;
}

.now-playing-overlay .mw-overlay-back {
    pointer-events: auto;
    flex: 0 0 auto;
}

/* The text column: title, then status + live rate, then the release bits, then
 * a clamped synopsis and the filename. Everything but the back button is
 * painted by js/player/player-view.js through the fixed ids in index.html.
 * min-width:0 is what actually lets its children ellipsize/clamp instead of
 * forcing the overlay wider than its max-width. */
.now-playing-overlay .mw-overlay-text {
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 0.28rem;
    padding-top: 0.3rem;
}

/* Name + qualifier as ONE run of text: "Some Long Show Name S15E05 (2026)",
 * all in the same font, size, weight and colour. They stay two spans only so
 * the qualifier can be kept off a line break of its own and so the page/tab
 * title can still use the bare name — visually there is no seam.
 *
 * A normal wrapping block, not a flex row: the title is never truncated. A
 * name too long for the overlay's max-width runs onto a second (or third)
 * line instead of ellipsizing — knowing WHAT is playing beats keeping the
 * panel one line tall. Do not put back `white-space: nowrap` +
 * `text-overflow: ellipsis` here. */
.now-playing-overlay .mw-overlay-title {
    min-width: 0;
    font-size: 1.05rem;
    font-weight: 650;
    line-height: 1.25;
    /* Only the last resort for a single unbroken token (a dotted release name
     * with no spaces); normal titles still break at spaces. */
    overflow-wrap: anywhere;
}

.now-playing-overlay .mw-overlay-name {
    min-width: 0;
}

/* No styling of its own — same font, size, weight and colour as the name, so
 * "S15E05 (2026)" reads as the tail of the title rather than as a chip beside
 * it. It gets the space that used to come from the flex `gap`, and stays
 * unbroken so the coordinate never splits across two lines. */
.now-playing-overlay .mw-overlay-qual {
    white-space: nowrap;
}
/* The separating space belongs to the NAME span, not to the qualifier: inside
 * the nowrap qualifier it would be unbreakable, leaving the browser no wrap
 * opportunity between the two and pushing the whole run off the panel. Here it
 * is an ordinary breakable space, so a long name wraps and the coordinate
 * follows on the next line intact. */
.now-playing-overlay .mw-overlay-name:not(:empty)::after {
    content: " ";
}
.now-playing-overlay .mw-overlay-qual[hidden] { display: none; }

/* Status badge + speed chip on one line. */
.now-playing-overlay .mw-overlay-line {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 4px 10px;
}

/* The overlay is click-through so it never steals a play/pause tap; the text
 * has to opt back in or the viewer cannot select a filename to copy. Pointer
 * events are dropped again while the chrome is faded out (see .is-idle). */
.now-playing-overlay .mw-overlay-text {
    pointer-events: auto;
    user-select: text;
}
.video-container.is-idle .now-playing-overlay .mw-overlay-text {
    pointer-events: none;
}

/* Retargeted from the old ad-hoc 900px to the app-wide phone breakpoint —
   see css/mobile.css for the single breakpoint system. */
@media (max-width: 640px) {
    .now-playing-overlay {
        font-size: 0.8rem;
        padding: 0.25rem 0.6rem 0.25rem 0.25rem;
        max-width: calc(100% - 2rem);
    }
}

/* ─── Centered loading spinner ─── */
/* Centered with margin offsets instead of transform so the rotate animation
 * doesn't fight the translate(-50%, -50%). */
.player-loading {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 56px;
    height: 56px;
    margin: -28px 0 0 -28px;
    border: 4px solid rgba(255, 255, 255, 0.15);
    border-top-color: var(--color-primary);
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.15s ease;
    z-index: 6;
}

.player-loading.is-visible {
    opacity: 1;
}

/* ─── The ⓘ panel ───
 * Stream + network diagnostics as a translucent sheet over the whole stage.
 * It replaced a <details> below the video; there is no below any more (see
 * body.mw-watching in styles.css), and a panel outside .video-container would
 * cease to exist in fullscreen — the mode people actually watch in.
 *
 * z-index 20 puts it over everything else in the container: the JASSUB subtitle
 * canvas (1), the click shield, the now-playing overlay (5), the control bar
 * (10), the seek tooltip (12) and the settings menu (15). Reading the
 * diagnostics means the picture is not what you are looking at — and the
 * failure offer's own detail is a reason to open this from over the top of it.
 */
.player-panel {
    position: absolute;
    inset: 0;
    z-index: 20;
    display: flex;
    flex-direction: column;
    /* The video keeps playing underneath and you should be able to see it —
     * but white text has to stay readable over a WHITE frame, which is a real
     * shot (snow, a lab, a title card), and no fixed scrim can do both. A flat
     * 38% black over white still leaves a light grey ground.
     *
     * `brightness()` in the backdrop filter is what resolves it: it MULTIPLIES
     * whatever is behind, so the ground is always at most ~32% of the source
     * luminance. White becomes a dark grey; an already-dark shot stays dark and
     * barely changes. The picture is dimmed like a cinema scrim rather than
     * covered, so it reads through at every brightness — which a constant alpha
     * cannot manage. The blur then kills the high-frequency detail that fights
     * the glyphs, and the alpha below is only a slight cool tint.
     *
     * ⚠ Keep the @supports fallback in step: with no backdrop-filter this is
     * just that tint, and it has to carry the whole job on its own. */
    background: rgba(8, 10, 16, 0.20);
    backdrop-filter: blur(7px) saturate(0.85) brightness(0.32);
    -webkit-backdrop-filter: blur(7px) saturate(0.85) brightness(0.32);
    color: var(--color-text);
    /* Two shadows, not one: the tight drop separates a glyph from a busy
     * frame, the wide soft one darkens the ground immediately around it. This
     * is what pays for the thin scrim above — without it the labels vanish over
     * a bright shot. */
    text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9), 0 0 14px rgba(0, 0, 0, 0.6);
    /* The sheet is the click-outside target (player-ui.js): a click that lands
     * on this padding, not on the content, closes it. */
    padding: max(4vh, 18px) max(6vw, 18px);
}
.player-panel[hidden] { display: none; }

/* No backdrop-filter (older Firefox, and anything where the compositor refuses
 * it): the tint above is all that is left, and 20% over a white frame is
 * unreadable. Fall back to a scrim heavy enough to stand alone — less of the
 * picture shows through, which is the right trade when the alternative is text
 * nobody can read. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
    .player-panel { background: rgba(8, 10, 16, 0.82); }
}

.player-panel__head {
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 0 16px 10px;
    font-size: 13px;
    font-weight: 700;
    letter-spacing: 0.02em;
    /* Not --color-text-muted: that token is calibrated against an opaque page
     * background, and over video at 38% it is barely there. */
    color: rgba(255, 255, 255, 0.86);
    text-transform: uppercase;
    border-bottom: 1px solid rgba(255, 255, 255, 0.22);
}

/* The one scroll container. The head stays pinned, so a long JSON blob never
 * pushes the close button off the top of a phone screen. */
.player-panel__body {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    overscroll-behavior: contain;
    padding-top: 12px;
}

/* ─── Control bar (bottom) ─── */
.player-controls {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    padding: 0.5rem 1rem 0.75rem;
    background: linear-gradient(0deg, rgba(0, 0, 0, 0.85) 0%, rgba(0, 0, 0, 0.55) 60%, rgba(0, 0, 0, 0) 100%);
    color: var(--color-text);
    font-size: 0.85rem;
    user-select: none;
    opacity: 1;
    transition: opacity 0.25s ease;
    z-index: 10;
}

/* Auto-hide while playing — controls fade out, overlay fades with them. */
.video-container.is-idle .player-controls,
.video-container.is-idle .now-playing-overlay {
    opacity: 0;
    pointer-events: none;
}
/* …except while the status badge is carrying a transient message. The badge
 * lives inside the overlay, so without this a notice fired during playback —
 * which is exactly when they fire — fades out under the idle rule two seconds
 * in, and an announcement nobody can see is not one. Set by playerNotice()
 * (js/player/ui/controls.js) for the life of the message only. */
.video-container.is-idle.is-noticing .now-playing-overlay {
    opacity: 1;
}
/* Cursor auto-hide. The descendant selector is load-bearing: `cursor` stops
 * inheriting at any element that sets its own, and .player-click-shield covers
 * the whole container with `cursor: pointer` — so a rule on .video-container
 * alone never actually hides the pointer.
 *  - .is-idle        → set with the control bar, playing only.
 *  - .is-cursor-idle → fullscreen only, whatever the play state (see
 *                      wireCursorAutoHide in player-ui.js). */
.video-container.is-idle,
.video-container.is-idle *,
.video-container.is-cursor-idle,
.video-container.is-cursor-idle * {
    cursor: none;
}

/* ─── Seek bar ─── */
.player-controls__seek {
    position: relative;
    height: 18px;
    margin: 0 0 0.25rem;
    cursor: pointer;
    display: flex;
    align-items: center;
}

.player-controls__seek-track {
    position: relative;
    width: 100%;
    height: 4px;
    background: rgba(255, 255, 255, 0.25);
    border-radius: 2px;
    overflow: hidden;
    transition: height 0.12s ease;
}

.player-controls__seek:hover .player-controls__seek-track,
.player-controls__seek.is-dragging .player-controls__seek-track {
    height: 6px;
}

.player-controls__seek-disk,
.player-controls__seek-inflight,
.player-controls__seek-buffered,
.player-controls__seek-played {
    position: absolute;
    left: 0;
    top: 0;
    height: 100%;
    pointer-events: none;
}

/* ─── The three fills, back to front ───
 * disk (bytes on the device) → buffered (the pump's MediaSource run-ahead) →
 * played. They are genuinely nested most of the time — the pump can only hold
 * what the byte tier has fetched — so the ramp has to be readable at every
 * overlap, starting from the empty track's 0.25. The disk band sits between the
 * two, which is why the pump band is lifted (below) rather than the new layer
 * being squeezed into the 0.10 of headroom it used to leave. */
.player-controls__seek-disk {
    /* Painted by player-ui.js as one linear-gradient with hard stops — the
       on-disk map is sparse (head + a run at the playhead + the tail warmEnds()
       pulled first), so it can never be a width. */
    --seek-disk: rgba(255, 255, 255, 0.42);
    width: 100%;
}

/* `left` AND `width` are both set by renderBuffered — this is a window around
   the playhead, not a fill from the left edge. The 0/0 here is only the state
   before the first render. */
.player-controls__seek-buffered {
    background: rgba(255, 255, 255, 0.35);
    left: 0;
    width: 0%;
}

/* Only lifted once there is a disk band to sit on top of. `has-disk-map` is set
   by setPlayerDiskMap(), so a title with nothing cached — or a browser with no
   service worker, hence no byte tier at all — keeps exactly the bar it had
   before this layer existed. Same gate shape as `has-chapters`. */
.player-controls.has-disk-map .player-controls__seek-buffered {
    background: rgba(255, 255, 255, 0.62);
}

/* The 8 MiB chunk being fetched right now. On a source that pays a round trip
   per window (an nzb-release locator cid) this is the only thing moving for
   seconds at a time — which is exactly the state that otherwise reads as a dead
   bar. Spans, not gradient stops, so each can carry a px floor: one chunk is
   well under 1% of a feature-length file. */
.player-controls__seek-inflight {
    width: 100%;
}

.player-controls__seek-chunk {
    position: absolute;
    top: 0;
    height: 100%;
    background: rgba(255, 255, 255, 0.85);
    animation: mw-chunk-pulse 1.2s ease-in-out infinite;
}

@keyframes mw-chunk-pulse {
    0%, 100% { opacity: 0.25; }
    50%      { opacity: 1; }
}

@media (prefers-reduced-motion: reduce) {
    .player-controls__seek-chunk { animation: none; opacity: 0.7; }
}

.player-controls__seek-played {
    background: var(--color-primary);
    width: 0%;
}

.player-controls__seek-thumb {
    position: absolute;
    top: 50%;
    width: 12px;
    height: 12px;
    margin-left: -6px;
    background: var(--color-primary);
    border-radius: 50%;
    transform: translateY(-50%) scale(0);
    transition: transform 0.12s ease;
    pointer-events: none;
    box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35);
}

.player-controls__seek:hover .player-controls__seek-thumb,
.player-controls__seek.is-dragging .player-controls__seek-thumb {
    transform: translateY(-50%) scale(1);
}

/* ─── Hover / scrub time readout ───
 * Anchored to .player-controls__seek (position:relative) and floated above the
 * bar, so it is NOT clipped by the 4px .player-controls__seek-track. `left` is
 * set in px by wireSeek (already clamped to the bar's ends); the translate
 * centres the bubble on that point. pointer-events:none so it can never
 * interrupt the drag that is producing it. */
.player-controls__seek-tip {
    position: absolute;
    bottom: 100%;
    margin-bottom: 9px;
    transform: translateX(-50%);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 1px;
    padding: 0.25rem 0.5rem;
    background: rgba(0, 0, 0, 0.85);
    /* The bubble floats over the video, not over the bar's gradient — on a dark
       frame a bare fill is invisible and on a bright one it has no edge. The
       hairline + shadow are what make it read as a bubble on both. */
    border: 1px solid rgba(255, 255, 255, 0.1);
    box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5);
    border-radius: var(--radius-md);
    color: var(--color-text);
    font-size: 0.78rem;
    /* Tabular figures: without them the bubble jitters horizontally as the
       digits change under a moving cursor. */
    font-variant-numeric: tabular-nums;
    line-height: 1.3;
    white-space: nowrap;
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.1s ease;
    z-index: 12;
}

.player-controls__seek-tip.is-visible { opacity: 1; }

.player-controls__seek-tip-chapter {
    display: none;
    max-width: 24ch;
    overflow: hidden;
    text-overflow: ellipsis;
    color: var(--color-text-muted);
    font-size: 0.72rem;
}

.player-controls__seek-tip.has-chapter .player-controls__seek-tip-chapter {
    display: block;
}

/* ─── Chapter ticks ───
 * Layered over the played/buffered fills inside the (overflow:hidden) track,
 * so they clip with it and never eat a drag — the whole seek bar stays one
 * pointer target. */
.player-controls__seek-chapters {
    position: absolute;
    inset: 0;
    pointer-events: none;
}

.player-controls__seek-marker {
    position: absolute;
    top: 0;
    height: 100%;
    width: 2px;
    margin-left: -1px;
    background: rgba(0, 0, 0, 0.75);
}

/* ─── Bar row (buttons, time, volume, settings) ─── */
.player-controls__bar {
    display: flex;
    align-items: center;
    gap: 0.5rem;
}

.player-controls__left {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    flex: 1;
    min-width: 0;
}

.player-controls__right {
    display: flex;
    align-items: center;
    gap: 0.2rem;
}

/* Generic icon button */
.player-btn {
    width: 36px;
    height: 36px;
    padding: 0;
    background: transparent;
    border: none;
    color: var(--color-text);
    cursor: pointer;
    font-size: 1.1rem;
    line-height: 1;
    border-radius: var(--radius-md);
    display: flex;
    align-items: center;
    justify-content: center;
    transition: background 0.12s ease, color 0.12s ease;
}

.player-btn:hover {
    background: rgba(255, 255, 255, 0.15);
}

.player-btn:focus-visible {
    outline: 2px solid var(--color-primary);
    outline-offset: -2px;
}

.player-btn.is-active {
    color: var(--color-primary);
}

/* Icon + short text (next episode). The label is load-bearing: on a chaptered
 * file the next-chapter button sits two slots to the left with essentially the
 * same silhouette, and the word is what tells them apart. Auto width, so the
 * square-button sizing above has to be relaxed. */
.player-btn--labelled {
    width: auto;
    gap: 0.35rem;
    padding: 0 0.6rem;
    /* Breathing room from the chapter cluster on its left, so "next chapter"
     * and "next episode" read as two groups rather than one row of arrows. */
    margin-left: 0.4rem;
}

.player-btn__label {
    font-size: 0.8rem;
    font-weight: 600;
    letter-spacing: 0.01em;
    white-space: nowrap;
    /* Centre the CAP-HEIGHT box against the icon, not the em box. Flex already
     * centres the label's line box, but that box carries the font's descender
     * space on a word ("Next") that has no descenders, so the glyphs land
     * ~0.5px above the icon's centre — measured, not assumed. Doing it with a
     * px nudge would be wrong: `system-ui` resolves to a different font per
     * platform (Arial here, Segoe UI on Windows, Inter where it is installed)
     * and each has its own metrics. This asks the browser to trim the box to
     * the actual ink instead, so it is exact everywhere — and a silent no-op
     * on engines that don't support it. */
    text-box: trim-both cap alphabetic;
}

/* Phones drop the word — the chapter buttons are hidden entirely at this
 * breakpoint (see the 640px block below), so the icon alone is unambiguous.
 * Width is left to the later `.player-btn { width: 44px }` mobile rule so the
 * button keeps the same tap target as its neighbours. */
@media (max-width: 640px) {
    .player-btn--labelled {
        padding: 0;
    }
    .player-btn__label {
        display: none;
    }
}

.player-btn[hidden] {
    display: none !important;
}

/* Time readout */
.player-controls__time {
    font-variant-numeric: tabular-nums;
    font-size: 0.85rem;
    color: var(--color-text);
    white-space: nowrap;
    margin-left: 0.25rem;
}

.player-controls__time-sep {
    color: var(--color-text-muted);
    margin: 0 0.25rem;
}

/* Mode badge (relocated from metrics panel) */
.player-controls__mode-badge {
    display: inline-flex;
    align-items: center;
    gap: 0.35rem;
    padding: 0.15rem 0.55rem;
    background: rgba(255, 255, 255, 0.12);
    border-radius: 999px;
    font-size: 0.7rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--color-primary);
    white-space: nowrap;
}

.player-controls__mode-badge[data-mode="client"] { color: var(--color-success); }
.player-controls__mode-badge[data-mode="transcode"] { color: var(--color-warning); }
.player-controls__mode-badge[data-mode="remux"] { color: var(--color-cyan); }
.player-controls__mode-badge[data-mode="direct"] { color: var(--color-primary); }
.player-controls__mode-badge[data-mode=""] { display: none; }

/* ─── Chapter name + nav buttons ───
 * `has-chapters` is set by setPlayerChapters(). Without it the bar is exactly
 * what it was before chapters existed — no empty slots, no dead buttons. */
.player-controls:not(.has-chapters) .player-controls__chapter-only { display: none; }

.player-controls__chapter-name {
    display: inline-block;
    max-width: 22ch;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 0.75rem;
    color: var(--color-text-muted);
    min-width: 0;
}

/* Stepping is debounced, so this is the only feedback between the press and
 * the seek: the target chapter, visibly not-yet-current. */
.player-controls__chapter-name.is-pending {
    color: var(--color-primary);
    opacity: 0.85;
}

/* Subtitle loading indicator (inline in bar) */
.player-controls__subtitle-loading {
    display: none;
    align-items: center;
    gap: 0.35rem;
    font-size: 0.7rem;
    color: var(--color-warning);
    margin-right: 0.25rem;
}

.player-controls__subtitle-loading.is-active {
    display: inline-flex;
}

.player-controls__subtitle-loading .mini-spinner {
    width: 12px;
    height: 12px;
    border: 2px solid var(--color-border);
    border-top-color: var(--color-warning);
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
}

/* Volume control */
.player-controls__volume {
    display: flex;
    align-items: center;
    gap: 0.25rem;
}

/* Wrap needs extra width beyond the slider so the thumb (which extends
 * beyond the track) doesn't get clipped at either end of the range. */
.player-controls__volume-slider-wrap {
    width: 0;
    overflow: hidden;
    transition: width 0.18s ease;
    display: flex;
    align-items: center;
}

.player-controls__volume:hover .player-controls__volume-slider-wrap,
.player-controls__volume.is-open .player-controls__volume-slider-wrap {
    width: 96px;
}

.player-controls__volume-slider {
    width: 80px;
    height: 4px;
    margin: 0 0.5rem;
    flex-shrink: 0;
    -webkit-appearance: none;
    appearance: none;
    background: rgba(255, 255, 255, 0.25);
    border-radius: 2px;
    cursor: pointer;
}

.player-controls__volume-slider::-webkit-slider-thumb {
    -webkit-appearance: none;
    appearance: none;
    width: 12px;
    height: 12px;
    background: var(--color-primary);
    border-radius: 50%;
    cursor: pointer;
    box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35);
}

.player-controls__volume-slider::-moz-range-thumb {
    width: 12px;
    height: 12px;
    background: var(--color-primary);
    border-radius: 50%;
    border: none;
    cursor: pointer;
    box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35);
}

/* ─── Settings menu ─── */
/* Anchored above the control bar (which is ~64px tall). Sibling of
 * .player-controls inside .video-container, so 'bottom' is measured from
 * the container's bottom edge. */
.player-settings-menu {
    position: absolute;
    right: 0.75rem;
    bottom: 4.5rem;
    min-width: 220px;
    max-width: 320px;
    max-height: 60vh;
    overflow: hidden;
    background: rgba(20, 20, 28, 0.97);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-lg);
    box-shadow: 0 10px 40px rgba(0, 0, 0, 0.6);
    color: var(--color-text);
    font-size: 0.85rem;
    opacity: 0;
    transform: translateY(8px) scale(0.96);
    transform-origin: bottom right;
    pointer-events: none;
    transition: opacity 0.14s ease, transform 0.14s ease;
    z-index: 15;
}

.player-settings-menu.is-open {
    opacity: 1;
    transform: translateY(0) scale(1);
    pointer-events: auto;
}

/* A column: header (fixed) → items (the only scroller) → footer (fixed).
 * Was a plain block with a hand-computed `calc(60vh - 3rem)` on __items; that
 * could not account for a footer, which the menu's `overflow: hidden` would
 * then clip away entirely. */
.player-settings-menu__page {
    padding: 0.4rem 0;
    display: flex;
    flex-direction: column;
    max-height: 60vh;
}

.player-settings-menu__page[hidden] {
    display: none;
}

.player-settings-menu__page-header {
    display: flex;
    align-items: center;
    gap: 0.4rem;
    padding: 0.4rem 0.75rem 0.6rem;
    border-bottom: 1px solid var(--color-border);
    margin-bottom: 0.25rem;
    color: var(--color-text-muted);
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    cursor: pointer;
}

.player-settings-menu__page-header:hover {
    color: var(--color-text);
}

.player-settings-menu__page-header .player-settings-menu__back {
    font-size: 1.1rem;
    line-height: 1;
}

.player-settings-menu__items {
    flex: 1 1 auto;
    min-height: 0;   /* without it a flex item refuses to shrink below content */
    overflow-y: auto;
}

.player-settings-menu__item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.55rem 0.75rem;
    cursor: pointer;
    color: var(--color-text);
    transition: background 0.12s ease;
}

.player-settings-menu__item:hover,
.player-settings-menu__item:focus-visible {
    background: rgba(255, 255, 255, 0.08);
    outline: none;
}

/* Shown-but-unavailable (e.g. "Source — No alternatives", "Keep permanently"
 * while offline). Kept on screen with its reason in the value column instead of
 * hidden, so the option doesn't appear and disappear between titles. */
.player-settings-menu__item.is-disabled {
    opacity: 0.45;
    cursor: default;
}

.player-settings-menu__item.is-disabled:hover {
    background: transparent;
}

.player-settings-menu__item-label {
    flex: 1;
    min-width: 0;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Two-line choice rows (Audio / Subtitles): the LANGUAGE on top, the track's
 * own name — "Signs & Songs", "Forced", a disambiguating track number — under
 * it. The column wrapper takes the row's flex slot so the check gutter and the
 * chevron keep their alignment whether or not a row has a second line. */
.player-settings-menu__item-text {
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 0.05rem;
}

.player-settings-menu__item-text .player-settings-menu__item-label {
    flex: 0 0 auto;
}

.player-settings-menu__item-sub {
    color: var(--color-text-muted);
    font-size: 0.72rem;
    line-height: 1.2;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.player-settings-menu__item-value {
    color: var(--color-text-muted);
    font-size: 0.78rem;
    max-width: 50%;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.player-settings-menu__item-chevron {
    color: var(--color-text-dim);
    font-size: 0.9rem;
}

.player-settings-menu__item.is-active .player-settings-menu__item-check {
    color: var(--color-primary);
}

.player-settings-menu__item-check {
    width: 1rem;
    color: transparent;
    flex-shrink: 0;
}

.player-settings-menu__divider {
    height: 1px;
    background: var(--color-border);
    margin: 0.3rem 0;
}

/* A divider that names the block under it ("Now playing", "Your languages").
 * It is a section label, not a row: no tabindex, no data-item-idx, so a D-pad
 * walks straight past it. The rule is the text's own baseline. */
.player-settings-menu__divider.has-heading {
    height: auto;
    background: none;
    margin: 0.5rem 0 0.15rem;
    padding: 0 0.75rem 0.2rem;
    border-bottom: 1px solid var(--color-border);
    color: var(--color-text-muted);
    font-size: 0.7rem;
    text-transform: uppercase;
    letter-spacing: 0.05em;
}

/* The state column: what is happening to this row right now, kept out of the
 * description line so a 30-second download cannot push the description off the
 * end of it. Short by construction — a failure gets the full-width note line
 * below instead (see __item-note). */
.player-settings-menu__item-state {
    font-variant-numeric: tabular-nums;
    flex: 0 0 auto;
    white-space: nowrap;
}

/* A failure's own line, under the description and the full width of the row.
 * It WRAPS: the whole point of "Download limit reached — try again in 3 h" is
 * the part that says what to do, and that is the half a clipped line loses. */
.player-settings-menu__item-note {
    display: block;
    margin-top: 0.15rem;
    font-size: 0.75rem;
    line-height: 1.3;
    white-space: normal;
    color: #f6a2a2;
}
.player-settings-menu__item-state.is-downloading,
.player-settings-menu__item-state.is-saving {
    opacity: 0.75;
}
.player-settings-menu__item-state.is-ready,
.player-settings-menu__item-state.is-saved {
    color: var(--color-accent, #6ee7a8);
}
.player-settings-menu__item-state.is-failed,
.player-settings-menu__item-state.is-savefailed {
    color: #f6a2a2;
}

/* Rows pinned under the scroll container rather than inside it — the actions,
 * which must stay reachable however long the list above them runs. The menu
 * itself is `overflow: hidden` with a `max-height`, and __items owns the
 * scrolling, so this is simply its sibling below. */
.player-settings-menu__page-footer {
    border-top: 1px solid var(--color-border);
    background: inherit;
}

/* ─── Fullscreen overrides ───
 * Windowed playback is already edge-to-edge and square-cornered
 * (body.mw-watching in styles.css), so entering fullscreen changes almost
 * nothing about the layout — the container goes from filling the viewport to
 * BEING it. What is left here is the scale-up: on a television the 20px buttons
 * and 0.95rem overlay that suit a laptop are too small.
 *
 * ⚠ Do NOT put `display: flex` back on the container. It made the JASSUB canvas
 * an in-flow flex ITEM sitting beside the video instead of over it — which is
 * why the .JASSUB rule near the top of this file forces `position: absolute`
 * with !important. That guard stays; this rule no longer needs it. */
.video-container:fullscreen,
.video-container:-webkit-full-screen {
    width: 100vw;
    height: 100vh;
    background: #000;
}

.video-container:fullscreen .player-controls,
.video-container:-webkit-full-screen .player-controls {
    padding: 0.75rem 1.5rem 1rem;
    font-size: 0.95rem;
}

.video-container:fullscreen .player-btn,
.video-container:-webkit-full-screen .player-btn {
    width: 42px;
    height: 42px;
    font-size: 1.25rem;
}

.video-container:fullscreen .now-playing-overlay,
.video-container:-webkit-full-screen .now-playing-overlay {
    top: 1.5rem;
    left: 1.5rem;
    font-size: 1.05rem;
    padding: 0.6rem 1rem;
}

/* Tighter on mobile. Retargeted from 900px to the app-wide phone breakpoint. */
@media (max-width: 640px) {
    .player-controls {
        padding: 0.4rem 0.6rem 0.6rem;
        font-size: 0.75rem;
    }
    /* Was 32px — under the 44px minimum for a reliable tap, on the controls
       that matter most. The bar still fits: 5 buttons at 44px + the time
       readout is ~300px, well inside a 390px viewport. */
    .player-btn {
        width: 44px;
        height: 44px;
        font-size: 1rem;
    }
    /* The seek strip is the hardest thing to hit with a thumb; give it a taller
       hit area without changing the drawn track. */
    .player-controls__seek {
        padding-top: 10px;
        padding-bottom: 10px;
    }
    /* No chapter controls in the bar on a phone. The width budget noted above
       fits FIVE 44px buttons + the clock; a sixth and seventh make the flex row
       shrink every button in the left group — measured at 20px each, which
       silently wrecks play/pause, the one control that must never be hard to
       hit. The seek-bar ticks still show where the chapters are, and the
       settings menu's Chapters page (a named, tappable list — a better touch
       affordance than two step buttons anyway) does the navigating. */
    .player-controls__chapter-only { display: none; }
    .player-settings-menu {
        right: 0.4rem;
        min-width: 200px;
        max-width: 85vw;
    }
    /* Touch-opened volume slider (wireVolume adds .is-open on a coarse pointer);
       a touch drag needs more travel than the 80px desktop slider. */
    .player-controls__volume.is-open .player-controls__volume-slider-wrap {
        width: 120px;
    }
    .player-controls__volume.is-open .player-controls__volume-slider {
        width: 104px;
        height: 6px;
    }
}

/* The report form at the top of the ⓘ panel (js/report.js). The same inset as
 * the panel's rows, and a ground dark enough to type on over any frame — the
 * page variant's --bg-elev is calibrated for the opaque app, not for video. */
#mw-tech-report { padding: 0 16px 14px; }
#mw-tech-report .mw-report-field {
    background: rgba(0, 0, 0, 0.55);
    border-color: rgba(255, 255, 255, 0.22);
}
#mw-tech-report .mw-report-field input { color: #fff; text-shadow: none; }
#mw-tech-report .mw-report-field input::placeholder { color: rgba(255, 255, 255, 0.6); }
