/* ============================================================
   HPPN TV — fullscreen MTV/late-night-cable mode for Explore.
   Scoped under body.tv-mode-active and #tvStage. Easy revert:
   delete the <link> in index.html and the JS section in browse.js.
   ============================================================ */

:root {
    --tv-bg: #050407;
    --tv-ink: #f6efe2;
    --tv-glow: #ffe27a;
    --tv-accent: #ff3d6e;
    --tv-cyan: #4ee2ff;
    --tv-mint: #6effb8;
    --tv-shadow: 0 0 18px rgba(255, 226, 122, 0.55), 0 0 38px rgba(255, 226, 122, 0.18);
    /* Consistent 4px-step spacing scale used throughout the TV chrome so
       padding/gap stays in lockstep across breakpoints. */
    --tv-space-1: 4px;
    --tv-space-2: 8px;
    --tv-space-3: 12px;
    --tv-space-4: 16px;
    --tv-space-5: 22px;
    /* Mobile video frame uses a strict 16:9 ratio so a portrait source can't
       balloon the broadcast tile and push everything below the fold. */
    --tv-mobile-aspect: calc(16 / 9);
    /* Glass-pill surface tokens — shared by the right-side action rail and the
       TV-mode bottom nav so the two read as the same translucent material. */
    --tv-glass-bg: rgba(0, 0, 0, 0.42);
    --tv-glass-border: rgba(246, 239, 226, 0.10);
    --tv-glass-blur: blur(14px) saturate(1.4);
    --tv-glass-shadow: 0 6px 22px rgba(0, 0, 0, 0.5);
    /* Bloom blur radius for the ambient backdrop + ambilight bitmap. 80px
       on a fullscreen surface is GPU-expensive even on a static bitmap;
       mobile viewports drop it to 48px (visually indistinguishable through
       the saturate/brightness pipeline) and low-power devices drop further
       via body.tv-low-power. */
    --tv-bloom-blur: 80px;
}
@media (max-width: 540px) {
    :root { --tv-bloom-blur: 48px; }
}
/* Capability-tier opt-out — JS sets body.tv-low-power once in enterTvMode
   from _tvDetectCaps (deviceMemory / hardwareConcurrency / saveData /
   effectiveType). Drops blur radius further and freezes the per-frame
   compositor effects (grain, rolling scanline) so we trade a little
   atmosphere for thermal headroom on phones that need it. */
body.tv-low-power {
    --tv-bloom-blur: 32px;
}
body.tv-low-power .tv-grain,
body.tv-low-power .tv-scanlines::after {
    animation: none !important;
}

/* Hide the regular feed + chrome while TV is on */
body.tv-mode-active {
    overflow: hidden;
}
/* When TV mode is active and the user taps the AREA pill, the existing global
   city picker overlay must render ABOVE the TV stage (z:9999), so we lift it. */
body.tv-mode-active #globalCityPickerOverlay {
    z-index: 10001 !important;
}
/* Same lift for the date-range calendar overlay, which the DATES pill opens
   from the TV identity row. Without this, the calendar renders behind the
   stage and is invisible. */
body.tv-mode-active #calendarOverlay {
    z-index: 10001 !important;
}
/* Same lift for the buy-tickets flow popups so the View Show button on the
   stage opens the same donation / multi-event / sticker UX as the swipe
   feed's Buy Tickets button. Without these overrides the modals would render
   beneath the z:9999 stage. */
body.tv-mode-active #donationOverlay,
body.tv-mode-active #stickerOverlay {
    z-index: 10002 !important;
}
body.tv-mode-active #multiEventOverlay { z-index: 10001 !important; }
body.tv-mode-active #multiEventModal   { z-index: 10002 !important; }
body.tv-mode-active #app,
body.tv-mode-active .keyboard-hint,
body.tv-mode-active .floating-support,
body.tv-mode-active .top-bar,
body.tv-mode-active #similarArtistsPage,
body.tv-mode-active .liner-notes-sheet {
    visibility: hidden;
    pointer-events: none;
}

/* Bottom-nav stays visible in TV mode (mobile-only — design-system.css already
   hides it ≥768px via display:none). It's the primary cross-app exit
   affordance now that the floating × is gone. The stage sits at z:9999, so
   we have to lift the nav above it; we stay below the TV-mode global city
   picker overlay (z:10001) so the picker still floats above the nav.
   Dim it to 0.7 by default so the broadcast reads as the hero — it brightens
   the moment the user touches it (or hovers on a desktop accidentally on the
   mobile breakpoint), keeping the cross-app exit affordance discoverable. */
body.tv-mode-active .bottom-nav {
    z-index: 10000;
    opacity: 0.7;
    transition: opacity 220ms ease;
}
body.tv-mode-active .bottom-nav:hover,
body.tv-mode-active .bottom-nav:focus-within,
body.tv-mode-active .bottom-nav:active {
    opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
    body.tv-mode-active .bottom-nav { transition: none; }
}

/* Restyle the bottom-nav as translucent black/blurred so the cream paper tray
   doesn't break the cinematic palette. We override the inner surface (paper
   becomes glass) and ink → warm white so icons stay readable on dark video.
   Active state lights up gold to match the rest of the TV chrome. */
body.tv-mode-active .bottom-nav-inner {
    background: rgba(0, 0, 0, 0.55);
    border-color: rgba(246, 239, 226, 0.16);
    box-shadow: var(--tv-glass-shadow);
    backdrop-filter: blur(18px) saturate(1.4);
    -webkit-backdrop-filter: blur(18px) saturate(1.4);
}
/* On mobile, the outer .bottom-nav gets a cream paper background and a 2px
   ink top-border (design-system.css mobile media query). In TV mode that
   sliver of cream around the dark glass tray reads as a UI bug. Strip both
   so the dark glass extends edge-to-edge. */
@media (max-width: 767px) {
    body.tv-mode-active .bottom-nav {
        background: transparent;
        border-top-color: rgba(246, 239, 226, 0.12);
    }
}
body.tv-mode-active .bottom-nav-item {
    color: rgba(246, 239, 226, 0.6);
}
body.tv-mode-active .bottom-nav-item:not(.active) svg {
    color: rgba(246, 239, 226, 0.72);
}
body.tv-mode-active .bottom-nav-item:hover:not(.active) {
    color: var(--tv-ink);
}
body.tv-mode-active .bottom-nav-item.active {
    color: var(--tv-glow);
}
body.tv-mode-active .bottom-nav-item.active svg {
    color: var(--tv-glow);
    filter: drop-shadow(0 0 6px rgba(255, 226, 122, 0.45));
}
body.tv-mode-active .bottom-nav-item.active::before {
    background: rgba(255, 226, 122, 0.16);
}

/* Stage */
.tv-stage {
    position: fixed;
    inset: 0;
    width: 100vw;
    height: 100dvh; /* dynamic viewport unit — survives iOS address-bar resize */
    background: #000;
    color: var(--tv-ink);
    z-index: 9999;
    overflow: hidden;
    font-family: 'DM Sans', system-ui, -apple-system, sans-serif;
    -webkit-tap-highlight-color: transparent;
    user-select: none;
    -webkit-user-select: none;
    contain: strict;
    /* Take all touch gestures ourselves — prevents iOS rubber-band/back-swipe
       from competing with the TikTok-style vertical pager. Children that need
       native scroll (channel rail, channel menu) re-enable specific axes. */
    touch-action: none;
    overscroll-behavior: none;
    /* Drag offsets driven by the JS pointer handler. .tv-video composes these
       with its own centering translate; .tv-ambilight slides 1:1 with them. */
    --tv-drag-x: 0px;
    --tv-drag-y: 0px;
    /* Post-commit "pop in" offset: video + lower-third + actions-rail jump
       to the OPPOSITE off-screen edge during the loading curtain, then animate
       back to 0 during the fade-in so the new artist visibly slides in from
       below (TikTok feel). Ambilight intentionally does NOT consume this so
       the room stays lit during the swap. */
    --tv-pop-y: 0px;
    /* Bottom-nav is mobile-only (display:none ≥768px in design-system.css)
       and floats above the stage (z:10000) in TV mode. Bottom-anchored chrome
       (ticker, overlay-grid, actions-rail) lifts by this amount so it never
       slides under the nav tray. Overridden in the mobile media query below. */
    --tv-nav-clearance: 0px;
    /* Structural rest-Y for the video frame. 0 means "centered at top:50%".
       Mobile overrides this to a negative value to lift the video into the
       upper portion of the screen so the lower-third + scrubber have room
       to breathe directly underneath. Must be in px units — the JS pointer
       handler parses this with parseFloat() to compensate the drag math
       (see browse.js _tvShouldIgnoreSwipe / pointerdown). */
    --tv-rest-y: 0px;
}
.tv-stage[hidden] { display: none; }

/* Mobile (matches the bottom-nav visibility breakpoint in design-system.css)  —
   reserve room above the bottom-nav. --bottom-nav-h is set on :root in
   design-system.css (56px default, 44px ≤767px), so this picks up automatically. */
@media (max-width: 767px) {
    .tv-stage { --tv-nav-clearance: var(--bottom-nav-h); }
}

/* Ambient backdrop — blurred artist photo bleeds light onto the negative
   space around vertical/letterboxed videos. Cross-fades on artist change. */
.tv-ambient {
    position: absolute;
    inset: -12%;
    background-color: #050407;
    background-size: cover;
    background-position: center;
    background-repeat: no-repeat;
    filter: blur(var(--tv-bloom-blur, 80px)) saturate(1.4) brightness(0.55);
    transform: scale(1.25);
    opacity: 0.55;
    z-index: 0;
    pointer-events: none;
    transition: background-image 600ms ease, opacity 600ms ease;
}

/* Video frame — centered "screen" sized to the SOURCE video's aspect ratio
   (fetched via oEmbed and stored in --tv-aspect). The foreground iframe is
   slightly oversized to clip YT chrome. */
.tv-stage { --tv-aspect: 1.7777; /* default 16:9 */ }

.tv-video {
    position: absolute;
    top: 50%;
    left: 50%;
    /* Compose drag offsets with centering translate, plus a structural rest-Y
       offset that lifts the video on mobile (see --tv-rest-y on .tv-stage)
       and the commit-time pop-in offset (--tv-pop-y) that drives the
       slide-in-from-below animation. At rest with all extras at 0 this
       resolves to translate(-50%, -50%). */
    transform: translate(
        calc(-50% + var(--tv-drag-x)),
        calc(-50% + var(--tv-drag-y) + var(--tv-pop-y, 0px) + var(--tv-rest-y, 0px))
    );
    aspect-ratio: var(--tv-aspect);
    /* Pick whichever dimension is binding given current viewport. Sized to
       dominate the screen — the broadcast tile is the primary element. */
    width: min(94vw, calc(96dvh * var(--tv-aspect)));
    max-height: 96dvh;
    pointer-events: none;
    background: #000;
    z-index: 3;
    overflow: hidden;
    border-radius: 12px;
    box-shadow:
        0 0 60px rgba(0, 0, 0, 0.6),
        0 30px 80px rgba(0, 0, 0, 0.55);
    isolation: isolate;
    will-change: transform;
}
.tv-video iframe,
.tv-video > div {
    position: absolute;
    top: -8%;
    left: -8%;
    width: 116%;
    height: 116%;
    border: 0;
}
/* Subtle dark gradient at the bottom of the video — softens the hard rectangle
   edge so the embed feels native to the TV chrome rather than a YT iframe.
   Sits above the iframe inside the .tv-video stacking context (isolation:isolate
   on .tv-video), pointer-events:none so it never swallows clicks. */
