PageSourceSearch

https://snow-feed-live.pages.dev/overlays.js

js snow-feed-live.pages.dev collected 2026-10-03 05:56:03 UTC 9,175 bytes, 138 lines download raw bytes

1// overlays.js — config-driven overlay framework for the live-do player.
2// Reads the resolved SNOW_LIVE.overlays and mounts each enabled overlay as a
3// registered component into #wrap. Components talk to the transport via
4// window.SNOW_CORE (events + commands). Fail-silent: a decorative overlay must
5// never break the feed.
6(function () {
7  const REGISTRY = new Map();   // type -> factory(container, opts, core) => { update?, destroy? }
8  const MOUNTED = new Map();    // id -> { update?, destroy? } instance handle
9  function register(type, factory) { REGISTRY.set(type, factory); }
10  window.SNOW_OVERLAYS = { register, mounted: MOUNTED };   // components self-register (loaded after this file)
11
12  function boot() {
13    if (window.__ovBooted) return; window.__ovBooted = true;   // idempotent: never double-mount
14    // On the preview surfaces keep navigation IN preview: rewrite the inter-view nav links to
15    // their *-preview counterparts so switching live<->snapshot stays on the preview pages.
16    // live-preview sets SNOW_PREVIEW; snapshot-preview sets SNOW_VARIANT. (status.html has no
17    // preview, so it's left alone.) Graceful — if this never runs the tabs fall back to live.
18    if (window.SNOW_VARIANT || window.SNOW_PREVIEW) {
19      const PREVIEW = { "live.html": "live-preview.html", "snapshot.html": "snapshot-preview.html" };
20      for (const a of document.querySelectorAll("nav.tabs a.tab")) {
21        const to = PREVIEW[a.getAttribute("href")];
22        if (to) a.setAttribute("href", to);
23      }
24    }
25    const overlays = (window.SNOW_LIVE && window.SNOW_LIVE.overlays) || null;
26    if (!overlays || typeof overlays !== "object") return;
27    const wrap = document.getElementById("wrap");
28    const core = window.SNOW_CORE;
29    if (!wrap) return;
30    const regions = {
31      top: document.querySelector(".chrome-top"),
32      video: wrap,
33      bottom: document.querySelector(".chrome-bottom"),
34    };
35    // Responsive region: framed (chrome in bars) vs immersive (overlays on the video). On the
36    // breakpoint crossing we RE-PARENT each container (a DOM move) — never destroy/recreate — so
37    // inner DOM, listeners, and SNOW_CORE subscriptions survive the rotate/resize.
38    // Framed when EITHER:
39    //   • a wide-landscape screen (≥700×601) — desktop/tablet; OR
40    //   • ANY portrait — a landscape video letterboxes into a band, leaving empty space above &
41    //     below for the bars; without framing those overlays pile onto the small video (the
42    //     phone-portrait complaint).
43    // Immersive stays only for LANDSCAPE phones (short), which the "(orientation:landscape) and
44    // (max-height:600px)" full-bleed query in player.css fills edge-to-edge. The min-height 601
45    // keeps the wide-landscape clause ABOVE that full-bleed query so framed + full-bleed never overlap.
46    const framedQ = window.matchMedia("(min-width: 700px) and (min-height: 601px), (orientation: portrait)");
47    // Embedded players pin the framed layout regardless of iframe size, so the chrome always frames
48    // the video (a small iframe would otherwise trip framedQ into the immersive on-video layout).
49    const isFramed = () => !!window.SNOW_EMBED || framedQ.matches;
50    const effRegion = (o) => (isFramed() ? (o.region || "video") : "video");
51    // Single source of truth for "resolve region → resolve target → guarded
52    // appendChild → rebuild className". Used at mount AND on breakpoint change so the
53    // two stay in lockstep. The guarded appendChild is a DOM move (re-parent), never
54    // a destroy/recreate — inner DOM, listeners, and SNOW_CORE subscriptions survive.
55    const place = (container, o) => {
56      const region = effRegion(o);
57      const target = regions[region] || wrap;        // unknown region → video
58      if (container.parentNode !== target) target.appendChild(container);   // DOM move: keeps inner DOM + listeners + subs
59      container.className = "overlay overlay-" + o.type + " ov-region-" + region;
60    };
61    const PLACED = [];   // { id, container, o } — for re-parenting on breakpoint change
62    for (const [id, o] of Object.entries(overlays)) {
63      if (!o || o.enabled !== true) continue;
64      const factory = REGISTRY.get(o.type);
65      if (!factory) { console.warn("overlays: unsupported type", o.type, "for", id
65); continue; }
66      try {
67        const container = document.createElement("div");
68        container.id = "ov-" + id;
69        place(container, o);                              // in the DOM with its class before the factory runs
70        const inst = factory(container, o, core) || {};   // component fills the container + wires core
71        MOUNTED.set(id, inst);                            // keep the lifecycle handle for later tasks
72        PLACED.push({ id, container, o });                // retain for re-parenting
73      } catch (e) { console.warn("overlays: failed to mount", id, e); }
74    }
75    framedQ.addEventListener("change", () => {
76      for (const p of PLACED) place(p.container, p.o);
77    });
78
79    // ── frame aspect ratio: the #wrap box is a FIXED 16:9 (declared in player.css). The
80    //    video/image letterboxes INSIDE it (object-fit:contain); we deliberately do NOT reshape
81    //    the box to the content, so the viewable screen keeps a stable shape across
82    //    live / offline / snapshot (the camera's delivery sub-stream is ~4:3, the snapshots are
83    //    ~16:9). fitStage just sizes that fixed-shape box to fit the space between the bars.
84    const FRAME_AR = 16 / 9;
85
86    // ── fitStage: size the framed video + bars to the video's DISPLAYED width ──
87    // The on-video box letterboxes when the viewport is short/wide, so the chrome bars (full
88    // width) would overhang the video. In framed mode we set the video and bars to the video's
89    // actual width (given the height left between the bars + its real aspect) so they track the
90    // video exactly. Pure CSS can't size this — the video's size depends on the bars' height and
91    // vice-versa. No-op (clears the inline widths, leaving the stable CSS) when not framed.
92    const sized = [wrap, regions.top, regions.bottom].filter(Boolean);
93    function fitStage() {
94      // While #wrap is MAXIMIZED (CSS pseudo-FS or the native fullscreen element) it must fill the
95      // viewport via CSS, so release any framed inline width and bail — never size it to the bars.
96      const maximized = wrap.classList.contains("snow-fs") || document.fullscreenElement === wrap;
97      const framed = !maximized && isFramed() &&
98        ((regions.top && regions.top.childElementCount) || (regions.bottom && regions.bottom.childElementCount));
99      if (!framed) { for (const el of sized) el.style.width = ""; return; }
100      const main = wrap.parentElement, cs = getComputedStyle(main);
101      const availH = main.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom);
102      const availW = main.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
103      const barsH = (regions.top ? regions.top.offsetHeight : 0) + (regions.bottom ? regions.bottom.offsetHeight : 0);
104      const videoW = Math.max(0, Math.floor(Math.min(availW, (availH - barsH) * FRAME_AR, 1280)));
105      for (const el of sized) el.style.width = videoW + "px";   // wrap height follows via aspect-ratio
106    }
107    let rafId = null;
108    const scheduleFit = () => { if (rafId) return; rafId = requestAnimationFrame(() => { rafId = null; fitStage(); }); };
109    window.addEventListener("resize", scheduleFit);
110    framedQ.addEventListener("change", scheduleFit);   // re-parent listener runs first; this rAF runs after
111    // Re-fit whenever the bar heights change: any image load under <main> — chiefly the brandbar
112    // PNG, whose height feeds back into the bar height and so into the video size. (load doesn't
113    // bubble, so listen in the capture phase on <main>.)
114    (wrap.parentElement || wrap).addEventListener("load", scheduleFit, true);
115    window.SNOW_OVERLAYS.fitStage = fitStage;           // exposed for tests
116    scheduleFit();
117  }
118  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
119  else boot();
120
121  // ── built-in: image (branding) — preserves the prior behavior ───────────────
122  register("image", function (container, o) {
123    if (!o.src) { console.warn("overlays: image has no src"); container.remove(); return; }
124    const img = document.createElement("img");
125    img.className = "overlay-image-img";
126    img.alt = ""; img.decoding = "async";
127    Object.assign(container.style, { position: "absolute", inset: "0", pointerEvents: "none", zIndex: "1" });
128    Object.assign(img.style, {
129      position: "absolute", inset: "0", width: "100%", height: "100%",
130      objectFit: o.fit === "cover" ? "cover" : "contain",
131      opacity: String(o.opacity ?? 1), pointerEvents: "none",
132    });
133    img.onerror = () => container.remove();
134    // o.src is trusted: config.js is a same-origin static asset. Validate the URL scheme here if overlay config ever comes from an untrusted source.
135    img.src = o.src;
136    container.appendChild(img);
137  });
138})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.