.tv-video::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 2;
    pointer-events: none;
    border-radius: inherit;
    background: linear-gradient(
        180deg,
        transparent 0%,
        transparent 62%,
        rgba(0, 0, 0, 0.18) 80%,
        rgba(0, 0, 0, 0.55) 100%
    );
}

/* Ambilight: heavily blurred YouTube poster image painted as the backdrop
   behind the foreground player. The earlier incarnation was a muted clone
   of the SAME video running in a hidden iframe; that ran a second video
   decoder + network stream per artist, which on mobile / iOS WKWebView
   dwarfed every other cost in TV mode. The bitmap version still paints the
   source-true color bloom around portrait / letterboxed videos but costs
   nothing per frame to keep rendering. The cover-fit math the iframe used
   collapses to background-size:cover on the (already inset:-10% oversized)
   layer — no inner element required. */
.tv-ambilight {
    position: absolute;
    inset: -10%;
    z-index: 1;
    pointer-events: none;
    overflow: hidden;
    background-color: #050407;
    background-size: cover;
    background-position: center;
    background-repeat: no-repeat;
    filter: blur(var(--tv-bloom-blur, 80px)) saturate(1.8) brightness(1.0);
    opacity: 0.92;
    /* Slide with the foreground frame during TikTok-style drags AND honor the
       same structural rest-Y so the bloom tracks the foreground at rest. The
       ambilight is heavily blurred + oversized (inset:-10%), so a 50px lift
       is invisible at the edges but keeps the bloom visually anchored.
       will-change is transform-only — promoting `filter` here kept the layer
       in a permanent compositor-readback state on Safari which dwarfed the
       cost of the actual transform updates. */
    transform: translate(var(--tv-drag-x), calc(var(--tv-drag-y) + var(--tv-rest-y, 0px)));
    will-change: transform;
    transition: opacity 600ms ease, background-image 600ms ease;
}

/* Desktop — bump the frame up to a near-fullscreen broadcast tile so the
   video carries the screen. The bloom still wraps it because the ambilight
   sits behind, inset:-10%. Targets the new immersive layout where actions /
   genre rail are tucked behind compact Tune/More toggles. */
@media (min-width: 920px) {
    .tv-video {
        width: min(94vw, calc(96dvh * var(--tv-aspect)));
        max-height: 96dvh;
    }
}

/* Tablet / landscape phone */
@media (max-width: 920px) and (min-width: 541px) {
    .tv-video {
        width: min(90vw, calc(84dvh * var(--tv-aspect)));
        max-height: 84dvh;
    }
}

/* Mobile — frame fills viewport width when the video is portrait, or fills
   viewport height when it's landscape. The ambilight always covers the whole
   stage, so any remaining negative space gets the colored bloom.

   The video stays anchored at top:50% (don't switch to `bottom:` anchoring —
   that would force the JS pointer handler to track multiple positioning
   modes). On mobile the bottom chrome cluster now FOLLOWS the video bottom
   edge (see the @media (max-width: 540px) cluster rules below), so the
   video can stay centered (--tv-rest-y stays at 0). The JS pointerdown
   handler still subtracts --tv-rest-y from baseY so any future structural
   lift would compose correctly. */
@media (max-width: 540px) {
    /* Strict 16:9 frame on mobile regardless of source aspect — keeps the
       broadcast tile compact and predictable so metadata reads sooner. The
       ambilight (which still uses --tv-aspect) keeps painting source-true
       colors around the frame. Portrait sources letterbox inside the iframe
       rather than stretching the visible tile. */
    .tv-video {
        aspect-ratio: var(--tv-mobile-aspect);
        width: min(100vw, calc(41dvh * var(--tv-mobile-aspect)));
        max-height: 41dvh;
        border-radius: 14px;
    }
    .tv-stage.tv-portrait .tv-video {
        aspect-ratio: var(--tv-mobile-aspect);
        width: min(100vw, calc(41dvh * var(--tv-mobile-aspect)));
        max-height: 41dvh;
    }
    /* More aggressive iframe bleed on mobile — the small frame means the
       default 8% crop still leaks YT title bar and the "Watch on YouTube"
       pill. The title bar in a small embed is ~22-26% of frame height, so
       28% on each edge is needed to clip it cleanly. */
    .tv-video iframe,
    .tv-video > div {
        top: -28%;
        left: -28%;
        width: 156%;
        height: 156%;
    }
    .tv-ambilight {
        filter: blur(var(--tv-bloom-blur, 70px)) saturate(2) brightness(1.1);
        opacity: 1;
    }
}

/* Vignette — sits BEHIND the video so it tints the ambilight without dimming
   the actual broadcast. Top/bottom darken the rows so overlay text reads
   without competing with the live colors of the video. */
.tv-stage::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 2;
    pointer-events: none;
    background:
        radial-gradient(ellipse at center, transparent 42%, rgba(0,0,0,0.45) 92%),
        linear-gradient(180deg, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.15) 14%, rgba(0,0,0,0) 28%, rgba(0,0,0,0) 64%, rgba(0,0,0,0.4) 88%, rgba(0,0,0,0.75) 100%);
}

/* CRT scanlines — subtle, always on */
.tv-scanlines {
    position: absolute;
    inset: 0;
    z-index: 6;
    pointer-events: none;
    background: repeating-linear-gradient(
        to bottom,
        rgba(255, 255, 255, 0.045) 0px,
        rgba(255, 255, 255, 0.045) 1px,
        transparent 1px,
        transparent 3px
    );
    mix-blend-mode: overlay;
    opacity: 0.7;
}
.tv-scanlines::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(transparent 0%, rgba(255,255,255,0.06) 50%, transparent 100%);
    height: 12vh;
    animation: tv-scan-roll 7s linear infinite;
    mix-blend-mode: screen;
    opacity: 0.5;
}
@keyframes tv-scan-roll {
    0%   { transform: translateY(-12vh); }
    100% { transform: translateY(100vh); }
}

/* Static / flicker — only visible on .active for ~280ms bursts */
.tv-static {
    position: absolute;
    inset: 0;
    z-index: 8;
    pointer-events: none;
    opacity: 0;
    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='220'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.95' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1  0 0 0 0 1  0 0 0 0 1  0 0 0 1.4 0'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.85'/></svg>");
    background-size: 220px 220px;
    mix-blend-mode: screen;
    transition: opacity 80ms linear;
}
.tv-static.active {
    opacity: 0.85;
    animation: tv-static-jitter 280ms steps(6, end);
    /* Promote only during the burst — the static is invisible (opacity 0)
       between flashes, so a permanent layer was paying a compositor cost
       for nothing 99% of the time. */
    will-change: opacity, background-position;
}
/* Stronger / longer burst used when the user changes channels — the
   re-tune deserves a heavier flicker than a track auto-advance. Same
   keyframes, just slowed and held a bit longer. */
.tv-static.active.strong {
    opacity: 0.95;
    animation: tv-static-jitter 540ms steps(10, end);
}
@keyframes tv-static-jitter {
    0%   { background-position: 0 0;       opacity: 0.95; filter: brightness(1.5) contrast(2); }
    20%  { background-position: 70px 30px; opacity: 0.55; }
    40%  { background-position: 130px 90px; opacity: 0.85; }
    60%  { background-position: 40px 180px; opacity: 0.4; }
    80%  { background-position: 200px 120px; opacity: 0.7; }
    100% { background-position: 90px 220px; opacity: 0; }
}

/* Subtle persistent grain at near-zero opacity */
.tv-stage > .tv-grain {
    position: absolute;
    inset: 0;
    z-index: 7;
    pointer-events: none;
    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='1.6' numOctaves='1' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)' opacity='0.5'/></svg>");
    background-size: 180px 180px;
    opacity: 0.045;
    mix-blend-mode: overlay;
    animation: tv-grain-shift 1.4s steps(4) infinite;
}
@keyframes tv-grain-shift {
    0%   { background-position: 0 0; }
    25%  { background-position: 60px 30px; }
    50%  { background-position: 30px 90px; }
    75%  { background-position: 120px 60px; }
    100% { background-position: 0 150px; }
}

/* Pause the always-on compositor effects (rolling scanline + grain shift)
   when the broadcast itself is idle/paused. The user isn't getting any
   value from the per-frame motion when the foreground video isn't
   advancing, and the GPU recomposes the entire viewport per tick of these
   animations. animation-play-state preserves the current frame so the
   effect freezes mid-cycle rather than visibly snapping back to 0. */
.tv-stage.idle .tv-grain,
.tv-stage.idle .tv-scanlines::after,
body.tv-mode-paused .tv-grain,
body.tv-mode-paused .tv-scanlines::after {
    animation-play-state: paused;
}

/* Overlay grid (top + bottom rows, content above all video/static) */
.tv-overlay-grid {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    /* 22px clears the slim toned-down ticker below; --tv-nav-clearance lifts
       above the mobile bottom-nav (0 on desktop where the nav is hidden).
       Mobile uses the same height now that the ticker is uniformly slim. */
    bottom: calc(22px + env(safe-area-inset-bottom, 0px) + var(--tv-nav-clearance, 0px));
    z-index: 20;
    display: grid;
    grid-template-rows: auto auto 1fr auto auto auto;
    pointer-events: none; /* children re-enable as needed */
}

.tv-row-top {
    display: flex;
    align-items: center;
    /* Two children: broadcast identity (left) and channel bug (right). The
       previous floating × was removed in favor of the persistent bottom-nav. */
    justify-content: space-between;
    padding: calc(env(safe-area-inset-top, 0px) + 18px) 22px 0;
    gap: 16px;
}
.tv-row-top > * { pointer-events: auto; }

/* Broadcast identity block — wordmark + status line. Stays left-aligned and
   carries everything that describes "this is HPPN TV broadcasting from X".
   The status line wraps below the wordmark if a wide city name forces it. */
.tv-wordmark-block {
    display: inline-flex;
    flex-direction: column;
    align-items: flex-start;
    min-width: 0;
}
.tv-status-row {
    display: inline-flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 6px 8px;
    margin-top: 6px;
    font-family: 'DM Sans', system-ui, sans-serif;
    font-weight: 500;
    font-size: clamp(9px, 1.6vw, 11px);
    letter-spacing: 0.32em;
    color: rgba(246, 239, 226, 0.65);
    text-transform: uppercase;
}
.tv-status-text {
    color: rgba(246, 239, 226, 0.78);
}
.tv-status-sep {
    color: rgba(246, 239, 226, 0.35);
    font-weight: 700;
    letter-spacing: 0;
}

/* Area / city pill — inline status chip sitting next to "On Air" in the
   identity block. Neutral ink so the warm yellow/red palette stays reserved
   for the channel system. Compact (icon + city + caret), no "AREA" prefix
   because the surrounding context already says we're broadcasting from somewhere. */
.tv-area-pill {
    appearance: none;
    display: inline-flex;
    align-items: center;
    gap: 8px;
    padding: 7px 13px 7px 12px;
    border: 1px solid rgba(246, 239, 226, 0.28);
    border-radius: 999px;
    background: rgba(0, 0, 0, 0.42);
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    color: var(--tv-ink);
    font-family: 'DM Sans', sans-serif;
    font-weight: 700;
    font-size: 12px;
    letter-spacing: 0.22em;
    text-transform: uppercase;
    cursor: pointer;
    transition: border-color 180ms ease, color 180ms ease, background 180ms ease, box-shadow 180ms ease, transform 180ms ease;
}
.tv-area-pill > svg { width: 14px; height: 14px; }
.tv-area-pill .tv-area-caret { width: 11px; height: 11px; }
.tv-area-pill:hover,
.tv-area-pill:focus-visible {
    border-color: rgba(246, 239, 226, 0.6);
    background: rgba(0, 0, 0, 0.55);
    box-shadow: 0 0 14px rgba(246, 239, 226, 0.18);
    outline: none;
}
.tv-area-pill:active { transform: translateY(1px); }
.tv-area-name,
.tv-date-name {
    color: var(--tv-ink);
}
.tv-area-caret {
    opacity: 0.7;
}
/* Pill labels always render on a single visual line; long values (e.g. a 3-word
   artist name in the SIMILAR TO pill) truncate with ellipsis instead of wrapping
   the label into a multi-line block that balloons the pill vertically. The
   per-pill max-widths live in the mobile media query below; on desktop the
   parent flex row has room so labels render in full. */
.tv-area-name,
.tv-date-name,
.tv-similar-pill-label,
.tv-near-pill-label,
.tv-quick-pill-label {
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    min-width: 0;
    max-width: 100%;
}
/* Date pill leans on the same chip styling as the area pill; an active
   filter is hinted with a warmer border + subtle glow so the user can see
   at a glance that DATES is filtering the broadcast. */
.tv-date-pill.is-active {
    border-color: rgba(255, 226, 122, 0.55);
    color: var(--tv-glow);
    box-shadow: 0 0 12px rgba(255, 226, 122, 0.18);
}
.tv-date-pill.is-active .tv-date-name {
    color: var(--tv-glow);
}
/* Filter toggle pills (NEAR YOU, <10 MIN). Inactive = muted outline only so
   it recedes; active = filled + outlined in the pink accent with a soft glow
   so "this filter is on" is unmistakable at a glance. */
.tv-near-pill:not(.is-active),
.tv-quick-pill:not(.is-active) {
    border-color: rgba(246, 239, 226, 0.22);
    background: rgba(0, 0, 0, 0.34);
}
.tv-near-pill.is-active,
.tv-quick-pill.is-active {
    border-color: rgba(255, 61, 110, 0.75);
    background: linear-gradient(135deg, rgba(255, 61, 110, 0.26), rgba(255, 61, 110, 0.12));
    color: #fff;
    box-shadow: 0 0 16px rgba(255, 61, 110, 0.4);
}
.tv-near-pill.is-active .tv-near-pill-label,
.tv-quick-pill.is-active .tv-quick-pill-label {
    color: #fff;
}

/* Navigation arrow buttons (desktop-friendly; also visible on mobile) */
.tv-nav {
    appearance: none;
    position: absolute;
    z-index: 25;
    pointer-events: auto;
    border: 1px solid rgba(255, 226, 122, 0.4);
    background: rgba(0, 0, 0, 0.55);
    color: var(--tv-ink);
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    cursor: pointer;
    font-family: 'DM Sans', sans-serif;
    font-weight: 700;
    font-size: 11px;
    letter-spacing: 0.24em;
    text-transform: uppercase;
    transition: opacity 280ms ease, background 180ms ease, border-color 180ms ease, color 180ms ease, transform 180ms ease;
}
.tv-nav:hover,
.tv-nav:focus-visible {
    background: rgba(255, 226, 122, 0.14);
    border-color: rgba(255, 226, 122, 0.85);
    color: var(--tv-glow);
    outline: none;
}
.tv-nav:active { transform: translateY(1px); }
.tv-nav svg { width: 18px; height: 18px; }

/* Channel rail — horizontal strip below the top row showing prev/current/next
   channels TikTok-style. Click any tab to jump; horizontal swipe on the stage
   surfs through them; on desktop, flanking chevrons advance one at a time. */
.tv-row-rail {
    position: relative;
    z-index: 24;
    display: flex;
    align-items: center;
    gap: 6px;
    padding: 6px 14px 0;
    pointer-events: auto;
    width: 100%;
    max-width: 100%;
    min-width: 0;
    box-sizing: border-box;
}
.tv-rail-chev {
    appearance: none;
    width: 34px;
    height: 34px;
    flex-shrink: 0;
    border-radius: 999px;
    border: 1px solid rgba(246, 239, 226, 0.18);
    background: rgba(0, 0, 0, 0.42);
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    color: rgba(246, 239, 226, 0.78);
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    transition: background 180ms ease, border-color 180ms ease, color 180ms ease, transform 180ms ease, box-shadow 180ms ease;
}
.tv-rail-chev svg { width: 16px; height: 16px; }
.tv-rail-chev:hover,
.tv-rail-chev:focus-visible {
    background: rgba(255, 226, 122, 0.12);
    border-color: rgba(255, 226, 122, 0.55);
    color: var(--tv-glow);
    box-shadow: 0 0 14px rgba(255, 226, 122, 0.18);
    outline: none;
}
.tv-rail-chev:active { transform: translateY(1px); }

.tv-channel-rail {
    flex: 1 1 0;
    min-width: 0;
    overflow-x: auto;
    overflow-y: hidden;
    scrollbar-width: none;
    scroll-behavior: smooth;
    -webkit-overflow-scrolling: touch;
    /* Side fade masks. The fade region is wide enough to swallow a partial
       chip whole — a narrower fade leaves half-words like "OP" (instead of
       "POP") readable at the edge, which reads as a rendering bug. */
    mask-image: linear-gradient(to right, transparent 0, #000 72px, #000 calc(100% - 72px), transparent 100%);
    -webkit-mask-image: linear-gradient(to right, transparent 0, #000 72px, #000 calc(100% - 72px), transparent 100%);
}
.tv-channel-rail::-webkit-scrollbar { display: none; }
.tv-channel-rail-track {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 4px 8px;
}

.tv-channel-tab {
    appearance: none;
    flex-shrink: 0;
    padding: 8px 14px;
    border-radius: 999px;
    border: 1px solid rgba(246, 239, 226, 0.22);
    background: rgba(0, 0, 0, 0.38);
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    color: rgba(246, 239, 226, 0.78);
    font-family: 'DM Sans', sans-serif;
    font-weight: 700;
    font-size: 11px;
    letter-spacing: 0.22em;
    text-transform: uppercase;
    white-space: nowrap;
    cursor: pointer;
    transition: background 180ms ease, border-color 180ms ease, color 180ms ease, transform 180ms ease, box-shadow 180ms ease;
}
.tv-channel-tab:hover,
.tv-channel-tab:focus-visible {
    background: rgba(255, 226, 122, 0.10);
    border-color: rgba(255, 226, 122, 0.5);
    color: var(--tv-ink);
    outline: none;
}
.tv-channel-tab.active {
    background:
        radial-gradient(120% 180% at 50% 130%, rgba(255, 61, 110, 0.22), transparent 60%),
        rgba(255, 226, 122, 0.08);
    border-color: rgba(255, 226, 122, 0.9);
    color: #fff;
    box-shadow:
        0 0 18px rgba(255, 226, 122, 0.32),
        inset 0 0 0 1px rgba(255, 255, 255, 0.10);
    transform: scale(1.04);
    text-shadow: 0 0 8px rgba(255, 226, 122, 0.45);
}
/* Tiny "ON AIR" caret that lights up only on the active chip — gives the
   selected channel a TV-style indicator instead of relying on color alone. */
.tv-channel-tab.active::before {
    content: "";
    display: inline-block;
    width: 5px;
    height: 5px;
    margin-right: 6px;
    margin-left: -1px;
    border-radius: 999px;
    background: var(--tv-accent);
    box-shadow: 0 0 5px var(--tv-accent), 0 0 10px rgba(255, 61, 110, 0.5);
    animation: tv-rec-blink 1.4s ease-in-out infinite;
    vertical-align: 1px;
}

/* Mobile — slimmer chips, smaller chevrons, less side padding. The fade
   mask narrows here so the centered active chip + 1 neighbor still has
   room to breathe in a 360px viewport, while still hiding partial words. */
@media (max-width: 540px) {
    .tv-row-rail { padding: 2px 8px 0; gap: 4px; }
    .tv-rail-chev { width: 30px; height: 30px; }
    .tv-rail-chev svg { width: 15px; height: 15px; }
    .tv-channel-tab { padding: 5px 11px; font-size: 10px; letter-spacing: 0.18em; }
    .tv-channel-rail {
        mask-image: linear-gradient(to right, transparent 0, #000 52px, #000 calc(100% - 52px), transparent 100%);
        -webkit-mask-image: linear-gradient(to right, transparent 0, #000 52px, #000 calc(100% - 52px), transparent 100%);
    }
}

/* On really wide desktop screens the chevrons can be hidden — keyboard, swipe,
   wheel, and tab-clicks already cover navigation. */
/* (intentionally always shown for now — easy to discover) */

/* Inline artist prev/next chevrons that flank the action button row */
.tv-action-nav {
    appearance: none;
    width: 36px;
    height: 36px;
    padding: 0;
    border-radius: 999px;
    border: 1px solid rgba(246, 239, 226, 0.35);
    background: rgba(0, 0, 0, 0.5);
    color: var(--tv-ink);
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    transition: background 180ms ease, border-color 180ms ease, color 180ms ease, transform 180ms ease;
}
.tv-action-nav svg { width: 16px; height: 16px; }
.tv-action-nav:hover,
.tv-action-nav:focus-visible {
    background: rgba(255, 226, 122, 0.16);
    border-color: rgba(255, 226, 122, 0.85);
    color: var(--tv-glow);
    outline: none;
}
.tv-action-nav:active { transform: translateY(1px); }

/* Hide drag-style arrows on idle to match the actions fade */
.tv-stage.idle .tv-nav { opacity: 0; pointer-events: none; }

@media (max-width: 540px) {
    /* Tighter pill chrome on mobile — smaller font, narrower side padding,
       tighter icon→label gap. Combined with `min-width: 0` + `max-width: 100%`,
       the pills can shrink into the wordmark-block's available row width
       (instead of overflowing) and the similar-pill label below truncates
       with ellipsis when an artist name is too long for the row. */
    .tv-area-pill {
        font-size: 11px;
        padding: 6px 11px 6px 10px;
        letter-spacing: 0.16em;
        gap: 6px;
        min-width: 0;
        max-width: 100%;
    }
    .tv-area-pill > svg { width: 13px; height: 13px; }
    .tv-area-pill .tv-area-caret { width: 10px; height: 10px; }
    /* Cap the SIMILAR TO label so a long anchor artist (e.g. "KEROSENE
       HEIGHTS") truncates with ellipsis instead of pushing the pill past
       the available row width and forcing siblings (NEAR YOU / <10 MIN)
       to wrap into their own full-width rows below. 46vw leaves room for
       at least one of NEAR / <10 MIN inline beside it on a 360px+ phone. */
    .tv-similar-pill-label { max-width: 46vw; }
    .tv-action-nav { width: 32px; height: 32px; }
    .tv-action-nav svg { width: 14px; height: 14px; }
}

.tv-wordmark {
    font-family: 'Fraunces', 'Libre Baskerville', serif;
    font-weight: 900;
    /* Desktop reads as a broadcast title, mobile collapses to a tight inline
       brand mark so the top bar doesn't dominate the viewport. The mobile
       max (28px) is enforced below in the @media (max-width: 540px) cluster. */
    font-size: clamp(22px, 4.6vw, 44px);
    letter-spacing: 0.04em;
    line-height: 0.95;
    color: var(--tv-glow);
    text-shadow: var(--tv-shadow);
    display: inline-flex;
    align-items: center;
    gap: 8px;
}
.tv-wordmark::before {
    content: "";
    display: inline-block;
    width: 9px;
    height: 9px;
    border-radius: 999px;
    background: var(--tv-accent);
    box-shadow: 0 0 14px var(--tv-accent), 0 0 28px rgba(255, 61, 110, 0.4);
    margin-right: 8px;
    animation: tv-rec-blink 1.4s ease-in-out infinite;
}
@keyframes tv-rec-blink {
    0%, 60%, 100% { opacity: 1; }
    70%, 90%      { opacity: 0.25; }
}
/* Queue position indicator — a compact "pos/total" readout wrapped in a
   circular progress ring that fills clockwise as the user moves through the
   queue. Replaces the old "X OF Y SIMILAR" channel badge: same top-right
   corner, but reads at a glance as ambient HUD rather than prose. */
.tv-pos-indicator {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 48px;
    height: 48px;
    flex-shrink: 0;
    pointer-events: none;
    font-family: 'DM Sans', sans-serif;
}

@media (min-width: 541px) {
    .tv-pos-indicator { margin-left: auto; }
}

.tv-pos-ring {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    /* Start the fill at 12 o'clock and grow clockwise. */
    transform: rotate(-90deg);
    overflow: visible;
}
.tv-pos-ring-bg,
.tv-pos-ring-fg {
    fill: none;
    stroke-width: 2.5;
    stroke-linecap: round;
}
.tv-pos-ring-bg {
    stroke: rgba(246, 239, 226, 0.16);
}
.tv-pos-ring-fg {
    stroke: var(--tv-accent);
    /* pathLength=100 on the <circle> normalizes the geometry so JS only has
       to set dashoffset = 100 - percent. */
    stroke-dasharray: 100;
    stroke-dashoffset: 100;
    filter: drop-shadow(0 0 4px rgba(255, 61, 110, 0.55));
    transition: stroke-dashoffset 320ms ease;
}
.tv-pos-text {
    position: relative;
    z-index: 1;
    font-weight: 700;
    font-size: 10px;
    letter-spacing: 0.01em;
    color: var(--tv-ink);
    text-shadow: 0 1px 4px rgba(0, 0, 0, 0.75);
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}
.tv-pos-sep { opacity: 0.4; margin: 0 0.5px; }
.tv-pos-total { opacity: 0.65; }

/* Top-right exit X — full close of the TV stage. The similar-pill X is
   for clearing the filter (stays inside TV); this is the actual leave-the-
   stage affordance, always visible regardless of mode. */
.tv-stage-close {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 36px;
    height: 36px;
    flex-shrink: 0;
    margin-left: 8px;
    border-radius: 50%;
    background: rgba(0, 0, 0, 0.55);
    border: 1px solid rgba(246, 239, 226, 0.18);
    color: var(--tv-ink);
    cursor: pointer;
    padding: 0;
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    box-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
    transition: transform 140ms ease, background 140ms ease, border-color 140ms ease;
    -webkit-tap-highlight-color: transparent;
}
.tv-stage-close:hover,
.tv-stage-close:focus-visible {
    transform: scale(1.06);
    background: rgba(0, 0, 0, 0.78);
    border-color: rgba(255, 61, 110, 0.55);
    outline: none;
}
.tv-stage-close:active { transform: scale(0.96); }

/* Channel menu (dropdown) */
.tv-channel-menu {
    position: absolute;
    top: calc(100% + 10px);
    right: 0;
    min-width: 240px;
    max-width: min(86vw, 360px);
    max-height: 60vh;
    overflow-y: auto;
    overflow-x: hidden;
    background: rgba(8, 4, 14, 0.92);
    border: 1px solid rgba(255, 226, 122, 0.45);
    border-radius: 8px;
    box-shadow: 0 18px 48px rgba(0,0,0,0.65), 0 0 24px rgba(255, 226, 122, 0.18);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    padding: 6px;
    z-index: 40;
    pointer-events: auto;
    animation: tv-menu-pop 160ms ease-out;
}
.tv-channel-menu[hidden] { display: none; }
@keyframes tv-menu-pop {
    0%   { opacity: 0; transform: translateY(-6px) scale(0.98); }
    100% { opacity: 1; transform: translateY(0) scale(1); }
}
.tv-channel-menu-item {
    appearance: none;
    width: 100%;
    display: flex;
    align-items: center;
    justify-content: flex-start;
    gap: 12px;
    padding: 9px 12px;
    border: 1px solid transparent;
    border-radius: 6px;
    background: transparent;
    color: var(--tv-ink);
    font-family: 'DM Sans', sans-serif;
    font-weight: 600;
    font-size: 12.5px;
    letter-spacing: 0.16em;
    text-transform: uppercase;
    text-align: left;
    cursor: pointer;
    transition: background 140ms ease, border-color 140ms ease, color 140ms ease;
}
.tv-channel-menu-item:hover,
.tv-channel-menu-item:focus-visible {
    background: rgba(255, 226, 122, 0.10);
    border-color: rgba(255, 226, 122, 0.4);
    outline: none;
    color: var(--tv-glow);
}
.tv-channel-menu-item.active {
    background: rgba(255, 61, 110, 0.14);
    border-color: rgba(255, 61, 110, 0.55);
    color: #fff;
}
.tv-channel-menu-num {
    font-family: 'Fraunces', serif;
    font-weight: 800;
    font-size: 11px;
    letter-spacing: 0.22em;
    color: var(--tv-glow);
    min-width: 38px;
}
.tv-channel-menu-name {
    flex: 1;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.tv-channel-menu-count {
    font-family: 'DM Sans', sans-serif;
    font-weight: 500;
    font-size: 10.5px;
    letter-spacing: 0.18em;
    color: rgba(246, 239, 226, 0.55);
    margin-left: auto;
}

/* Desktop transport row — explicit prev/play/next replaces wheel-scrolling
   on >=920px viewports. Hidden on mobile/tablet, where touch swipe + the
   scrubber already provide a natural transport. Buttons are real <button>s
   so the existing _tvShouldIgnoreSwipe button-selector keeps stage drag /
   wheel handlers from firing when the user hits transport. */
.tv-transport {
    display: none;
    align-items: center;
    justify-content: center;
    gap: 14px;
    padding: 4px 22px 6px;
    pointer-events: auto;
    transition: opacity 320ms ease;
    opacity: 0.95;
}
.tv-stage.idle .tv-transport {
    opacity: 0;
    pointer-events: none;
}
.tv-transport-btn {
    appearance: none;
    width: 44px;
    height: 44px;
    padding: 0;
    border-radius: 999px;
    border: 1px solid rgba(255, 226, 122, 0.45);
    background: rgba(0, 0, 0, 0.55);
    color: var(--tv-ink);
    cursor: pointer;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    transition: background 180ms ease, border-color 180ms ease, color 180ms ease, transform 180ms ease, box-shadow 180ms ease;
}
.tv-transport-btn:hover,
.tv-transport-btn:focus-visible {
    background: rgba(255, 226, 122, 0.16);
    border-color: rgba(255, 226, 122, 0.85);
    color: var(--tv-glow);
    box-shadow: 0 0 18px rgba(255, 226, 122, 0.3);
    outline: none;
}
.tv-transport-btn:active { transform: translateY(1px) scale(0.97); }
.tv-transport-btn svg { width: 18px; height: 18px; }
/* Center play/pause is the primary action — slightly larger and pre-glowed */
.tv-transport-play {
    width: 56px;
    height: 56px;
    border-color: rgba(255, 226, 122, 0.7);
    background: rgba(255, 226, 122, 0.16);
    color: var(--tv-glow);
    box-shadow: 0 0 18px rgba(255, 226, 122, 0.32);
}
.tv-transport-play svg { width: 22px; height: 22px; }
/* Stack play + pause SVGs and toggle visibility via .is-playing on the button.
   Default visible icon is "play" (action available when paused/stopped). */
.tv-transport-play .tv-transport-icon-pause { display: none; }
.tv-transport-play.is-playing .tv-transport-icon-play { display: none; }
.tv-transport-play.is-playing .tv-transport-icon-pause { display: inline-block; }

@media (min-width: 920px) {
    .tv-transport { display: flex; }
}

/* Video scrubber — appears in the row above .tv-actions */
.tv-scrub {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 4px 22px 0;
    pointer-events: auto;
    transition: opacity 320ms ease;
    opacity: 0.95;
}
.tv-stage.idle .tv-scrub {
    opacity: 0;
    pointer-events: none;
}
.tv-scrub-time {
    font-family: 'DM Sans', sans-serif;
    font-weight: 700;
    font-size: 11px;
    letter-spacing: 0.18em;
    color: rgba(246, 239, 226, 0.85);
    text-shadow: 0 0 6px rgba(0,0,0,0.6);
    min-width: 38px;
    text-align: center;
    font-variant-numeric: tabular-nums;
}
.tv-scrub-track {
    position: relative;
    flex: 1;
    height: 22px;
    display: flex;
    align-items: center;
    cursor: pointer;
    touch-action: none;
}
.tv-scrub-track::before {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    top: 50%;
    height: 3px;
    transform: translateY(-50%);
    background: rgba(255, 255, 255, 0.18);
    border-radius: 2px;
}
.tv-scrub-fill {
    position: absolute;
    left: 0;
    top: 50%;
    height: 3px;
    width: 0%;
    transform: translateY(-50%);
    background: linear-gradient(90deg, var(--tv-glow), var(--tv-accent));
    border-radius: 2px;
    box-shadow: 0 0 10px rgba(255, 226, 122, 0.55);
    pointer-events: none;
    transition: width 160ms linear;
}
.tv-scrub-handle {
    position: absolute;
    top: 50%;
    left: 0%;
    width: 12px;
    height: 12px;
    border-radius: 999px;
    transform: translate(-50%, -50%);
    background: var(--tv-accent);
    box-shadow: 0 0 10px var(--tv-accent), 0 0 18px rgba(255, 61, 110, 0.6);
    pointer-events: none;
    transition: left 160ms linear, transform 120ms ease;
}
.tv-scrub-track:hover .tv-scrub-handle,
.tv-scrub-track:active .tv-scrub-handle {
    transform: translate(-50%, -50%) scale(1.18);
}
.tv-channel-num {
    font-family: 'Fraunces', serif;
    font-weight: 900;
    font-size: clamp(22px, 5vw, 36px);
    letter-spacing: 0.06em;
    color: var(--tv-glow);
    text-shadow: 0 0 10px rgba(255, 226, 122, 0.55);
    line-height: 1;
}
.tv-channel-num small {
    display: inline-block;
    font-size: 0.55em;
    letter-spacing: 0.32em;
    color: rgba(246, 239, 226, 0.7);
    margin-right: 6px;
    vertical-align: 0.25em;
}
.tv-channel-name {
    margin-top: 4px;
    font-size: 11px;
    letter-spacing: 0.28em;
    text-transform: uppercase;
    color: rgba(246, 239, 226, 0.85);
}


/* Brief scale + glow when the channel changes, so the switch registers in
   peripheral vision even if the user is looking at the rail. */
.tv-channel-num.pulse {
    animation: tv-channel-pulse 460ms ease-out;
}
@keyframes tv-channel-pulse {
    0%   { transform: scale(1);    color: var(--tv-glow); text-shadow: 0 0 10px rgba(255, 226, 122, 0.55); }
    35%  { transform: scale(1.18); color: #ffffff;       text-shadow: 0 0 22px rgba(255, 226, 122, 0.95), 0 0 38px rgba(255, 61, 110, 0.55); }
    100% { transform: scale(1);    color: var(--tv-glow); text-shadow: 0 0 10px rgba(255, 226, 122, 0.55); }
}

/* Spacer / video focus zone */
.tv-row-mid {
    /* purely a layout spacer; clicks pass through */
}

/* Bottom row */
.tv-row-bottom {
    display: flex;
    align-items: flex-end;
    justify-content: space-between;
    gap: 16px;
    /* Right padding reserves a column for the absolutely-positioned
       .tv-actions-rail (≈64px wide) so the upcoming card never runs under
       the heart/comment/share dock. The dock now anchors to the lower-third
       baseline (see .tv-actions-rail bottom override) so the two rails sit
       on a shared horizontal axis. Mobile re-overrides this — the dock
       there sits beside the lower-third already via cluster-top math. */
    padding: 12px 88px 6px 22px;
}
.tv-row-bottom > * { pointer-events: auto; }

.tv-now-playing {
    max-width: 70%;
}
.tv-now-playing-label {
    font-size: 11px;
    letter-spacing: 0.36em;
    text-transform: uppercase;
    color: var(--tv-cyan);
    text-shadow: 0 0 10px rgba(78, 226, 255, 0.45);
    margin-bottom: 6px;
}
.tv-now-playing-name {
    font-family: 'Fraunces', serif;
    font-weight: 800;
    font-size: clamp(28px, 7vw, 56px);
    line-height: 1;
    letter-spacing: 0.005em;
    color: var(--tv-ink);
    text-shadow: 0 2px 24px rgba(0, 0, 0, 0.85), 0 0 30px rgba(255, 226, 122, 0.12);
    word-break: break-word;
    /* Now a tappable <button> that pivots into similar-artists mode for the
       current artist. Strip the default chrome so it still reads as a title. */
    background: transparent;
    border: none;
    padding: 0;
    text-align: left;
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
    transition: color 160ms ease, text-shadow 160ms ease;
}
.tv-now-playing-name:hover,
.tv-now-playing-name:focus-visible {
    color: var(--tv-glow);
    text-shadow: 0 2px 24px rgba(0, 0, 0, 0.85), 0 0 36px rgba(255, 226, 122, 0.45);
    outline: none;
}
/* "Similar to <X>" filter pill on the TV stage in list mode. Inherits most
   of its look from .tv-area-pill; this just tunes the accent color so it
   reads as a "filter" indicator rather than a passive context chip. */
.tv-similar-pill {
    border-color: rgba(255, 61, 110, 0.55) !important;
    background: linear-gradient(135deg, rgba(20, 8, 14, 0.85), rgba(38, 8, 22, 0.85)) !important;
    color: #f6efe2 !important;
    box-shadow: 0 0 14px rgba(255, 61, 110, 0.32) !important;
}
/* The pill is a .tv-area-pill, which forces display:flex; respect the
   [hidden] attribute so JS can hide/show it without manual style toggles. */
.tv-similar-pill[hidden] { display: none !important; }
.tv-similar-pill .tv-similar-pill-x {
    opacity: 0.7;
}
.tv-similar-pill:hover .tv-similar-pill-x {
    opacity: 1;
}
/* "Near You" companion to the similar-to pill — only shown in list mode and
   only when at least one similar artist is flagged near_user. Active/inactive
   styling is shared with the <10 MIN toggle (see the filter-toggle block
   above) so the user has one visual vocabulary for "filter is on". */
.tv-near-pill[hidden] { display: none !important; }
/* WHEN pill — shows the active "Shows near you" time slice (Tonight /
   Tomorrow / This Weekend / This Month) when the user entered TV mode via
   the home rail's Watch button. Visually mirrors the similar-to pill (it's
   also a clearable filter) but uses a warmer color so the two chips read
   as different concepts. */
.tv-when-pill[hidden] { display: none !important; }
.tv-when-pill {
    border-color: rgba(255, 226, 122, 0.55) !important;
    background: linear-gradient(135deg, rgba(28, 18, 8, 0.85), rgba(38, 28, 12, 0.85)) !important;
    color: var(--tv-glow) !important;
    box-shadow: 0 0 12px rgba(255, 226, 122, 0.18) !important;
}
.tv-when-pill .tv-when-pill-x {
    opacity: 0.7;
}
.tv-when-pill:hover .tv-when-pill-x {
    opacity: 1;
}
.tv-when-pill-label {
    color: var(--tv-glow);
}
.tv-now-playing-meta {
    margin-top: 10px;
    font-size: 12px;
    letter-spacing: 0.3em;
    text-transform: uppercase;
    color: rgba(246, 239, 226, 0.92);
    display: flex;
    flex-wrap: wrap;
    gap: 10px 16px;
    align-items: center;
}
.tv-meta-dot {
    display: inline-block;
    width: 4px;
    height: 4px;
    border-radius: 999px;
    background: rgba(246, 239, 226, 0.45);
}
.tv-genre {
    color: var(--tv-mint);
    text-shadow: 0 0 10px rgba(110, 255, 184, 0.35);
}

/* Upcoming-show line. Always rendered as a <button> for a11y/keyboard parity.
   Collapsed to a single ambient line — no boxed container, no separate label
   or CTA chip. When the current artist has a real show (`.is-clickable`) the
   line gets a leading "→" and is a tap target that deep-links to the show
   (this IS the view-show affordance). Without `.is-clickable` it's inert
   "Up next — …" info so users don't expect a ticket flow that doesn't exist. */
.tv-upcoming {
    appearance: none;
    background: transparent;
    border: 0;
    padding: 6px 2px;
    text-align: right;
    max-width: 46%;
    color: rgba(246, 239, 226, 0.9);
    font: inherit;
    font-size: 12px;
    letter-spacing: 0.18em;
    text-transform: uppercase;
    line-height: 1.4;
    cursor: default;
    transition: color 180ms ease, opacity 180ms ease, transform 160ms ease;
}
.tv-upcoming.is-clickable { cursor: pointer; }
.tv-upcoming.is-clickable:hover,
.tv-upcoming.is-clickable:focus-visible {
    color: #fff;
    outline: none;
    transform: translateY(-1px);
}
.tv-upcoming.is-clickable:active { transform: translateY(0) scale(0.99); }
.tv-upcoming:disabled {
    opacity: 0.75;
    color: rgba(246, 239, 226, 0.82);
    cursor: default;
}
/* Label + CTA chip collapsed away — the single line carries everything. */
.tv-upcoming-label,
.tv-upcoming-cta { display: none !important; }
.tv-upcoming-line {
    font-size: 13px;
    letter-spacing: 0.14em;
    color: inherit;
}
/* Leading arrow doubles as the "view show" affordance on the clickable line. */
.tv-upcoming.is-clickable .tv-upcoming-line::before {
    content: "→ ";
    color: var(--tv-accent);
    font-weight: 700;
}
/* Performing-near-you: same single line, mint accent so a local show pops. */
.tv-upcoming.is-near.is-clickable .tv-upcoming-line {
    color: var(--tv-mint, #6effb8);
    text-shadow: 0 0 10px rgba(110, 255, 184, 0.4);
}
.tv-upcoming.is-near.is-clickable .tv-upcoming-line::before {
    color: var(--tv-mint, #6effb8);
}

/* TikTok-style vertical action rail — anchored to the BOTTOM-right of the
   stage so the avatar/heart/comment/share never block the artist's face.
   The rail clears the scrubber/ticker that live in the bottom rows of the
   overlay grid (~30px ticker + ~30px scrubber + safe-area), and stays
   visible at all times — it does NOT participate in the idle fade so users
   can always reach Save/Share without having to wake the chrome first.
   The avatar sits proud at the top; save/comment/share live inside a
   .tv-rail-dock glass pill so they read as a single attached control unit. */
.tv-actions-rail {
    position: absolute;
    right: max(14px, env(safe-area-inset-right, 0px));
    /* Anchors to the lower-third baseline so the dock visually belongs WITH
       the artist info rather than floating in dead space. The dock now holds
       the avatar as its top item plus save/comment/share, so the bottom
       offset just needs to clear the ticker + scrubber + lower-third padding.
       56px = 24 (ticker) + 28 (scrub) + 4 (gap). --tv-nav-clearance lifts
       above the mobile bottom-nav (0 on desktop). */
    bottom: calc(56px + env(safe-area-inset-bottom, 0px) + var(--tv-nav-clearance, 0px));
    z-index: 25;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 10px;
    pointer-events: auto;
}

/* Glass pill containing save/comment/share — single attached control surface
   instead of three free-floating icons. Background reuses the shared glass
   tokens so the pill matches the bottom-nav material at the same depth. */
.tv-rail-dock {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 6px;
    padding: 8px 6px;
    border-radius: 36px;
    background: var(--tv-glass-bg);
    border: 1px solid var(--tv-glass-border);
    backdrop-filter: var(--tv-glass-blur);
    -webkit-backdrop-filter: var(--tv-glass-blur);
    box-shadow: var(--tv-glass-shadow);
}
/* Buttons inside the dock drop their per-icon glass — the dock provides the
   blur surface, and a nested blur on each icon would double-frost. The hover
   highlight stays so the active button still glows. */
.tv-rail-dock .tv-rail-icon {
    background: transparent;
    box-shadow: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
}
.tv-rail-dock .tv-rail-btn:hover .tv-rail-icon,
.tv-rail-dock .tv-rail-btn:focus-visible .tv-rail-icon {
    background: rgba(255, 226, 122, 0.16);
    box-shadow: 0 0 16px rgba(255, 226, 122, 0.3);
}
.tv-rail-dock .tv-rail-save.is-saved .tv-rail-icon {
    background: rgba(255, 61, 110, 0.22);
    box-shadow: 0 0 16px rgba(255, 61, 110, 0.5);
}

/* Circular avatar — top item in the action dock, tappable through to the
   artist profile. Sized to match the icon buttons below it and separated by a
   hairline so it reads as the head of the stack, not another action button. */
.tv-rail-avatar {
    position: relative;
    display: block;
    width: 48px;
    height: 48px;
    border-radius: 999px;
    border: 1.5px solid rgba(246, 239, 226, 0.8);
    background: linear-gradient(135deg, #2a1830, #3d1140);
    cursor: pointer;
    text-decoration: none;
    flex-shrink: 0;
    margin-bottom: 4px;
    box-shadow: 0 2px 8px rgba(0,0,0,0.5);
    transition: transform 180ms ease, border-color 180ms ease, box-shadow 180ms ease;
}
.tv-rail-avatar::after {
    /* Hairline divider between the avatar and the action buttons. */
    content: "";
    position: absolute;
    left: 50%;
    bottom: -6px;
    width: 26px;
    height: 1px;
    transform: translateX(-50%);
    background: var(--tv-glass-border, rgba(246, 239, 226, 0.18));
}
.tv-rail-avatar:hover,
.tv-rail-avatar:focus-visible {
    transform: scale(1.06);
    border-color: var(--tv-glow);
    box-shadow: 0 2px 10px rgba(0,0,0,0.55), 0 0 16px rgba(255, 226, 122, 0.4);
    outline: none;
}
.tv-rail-avatar:active { transform: scale(0.96); }
.tv-rail-avatar-img {
    position: absolute;
    inset: 0;
    border-radius: 999px;
    background-size: cover;
    background-position: center;
    background-repeat: no-repeat;
}

/* Save / Comment / Share — circular frosted icon with an OPTIONAL count below.
   The count slot is only rendered (and only contributes flex height) when the
   button has a non-empty value — see `.tv-rail-count:empty` below. The btn
   gap stays 0 by default and only flips to 4px when there's actually a count
   to separate from the icon, so empty buttons collapse to icon-height and the
   rail can sit compact under the video frame. */
.tv-rail-btn {
    appearance: none;
    background: transparent;
    border: 0;
    padding: 0;
    cursor: pointer;
    color: var(--tv-ink);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0;
    transition: transform 160ms ease;
}
.tv-rail-btn:has(.tv-rail-count:not(:empty)) { gap: 4px; }
.tv-rail-btn:focus-visible { outline: none; }
.tv-rail-btn:active { transform: scale(0.94); }
.tv-rail-icon {
    position: relative;
    width: 48px;
    height: 48px;
    border-radius: 999px;
    background: rgba(0, 0, 0, 0.5);
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    box-shadow: 0 4px 14px rgba(0,0,0,0.45);
    transition: background 180ms ease, box-shadow 180ms ease;
}
.tv-rail-btn:hover .tv-rail-icon,
.tv-rail-btn:focus-visible .tv-rail-icon {
    background: rgba(255, 226, 122, 0.16);
    box-shadow: 0 4px 18px rgba(0,0,0,0.55), 0 0 18px rgba(255, 226, 122, 0.35);
}
.tv-rail-icon svg {
    width: 24px;
    height: 24px;
    color: var(--tv-ink);
    filter: drop-shadow(0 1px 2px rgba(0,0,0,0.55));
}
.tv-rail-count {
    font-family: 'DM Sans', sans-serif;
    font-weight: 700;
    font-size: 11px;
    letter-spacing: 0.02em;
    color: rgba(246, 239, 226, 0.95);
    text-shadow: 0 1px 6px rgba(0,0,0,0.7);
    line-height: 1;
}
/* Collapse the count entirely when there's no value to show. Without this,
   the empty <span> reserves ~13px under every icon and stretches the rail
   vertically — the visual "extra padding" the rail had below the video. */
.tv-rail-count:empty { display: none; }

/* "More like this" CTA pill — sits inline with the now-playing card so the
   pivot-to-similar action is right where the user's attention already is.
   Accent-colored and arrowed so it reads as a directional next-step, not
   another passive label. */
.tv-more-like-this {
    margin-top: 14px;
    display: inline-flex;
    align-items: center;
    gap: 8px;
    padding: 10px 16px 10px 18px;
    border: 1px solid rgba(255, 61, 110, 0.55);
    border-radius: 999px;
    background: linear-gradient(135deg, rgba(20, 8, 14, 0.92), rgba(38, 8, 22, 0.92));
    color: var(--tv-ink);
    font-family: 'DM Sans', sans-serif;
    font-size: 12px;
    font-weight: 700;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    cursor: pointer;
    box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4), 0 0 16px rgba(255, 61, 110, 0.32);
    transition: transform 140ms ease, background 140ms ease, box-shadow 140ms ease, gap 140ms ease;
    -webkit-tap-highlight-color: transparent;
    line-height: 1;
    align-self: flex-start;
}
.tv-more-like-this:hover,
.tv-more-like-this:focus-visible {
    transform: translateY(-1px);
    background: linear-gradient(135deg, rgba(38, 8, 22, 0.95), rgba(58, 12, 32, 0.95));
    box-shadow: 0 6px 22px rgba(0, 0, 0, 0.5), 0 0 24px rgba(255, 61, 110, 0.55);
    gap: 12px;
    outline: none;
}
.tv-more-like-this:active { transform: translateY(0); }
.tv-more-like-this svg {
    width: 14px;
    height: 14px;
    opacity: 0.9;
}
.tv-more-like-this-label { line-height: 1; }

/* Heart toggle: stack outline + filled SVGs so the swap is instant and the
   filled state can carry its own color/glow without triggering layout. */
.tv-rail-save .tv-rail-icon-fill { display: none; }
.tv-rail-save.is-saved .tv-rail-icon-outline { display: none; }
.tv-rail-save.is-saved .tv-rail-icon-fill {
    display: block;
    color: var(--tv-accent);
}
.tv-rail-save.is-saved .tv-rail-icon {
    background: rgba(255, 61, 110, 0.22);
    box-shadow: 0 0 18px rgba(255, 61, 110, 0.55);
}

/* Bottom broadcast ticker — quiet broadcast accent. Toned down ~25%: lower
   text opacity, thinner border glow, dimmer star, and a touch shorter so it
   doesn't compete with the lower-third for attention. */
.tv-ticker {
    position: absolute;
    left: 0;
    right: 0;
    /* Sits at the safe-area edge on desktop; lifts above the bottom-nav on
       mobile via --tv-nav-clearance (set on .tv-stage in the mobile media query). */
    bottom: calc(env(safe-area-inset-bottom, 0px) + var(--tv-nav-clearance, 0px));
    z-index: 22;
    border-top: 1px solid rgba(255, 226, 122, 0.18);
    border-bottom: 1px solid rgba(255, 226, 122, 0.18);
    background: linear-gradient(90deg, rgba(0,0,0,0.62), rgba(20,5,30,0.62), rgba(0,0,0,0.62));
    overflow: hidden;
    height: 22px;
    display: flex;
    align-items: center;
}
.tv-ticker-track {
    display: inline-flex;
    white-space: nowrap;
    will-change: transform;
    /* ~50% slower than before (was 42s) — a cinema-credit crawl, not a
       stock ticker, so it reads as ambient peripheral motion. */
    animation: tv-ticker-scroll 84s linear infinite;
    color: var(--tv-glow);
    font-family: 'DM Sans', sans-serif;
    font-weight: 700;
    font-size: 10px;
    letter-spacing: 0.3em;
    text-transform: uppercase;
    text-shadow: none;
    opacity: 0.4;
}
.tv-ticker-track > span {
    padding: 0 22px;
    display: inline-flex;
    align-items: center;
    gap: 12px;
}
.tv-ticker-track > span::before {
    content: "★";
    color: var(--tv-accent);
    font-size: 9px;
    opacity: 0.7;
    text-shadow: none;
}
@keyframes tv-ticker-scroll {
    0%   { transform: translateX(0); }
    100% { transform: translateX(-50%); }
}

/* Tuning indicator — small floating sticker pulse near the top of the
   stage. Replaces the previous full-screen dark curtain; the rest of the
   chrome stays visible (skeleton-shimmered) so the page feels alive while
   the next video loads. */
.tv-no-signal {
    position: absolute;
    top: 18%;
    left: 50%;
    transform: translateX(-50%);
    z-index: 30;
    display: none;
    align-items: center;
    justify-content: center;
    pointer-events: none;
}
.tv-stage.no-signal .tv-no-signal,
.tv-stage.tv-pivoting .tv-no-signal {
    display: flex;
}
.tv-no-signal-sticker {
    width: clamp(72px, 16vw, 120px);
    height: auto;
    filter: drop-shadow(0 8px 24px rgba(0, 0, 0, 0.45));
    animation: tv-no-signal-pulse 1.4s ease-in-out infinite;
}
@keyframes tv-no-signal-pulse {
    0%, 100% { transform: scale(1); opacity: 1; }
    50%      { transform: scale(0.92); opacity: 0.78; }
}

/* Airbnb-style skeleton shimmer for the lower-third while the stage is
   mid-pivot (More-like-this handoff). The chrome stays in its final
   position; only its visible text turns into pulsing slabs so the user
   sees the page shape, not a black curtain.

   Distinct from tv-loading (used for the inter-video swap curtain that
   hides chrome) — this one keeps everything visible and skeletonized. */
.tv-stage.tv-pivoting .tv-video {
    opacity: 0.35;
    transition: opacity 0.18s ease;
}
.tv-stage.tv-pivoting .tv-now-playing-name,
.tv-stage.tv-pivoting .tv-meta-city,
.tv-stage.tv-pivoting .tv-genre,
.tv-stage.tv-pivoting .tv-upcoming-line,
.tv-stage.tv-pivoting .tv-upcoming-cta {
    color: transparent !important;
    text-shadow: none !important;
    background: linear-gradient(90deg,
        rgba(246, 239, 226, 0.10) 0%,
        rgba(246, 239, 226, 0.22) 50%,
        rgba(246, 239, 226, 0.10) 100%);
    background-size: 200% 100%;
    border-radius: 6px;
    animation: tv-skeleton-shimmer 1.2s ease-in-out infinite;
}
.tv-stage.tv-pivoting .tv-meta-dot {
    visibility: hidden;
}
@keyframes tv-skeleton-shimmer {
    0%   { background-position: 100% 0; opacity: 0.7; }
    50%  { opacity: 1; }
    100% { background-position: -100% 0; opacity: 0.7; }
}

/* No-video card for similar handoff — when an artist surfaced via
   /api/similar-artists has no harvested YouTube video. Hides the empty
   YT iframe and lifts the ambient backdrop so the artist photo carries
   the screen; existing overlays (artist name, "SIMILAR TO …" pill,
   counter badge) keep the artist identifiable while the user swipes
   through the list. Pairs with browse.js's tvLoadCurrentArtist branch
   that adds .tv-no-video instead of auto-advancing in _tvMode === 'list'. */
.tv-stage.tv-no-video .tv-video,
.tv-stage.tv-no-video .tv-ambilight {
    visibility: hidden;
}
.tv-stage.tv-no-video .tv-ambient {
    filter: blur(28px) saturate(1.4) brightness(0.7);
    opacity: 0.95;
}

/* Empty state — shown when an area+channel combo legitimately has zero
   live videos (vs. .tv-no-signal which is for transient tuning/static).
   Provides recovery CTAs for the user (switch channel, switch city) so
   they aren't stuck staring at a dead broadcast. */
.tv-empty-state {
    position: absolute;
    inset: 0;
    z-index: 31;
    display: none;
    align-items: center;
    justify-content: center;
    flex-direction: column;
    gap: 14px;
    padding: 32px 24px;
    background: rgba(5, 4, 7, 0.86);
    backdrop-filter: blur(4px);
    -webkit-backdrop-filter: blur(4px);
    color: var(--tv-ink);
    text-align: center;
    pointer-events: auto;
}
.tv-stage.tv-empty .tv-empty-state {
    display: flex;
}

/* Blocked state — surfaced when YouTube refuses to play multiple
   consecutive videos in a row (the canonical trigger is iOS WKWebView
   catching YouTube's "Sign in to confirm you're not a bot" challenge,
   which fires onError on every embed). Reuses the .tv-empty-state
   shell (background curtain, typography, action buttons) for visual
   consistency — both are "stage is intentionally blocked, take an
   action" states — but is mutually exclusive with .tv-empty so a
   blocked broadcast in an otherwise-populated channel doesn't render
   the empty-state message on top of the recovery banner. */
.tv-blocked-state {
    position: absolute;
    inset: 0;
    z-index: 32;
    display: none;
    align-items: center;
    justify-content: center;
    flex-direction: column;
    gap: 14px;
    padding: 32px 24px;
    background: rgba(5, 4, 7, 0.86);
    backdrop-filter: blur(4px);
    -webkit-backdrop-filter: blur(4px);
    color: var(--tv-ink);
    text-align: center;
    pointer-events: auto;
}
.tv-stage.tv-blocked .tv-blocked-state {
    display: flex;
}
/* Hide the empty-state if it happens to also be set — blocked takes
   precedence (it has a specific recovery path; "No live videos" would
   be misleading when the data is fine and only the embed is refused). */
.tv-stage.tv-blocked .tv-empty-state {
    display: none;
}
.tv-empty-title {
    font-family: 'Fraunces', serif;
    font-weight: 900;
    font-size: clamp(22px, 4vw, 36px);
    letter-spacing: 0.06em;
    color: var(--tv-glow);
    text-shadow: var(--tv-shadow);
    text-transform: uppercase;
    line-height: 1.1;
    max-width: 22ch;
}
.tv-empty-sub {
    font-family: 'DM Sans', sans-serif;
    font-weight: 600;
    font-size: 12px;
    letter-spacing: 0.32em;
    color: rgba(246, 239, 226, 0.72);
    text-transform: uppercase;
    max-width: 36ch;
    line-height: 1.5;
}
.tv-empty-actions {
    display: inline-flex;
    flex-wrap: wrap;
    gap: 10px;
    margin-top: 6px;
    justify-content: center;
}
.tv-empty-btn {
    appearance: none;
    border: 1px solid rgba(255, 226, 122, 0.55);
    background: rgba(0, 0, 0, 0.5);
    color: var(--tv-glow);
    padding: 9px 16px;
    border-radius: 999px;
    font-family: 'DM Sans', sans-serif;
    font-weight: 700;
    font-size: 11px;
    letter-spacing: 0.3em;
    text-transform: uppercase;
    cursor: pointer;
    backdrop-filter: blur(6px);
    -webkit-backdrop-filter: blur(6px);
    transition: background 180ms ease, border-color 180ms ease, color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
}
.tv-empty-btn:hover,
.tv-empty-btn:focus-visible {
    background: rgba(255, 226, 122, 0.16);
    border-color: rgba(255, 226, 122, 1);
    color: #fff;
    box-shadow: 0 0 20px rgba(255, 226, 122, 0.45);
    outline: none;
}
.tv-empty-btn:active { transform: translateY(1px); }
.tv-empty-btn.tv-empty-btn-primary {
    background: linear-gradient(135deg, rgba(255, 61, 110, 0.4), rgba(255, 226, 122, 0.32));
    border-color: rgba(255, 226, 122, 0.95);
    color: #fff;
}

/* TikTok-style vertical pager drag —
   - .tv-dragging: finger is down, follow 1:1, no transition.
   - .tv-snap: finger released, animate to commit-target or back to rest.
   The transition wins over any prior transitions on the same property since
   it's specified later in the cascade.

   The lower-third (artist text + Showing-Near-You) and right-side action rail
   ride along with the video so the whole "card" feels like a single TikTok
   tile sliding past the chrome (top wordmark, channel rail, transport,
   ticker stay anchored). The scrubber rides along too — its time numbers
   are per-video, so anchoring it would leave a stale "0:03 / 3:19" floating
   between the moving video and the moving lower-third. */
.tv-row-bottom,
.tv-actions-rail,
.tv-scrub {
    transform: translateY(calc(var(--tv-drag-y, 0px) + var(--tv-pop-y, 0px)));
}
/* Promote the lower-third / action-rail / scrubber to their own GPU layer
   ONLY while a gesture or commit transition is active. Permanent
   will-change kept those layers in the compositor at all times — fine on
   desktop, pricey on mobile + iOS WKWebView where layer count translates
   directly to memory pressure. The promotion class is the same one CSS
   already keys transitions on (.tv-dragging / .tv-snap / .tv-loading /
   .tv-fading) so JS doesn't need to manage a new flag. */
.tv-stage.tv-dragging .tv-row-bottom,
.tv-stage.tv-dragging .tv-actions-rail,
.tv-stage.tv-dragging .tv-scrub,
.tv-stage.tv-snap .tv-row-bottom,
.tv-stage.tv-snap .tv-actions-rail,
.tv-stage.tv-snap .tv-scrub,
.tv-stage.tv-loading .tv-row-bottom,
.tv-stage.tv-loading .tv-actions-rail,
.tv-stage.tv-loading .tv-scrub,
.tv-stage.tv-fading .tv-row-bottom,
.tv-stage.tv-fading .tv-actions-rail,
.tv-stage.tv-fading .tv-scrub {
    will-change: transform, opacity;
}
.tv-stage.tv-dragging .tv-video,
.tv-stage.tv-dragging .tv-ambilight,
.tv-stage.tv-dragging .tv-row-bottom,
.tv-stage.tv-dragging .tv-actions-rail,
.tv-stage.tv-dragging .tv-scrub {
    transition: none !important;
}
.tv-stage.tv-snap .tv-video,
.tv-stage.tv-snap .tv-ambilight,
.tv-stage.tv-snap .tv-row-bottom,
.tv-stage.tv-snap .tv-actions-rail,
.tv-stage.tv-snap .tv-scrub {
    transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1) !important;
}

/* Post-commit content swap — hide the foreground frame instantly so the
   YT iframe's black-poster flicker, the new video buffering, and the
   oEmbed aspect-ratio resize all happen behind the curtain. The ambilight
   keeps glowing so the room never goes dark. The card content (lower-third
   + action rail + scrubber) curtains too — otherwise they'd visibly snap
   from the off-screen exit position back to center while the video curtain
   hides the frame, breaking the "everything moves together" illusion. The
   scrubber's stale time would also flash through if it stayed visible. */
.tv-stage.tv-loading .tv-video,
.tv-stage.tv-loading .tv-row-bottom,
.tv-stage.tv-loading .tv-actions-rail,
.tv-stage.tv-loading .tv-scrub {
    opacity: 0;
    transition: none !important;
}
/* Slide IN from the opposite edge while fading from black. The commit JS
   sets --tv-pop-y to the entry offset under .tv-loading (transition:none
   → instant jump off-screen on the opposite side), then swaps to .tv-fading
   and animates --tv-pop-y back to 0 here. Transitioning transform AND
   opacity together gives the "next video sliding up to take the slot"
   feel TikTok has, instead of an opacity-only curtain that reads as a
   pause. */
.tv-stage.tv-fading .tv-video,
.tv-stage.tv-fading .tv-row-bottom,
.tv-stage.tv-fading .tv-actions-rail,
.tv-stage.tv-fading .tv-scrub {
    opacity: 1;
    transition:
        opacity 200ms ease-out,
        transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
}
/* Soft fade-out used by non-swipe artist advances (Next/Prev buttons,
   arrow keys, wheel, auto-advance). The swipe path uses the slide-out as
   its fade-out, so this only kicks in when there's no slide. */
.tv-stage.tv-curtain-out .tv-video {
    opacity: 0;
    transition: opacity 140ms ease-in;
}

/* Children that genuinely need native touch scroll inside the otherwise
   touch-action:none stage. */
.tv-channel-rail { touch-action: pan-x; }
.tv-channel-menu { touch-action: pan-y; }

/* Subtle entrance for the stage */
body.tv-mode-active .tv-stage {
    animation: tv-power-on 380ms ease-out;
}
@keyframes tv-power-on {
    0%   { opacity: 0; filter: brightness(2.6) contrast(0.4); transform: scale(1.02); }
    35%  { opacity: 1; filter: brightness(1.4) contrast(1.4); }
    100% { opacity: 1; filter: brightness(1)   contrast(1);   transform: scale(1); }
}

/* Mobile tweaks */
@media (max-width: 540px) {
    /* Tight top bar: identity pills (left, growing) ⇄ channel badge + close X
       (right, fixed). Reduce padding so the broadcast can sit within the
       first 25-30% of viewport height instead of getting pushed down by
       chrome. align-items:flex-start so a wrapped second row of pills (NEAR
       YOU / <10 MIN) hangs from the same top edge as the badge instead of
       pushing the badge vertically off-center. */
    .tv-row-top {
        padding: calc(env(safe-area-inset-top, 0px) + 6px) 10px 0;
        gap: 6px;
        align-items: flex-start;
    }
    /* Identity column claims all leftover horizontal space so the inner pill
       row has room to lay out side-by-side. Without `flex: 1 1 0` + `min-width: 0`
       the wordmark-block sized to its content's natural width while the
       siblings (badge + close) reserved their own ~140px on the right —
       leaving each pill its own full-width visual row, which read as a
       clumsy vertical stack. */
    .tv-wordmark-block {
        flex: 1 1 0;
        min-width: 0;
    }
    .tv-status-row {
        gap: 4px 6px;
        margin-top: 0;
        font-size: 9px;
        letter-spacing: 0.24em;
    }
    .tv-wordmark { font-size: clamp(17px, 4.8vw, 22px); }
    .tv-wordmark::before { width: 7px; height: 7px; margin-right: 6px; }
    /* Right-side cluster keeps its natural width — flex-shrink:0 already on
       both, and we drop the close X margin so it sits flush against the
       badge without eating an extra ~8px on a 360px viewport. The badge
       padding is tightened further down in this same media query. */
    .tv-stage-close { margin-left: 0; width: 32px; height: 32px; }
    .tv-stage-close svg { width: 12px; height: 12px; }

    /* === Bottom chrome cluster anchored to video bottom edge ====================
       The default grid-anchored chrome (scrub + lower-third pinned to the
       bottom of the overlay grid) leaves a big void between a centered
       landscape video and the lower-third on portrait phones — a 16:9
       source at 96vw is only ~210px tall on a 390-wide phone, so ~150px
       of dead air sits between the video and the chrome.

       Instead, we anchor the chrome cluster to the VIDEO bottom: stage
       center + half video height + a small visual gap. Half video height
       is computed in CSS from --tv-aspect (set by JS via oEmbed): the
       smaller of (96vw / aspect / 2) and (max-height / 2) is the binding
       dimension. The `min()` cap on --tv-cluster-top prevents portrait
       sources from pushing chrome off-screen — in that edge case the
       cluster falls back near its previous bottom-anchored position.

       Cluster contents (top → bottom):
         1. Scrubber (~32px) — leads with a transparent→dark fade-in
         2. Lower-third meta — stretched from scrub bottom down to the
            ticker, so the cluster reads as one solid dark slab instead
            of leaving a bloom gap above the ticker.
       Below the cluster: ticker + bottom-nav (separately positioned).
       The vertical action rail floats on the right anchored to its
       original bottom offset (overlaps the lower-third's right column,
       which reserves padding-right: 64px to stay clear).

       Video stays centered structurally (--tv-rest-y stays 0). The chrome
       follows the video, not the other way around — so any video aspect
       (and any future viewport) self-balances without per-device tuning. */
    .tv-stage {
        /* Strict 16:9 on mobile — half-height is half of (100vw * 9/16). The
           20.5dvh cap mirrors the 41dvh max-height on .tv-video. Both branches
           (landscape + portrait sources) use the same formula now since the
           frame is always 16:9 here. */
        --tv-video-half-h: min(calc(50vw / var(--tv-mobile-aspect)), 20.5dvh);
        /* Lift the video off-center so the screen reads top → bottom as
           video → slider → profile-pic + lower-third. The smaller 16:9 frame
           combined with a tighter top header lets us pull the broadcast tile
           further up — gap between channel rail and video drops from ~80px
           to ~50px so metadata sits closer to where the eye is already
           pointing. */
        --tv-rest-y: -110px;
        /* Cap so portrait sources can't push the rail below the ticker.
           = 100dvh - (scrub→rail offset 38 + rail 200 + ticker 26 + safe + nav). */
        --tv-cluster-top: min(
            calc(50dvh + var(--tv-video-half-h) + 14px + var(--tv-rest-y, 0px)),
            calc(100dvh - 264px - env(safe-area-inset-bottom, 0px) - var(--tv-nav-clearance, 0px))
        );
    }
    .tv-stage.tv-portrait {
        /* Frame is forced to 16:9 above; portrait branch matches landscape so
           the cluster anchors consistently regardless of source aspect. */
        --tv-video-half-h: min(calc(50vw / var(--tv-mobile-aspect)), 20.5dvh);
        --tv-rest-y: -110px;
    }

    /* Scrubber — directly below the video, leading the cluster. Provides
       a transparent→dark fade-in so the video edge reads cleanly above it.
       Removed from grid flow via absolute positioning so it stacks with
       the lower-third below it independently of the grid's row order. */
    .tv-scrub {
        position: absolute;
        top: var(--tv-cluster-top);
        left: 0;
        right: 0;
        bottom: auto;
        padding: 8px 14px 6px;
        gap: 10px;
        background: linear-gradient(180deg,
            rgba(0, 0, 0, 0)    0%,
            rgba(0, 0, 0, 0.55) 55%,
            rgba(0, 0, 0, 0.78) 100%);
    }
    .tv-scrub-time { font-size: 10.5px; min-width: 32px; }

    /* Lower-third meta — directly below the scrubber, stretched down to
       abut the ticker. Stretching (top + bottom anchors) absorbs the
       leftover space between cluster bottom and ticker top into the dark
       backdrop, so the cluster reads as one solid block from scrubber all
       the way to the ticker. Content is top-aligned (flex-start overrides
       the base flex-end) so the artist name sits naturally just below the
       scrubber rather than floating above the ticker. */
    .tv-row-bottom {
        position: absolute;
        top: calc(var(--tv-cluster-top) + 32px);
        left: 0;
        right: 0;
        bottom: 0; /* = .tv-overlay-grid bottom = right above the ticker */
        padding: 8px 14px 12px;
        gap: 8px;
        flex-wrap: wrap;
        align-items: flex-start;
        background: rgba(0, 0, 0, 0.78);
    }
    /* The vertical action rail starts directly below the scrubber and the
       lower-third sits beside it on the left, so the rail's avatar reads as
       coming AFTER the slider in the visual stack rather than overlapping
       the video. The lower-third reserves a ~64px right column to stay
       clear of the rail. */
    .tv-now-playing { max-width: 100%; padding-right: 64px; }
    .tv-upcoming { max-width: 100%; text-align: left; padding-right: 64px; }
    .tv-now-playing-meta { gap: 6px 10px; font-size: 10.5px; letter-spacing: 0.22em; }

    .tv-actions-rail {
        right: max(8px, env(safe-area-inset-right, 0px));
        top: calc(var(--tv-cluster-top) + 38px);
        bottom: auto;
        gap: 8px;
    }
    .tv-rail-avatar { width: 40px; height: 40px; margin-bottom: 3px; }
    .tv-rail-avatar::after { bottom: -5px; width: 22px; }
    .tv-rail-dock { padding: 6px 5px; gap: 4px; border-radius: 30px; }
    .tv-rail-icon { width: 40px; height: 40px; }
    .tv-rail-icon svg { width: 19px; height: 19px; }
    .tv-rail-count { font-size: 10px; }

    .tv-pos-indicator { width: 40px; height: 40px; }
    .tv-pos-text { font-size: 9px; }
    .tv-channel-menu { min-width: 200px; max-width: 86vw; }

    /* Ticker hidden on mobile — the lower-third already shows the now-playing
       artist + city, and at this width only one phrase fits on screen at a
       time, so the ticker is more visual noise than signal. */
    .tv-ticker { display: none; }
    .tv-overlay-grid { bottom: calc(env(safe-area-inset-bottom, 0px) + var(--tv-nav-clearance, 0px)); }

    /* Auto-hide the channel rail on idle so it doesn't permanently eat
       ~50px of vertical space on a phone. Wakes the moment the user
       touches the stage or any control — tvScheduleIdleFade is called
       from every input handler. 320ms matches the existing chrome fade
       (.tv-nav, .tv-transport, .tv-scrub) so the whole HUD breathes
       together. Desktop keeps the rail always-visible because vertical
       space isn't precious there. */
    .tv-row-rail {
        transition: opacity 320ms ease;
    }
    .tv-stage.idle .tv-row-rail {
        opacity: 0;
        pointer-events: none;
    }
    /* Override the universal idle fade for the scrubber on mobile. Playback
       progress is primary information on a phone — the user shouldn't have
       to wake the chrome with a tap just to see (or grab) the timeline.
       Desktop keeps the idle fade because mouse hover + transport buttons
       make on-demand chrome cheap there. */
    .tv-stage.idle .tv-scrub {
        opacity: 0.95;
        pointer-events: auto;
    }
}

/* When the user opens comments from the TV rail, lift the existing
   liner-notes sheet (otherwise hidden under .tv-mode-active per the rule
   near the top of this file) above the stage. The sheet's native open/close
   behavior is preserved; the JS just toggles this body class. */
body.tv-mode-active.tv-mode-comments-open .liner-notes-sheet,
body.tv-mode-active.tv-mode-comments-open #linerNotesOverlay {
    visibility: visible !important;
    pointer-events: auto !important;
}
body.tv-mode-active.tv-mode-comments-open .liner-notes-sheet { z-index: 10010 !important; }
body.tv-mode-active.tv-mode-comments-open #linerNotesOverlay { z-index: 10005 !important; }

/* ─── EXIT AFFORDANCE (desktop) ───
   Desktop has no bottom-nav, so we keep the site-header floating above the
   stage as the way back to the rest of the app. Native styling preserved —
   same cream paper / ink / orange Explore pill the user sees everywhere
   else. When the user pushes the stage to true fullscreen the browser
   hides everything outside the fullscreen element for us. Mobile keeps the
   bottom-nav for exit, so the header stays tucked beneath the stage there. */
@media (min-width: 768px) {
    body.tv-mode-active .site-header {
        z-index: 10000;
        transition: opacity 220ms ease, transform 220ms ease;
    }
    /* Push the stage's top row below the floating header so the wordmark
       and channel badge don't slide under hppn.ing's logo / nav. */
    body.tv-mode-active .tv-row-top {
        padding-top: calc(env(safe-area-inset-top, 0px) + 90px);
    }
    /* Header fades with the rest of the chrome when the stage goes idle.
       :has() lets the body-level header read the .idle class that lives on
       a descendant (.tv-stage). Any pointermove / click / wheel / key event
       resets the idle timer in browse.js and brings the header back. */
    body.tv-mode-active:has(.tv-stage.idle) .site-header {
        opacity: 0;
        pointer-events: none;
        transform: translateY(-8px);
    }
}

/* ─── IMMERSIVE PLAYER FADE ───
   Genre rail (top channel tabs), top filter row (city/dates/similar/etc.
   pills + queue position + close), and actions rail (right-side save/
   comment/share) are visible while the user interacts with the stage but
   fade out on idle so the video carries the screen. tvScheduleIdleFade
   lives in browse.js and toggles .tv-stage.idle after 2500ms of
   inactivity; any pointermove / click / wheel / key event clears it.
   Now-playing name, scrubber, and ticker stay always-on. */
.tv-row-top,
.tv-row-rail,
.tv-actions-rail {
    transition: opacity 220ms ease, transform 220ms ease;
}
.tv-stage.idle .tv-row-rail {
    opacity: 0;
    pointer-events: none;
    transform: translateY(-6px);
}
.tv-stage.idle .tv-actions-rail {
    opacity: 0;
    pointer-events: none;
    transform: translateX(10px);
}
/* Desktop-only: the top filter row also fades on idle. On mobile the
   filter pills are the primary way to change area/dates, and the layout
   already wraps tightly above the video, so keeping them visible
   preserves single-tap access. */
@media (min-width: 541px) {
    .tv-stage.idle .tv-row-top {
        opacity: 0;
        pointer-events: none;
        transform: translateY(-6px);
    }
}

/* Progress bar (scrub) stays visible on idle on mobile (where touch is the
   primary way to scrub and a sleeping bar means a tap is wasted waking
   chrome). On desktop it fades with the rest of the chrome — a mouse wiggle
   brings everything back, and the cinematic intent wins. */
.tv-stage.idle .tv-scrub {
    opacity: 0.85;
    pointer-events: auto;
}
@media (min-width: 541px) {
    .tv-stage.idle .tv-scrub {
        opacity: 0;
        pointer-events: none;
        transform: translateY(6px);
    }
}

/* Keep the marquee ticker and the right-side actions rail visible on
   mobile even when the stage idles. The ticker carries genre/location
   provenance and the rail is the primary save/comment/share affordance
   — both should be reachable without a tap to wake the chrome.
   Lives outside the @media block above because the universal idle fade
   for .tv-actions-rail is declared further down the file; that source
   order means an override at the same specificity has to come after it. */
@media (max-width: 540px) {
    .tv-stage.idle .tv-ticker {
        opacity: 1;
        pointer-events: auto;
    }
    .tv-stage.idle .tv-actions-rail {
        opacity: 1;
        pointer-events: auto;
        transform: none;
    }
}

/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
    .tv-stage, .tv-static, .tv-scanlines::after, .tv-grain, .tv-ticker-track, .tv-wordmark::before, .tv-channel-num.pulse, .tv-channel-tab.active::before {
        animation: none !important;
    }
    /* Drop the springy pager transition too — instant swaps respect the user's
       motion preference but the gesture still resolves correctly because the
       JS commit logic runs regardless. */
    .tv-stage.tv-snap .tv-video,
    .tv-stage.tv-snap .tv-ambilight,
    .tv-stage.tv-snap .tv-row-bottom,
    .tv-stage.tv-snap .tv-actions-rail,
    .tv-stage.tv-snap .tv-scrub,
    .tv-stage.tv-fading .tv-video,
    .tv-stage.tv-fading .tv-row-bottom,
    .tv-stage.tv-fading .tv-actions-rail,
    .tv-stage.tv-fading .tv-scrub {
        transition: none !important;
    }
    .tv-row-rail,
    .tv-actions-rail {
        transition: none !important;
    }
}

/* The .live-pill style itself lives in design-system.css so /artist and
   /<slug> (which don't load tv-mode.css) can use the same floating pill.
   This file only owns the immersive-stage hide rule. */
body.tv-mode-active .live-pill {
    display: none;
}

/* List mode (similar-artists handoff): no channel concept, so hide the rail
   and channel menu. tvSurfChannel is already a no-op in this mode. AREA /
   DATES filter pills are also hidden — the list is fixed and tapping them
   would open pickers that wouldn't change the feed. The "Similar to <X>"
   pill takes their slot in the top row, and the "Near You" pill rides
   alongside it as the only meaningful filter for a fixed list. The "More
   like this" button stays visible so you can pivot again off the artist
   currently on screen. */
body.tv-mode-active.tv-list-mode .tv-channel-rail,
body.tv-mode-active.tv-list-mode .tv-channel-menu,
body.tv-mode-active.tv-list-mode .tv-channel-button,
body.tv-mode-active.tv-list-mode .tv-row-rail,
body.tv-mode-active.tv-list-mode .tv-area-pill:not(.tv-similar-pill):not(.tv-near-pill):not(.tv-quick-pill):not(.tv-when-pill) {
    display: none !important;
}
