1
/* Jellyfin NVR timeline strip
 * Paste into: Custom JavaScript plugin (Dashboard → Plugins → Custom JavaScript)
 * Activates on any folder whose contents match the Tapo clip filename pattern.
 *
 * Filename pattern: <Camera>-YYYYMMDD-HHMMSS-NNNNs.mp4
 *   start time + duration both come from the filename — no metadata calls needed.
 */
(function () {
  "use strict";

  const CLIP_RE = /^(.+?)-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})-(\d+)s\.mp4$/i;
  const SECS_PER_DAY = 86400;
  const STYLE_ID = "nvr-timeline-style";
  const STRIP_ID = "nvr-timeline-strip";

  // ---------- styles ----------
  function injectStyles() {
    if (document.getElementById(STYLE_ID)) return;
    const s = document.createElement("style");
    s.id = STYLE_ID;
    s.textContent = `
      #${STRIP_ID} {
        margin: 8px 3.3% 18px;
        background: #161b22;
        border: 1px solid #21262d;
        border-radius: 8px;
        padding: 12px 14px 10px;
        color: #e6edf3;
        font: 13px -apple-system, "Segoe UI", Roboto, sans-serif;
      }
      #${STRIP_ID} .nvr-row1 {
        display: flex; align-items: center; gap: 12px; margin-bottom: 10px;
      }
      #${STRIP_ID} .nvr-date {
        background: #0d1117; border: 1px solid #30363d;
        padding: 6px 12px; border-radius: 6px;
        font-variant-numeric: tabular-nums; font-weight: 600;
      }
      #${STRIP_ID} .nvr-stats { color: #8b949e; font-size: 12px; }
      #${STRIP_ID} .nvr-now {
        margin-left: auto;
        font-variant-numeric: tabular-nums;
        background: #0d1117; padding: 6px 12px; border-radius: 6px;
        border: 1px solid #30363d; font-weight: 600;
      }
      #${STRIP_ID} .nvr-tl { position: relative; height: 60px; user-select: none; }
      #${STRIP_ID} .nvr-track {
        position: absolute; inset: 18px 0 14px 0;
        background: #1f262e; border-radius: 4px; overflow: hidden;
        background-image: repeating-linear-gradient(
          to right, transparent 0,
          transparent calc(100%/24 - 1px),
          #2a313a calc(100%/24 - 1px),
          #2a313a calc(100%/24));
      }
      #${STRIP_ID} .nvr-seg {
        position: absolute; top: 0; bottom: 0;
        background: #2f81f7; cursor: pointer;
        transition: background .08s;
      }
      #${STRIP_ID} .nvr-seg:hover { background: #58a6ff; }
      #${STRIP_ID} .nvr-labels {
        position: absolute; left: 0; right: 0; top: 0; height: 14px;
        color: #8b949e; font-size: 10px; font-variant-numeric: tabular-nums;
      }
      #${STRIP_ID} .nvr-labels span { position: absolute; transform: translateX(-50%); }
      #${STRIP_ID} .nvr-cursor {
        position: absolute; top: 14px; bottom: 10px; width: 1px;
        background: #ffffff80; pointer-events: none; display: none;
      }
    `;
    document.head.appendChild(s);
  }

  // ---------- parsing ----------
  function parseClip(item) {
    // item.Name is the filename without extension in some libraries,
    // and with extension in others. Try Path first when available.
    const candidates = [item.Path, item.FileName, item.Name].filter(Boolean);
    for (const c of candidates) {
      const base = c.split(/[\\/]/).pop();
      const m = CLIP_RE.exec(base);
      if (m) {
        const [, , Y, Mo, D, h, mi, s, dur] = m;
        const startSec = (+h) * 3600 + (+mi) * 60 + (+s);
        return {
          id: item.Id,
          serverId: item.ServerId,
          dateLabel: `${Y}-${Mo}-${D}`,
          startSec,
          dur: +dur,
        };
      }
    }
    return null;
  }

  function fmtTime(sec) {
    sec = Math.max(0, Math.min(SECS_PER_DAY - 1, Math.floor(sec)));
    const h = String(Math.floor(sec / 3600)).padStart(2, "0");
    const m = String(Math.floor(sec / 60) % 60).padStart(2, "0");
    const s = String(sec % 60).padStart(2, "0");
    return `${h}:${m}:${s}`;
  }

  function fmtDur(sec) {
    const m = Math.floor(sec / 60), r = sec % 60;
    return m ? `${m}m ${r}s` : `${r}s`;
  }

  // ---------- build the strip ----------
  function buildStrip(clips, hostEl) {
    if (!clips.length) return;

    const dateLabel = clips[0].dateLabel;
    const totalSec = clips.reduce((a, c) => a + c.dur, 0);
    const last = clips.reduce((a, c) => (c.startSec > a.startSec ? c : a));

    const wrap = document.createElement("div");
    wrap.id = STRIP_ID;
    wrap.className = "nvr-timeline-strip";
    wrap.innerHTML = `
      <div class="nvr-row1">
        <div class="nvr-date">${dateLabel}</div>
        <span class="nvr-stats">${clips.length} clips · ${fmtDur(totalSec)} total · last ${fmtTime(last.startSec)}</span>
        <div class="nvr-now">— : — : —</div>
      </div>
      <div class="nvr-tl">
        <div class="nvr-labels"></div>
        <div class="nvr-track"></div>
        <div class="nvr-cursor"></div>
      </div>
    `;

    const labels = wrap.querySelector(".nvr-labels");
    for (let h = 0; h <= 24; h += 3) {
      const el = document.createElement("span");
      el.style.left = (h / 24 * 100) + "%";
      el.textContent = String(h).padStart(2, "0") + ":00";
      labels.appendChild(el);
    }

    const track = wrap.querySelector(".nvr-track");
    clips.forEach((c) => {
      const seg = document.createElement("div");
      seg.className = "nvr-seg";
      seg.style.left = (c.startSec / SECS_PER_DAY * 100) + "%";
      seg.style.width = Math.max(0.18, c.dur / SECS_PER_DAY * 100) + "%";
      seg.title = `${fmtTime(c.startSec)} · ${fmtDur(c.dur)}`;
      seg.onclick = (e) => {
        e.stopPropagation();
        // Desktop fast-path: card's play overlay button exists in DOM.
        const card = document.querySelector(
          `.card[data-id="${c.id}"], [data-id="${c.id}"]`
        );
        const cardBtn = card && card.querySelector(
          '[data-action="resume"], [data-action="play"], [data-action="playallfromhere"]'
        );
        if (cardBtn) { cardBtn.click(); return; }

        // Mobile fallback: route to details page, auto-click its Play button.
        // The segment tap counts as a user gesture for autoplay policy.
        window.location.hash = `#/details?id=${c.id}&serverId=${c.serverId}`;
        const start = Date.now();
        const tick = () => {
          const btn = document.querySelector(
            '.page:not(.hide) .mainDetailButtons [data-action="resume"], ' +
            '.page:not(.hide) .mainDetailButtons [data-action="play"], ' +
            '.page:not(.hide) .btnPlay, ' +
            '.page:not(.hide) button[is="emby-playstatebutton"][data-action="resume"], ' +
            '.page:not(.hide) button[title="Play"]'
          );
          if (btn) { btn.click(); return; }
          if (Date.now() - start < 4000) requestAnimationFrame(tick);
        };
        requestAnimationFrame(tick);
      };
      track.appendChild(seg);
    });

    const tl = wrap.querySelector(".nvr-tl");
    const now = wrap.querySelector(".nvr-now");
    const cursor = wrap.querySelector(".nvr-cursor");
    tl.addEventListener("mousemove", (e) => {
      const r = tl.getBoundingClientRect();
      const pct = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width));
      now.textContent = fmtTime(pct * SECS_PER_DAY);
      cursor.style.display = "block";
      cursor.style.left = (pct * 100) + "%";
    });
    tl.addEventListener("mouseleave", () => {
      now.textContent = "— : — : —";
      cursor.style.display = "none";
    });

    hostEl.parentNode.insertBefore(wrap, hostEl);
  }

  // ---------- find folder content + parent ----------
  function getActivePage() {
    // Jellyfin keeps prior pages in DOM as cached .page nodes; only one is visible.
    return document.querySelector('.page:not(.hide):not(.dialog)') || document;
  }

  function findItemsContainer() {
    const page = getActivePage();
    return page.querySelector('.itemsContainer:not(.hide), [is="emby-itemscontainer"]:not(.hide)');
  }

  async function tryRenderForCurrentView() {
    const container = findItemsContainer();
    if (!container) return;

    // Synchronous claim — must happen BEFORE any await, otherwise concurrent
    // viewshow events both pass the dedupe check and insert two strips.
    if (container.dataset.nvrPending) return;
    if (
      container.previousElementSibling &&
      container.previousElementSibling.classList &&
      container.previousElementSibling.classList.contains("nvr-timeline-strip")
    ) {
      return; // strip already attached to this container
    }
    container.dataset.nvrPending = "1";

    // Clean up orphan strips elsewhere (e.g. cached pages without a sibling container).
    document.querySelectorAll(".nvr-timeline-strip").forEach((n) => {
      const next = n.nextElementSibling;
      const isAttached =
        next && (next.classList.contains("itemsContainer") || next.getAttribute("is") === "emby-itemscontainer");
      if (!isAttached || next !== container) n.remove();
    });

    // Pull parentId from URL hash
    const hash = window.location.hash || "";
    const m = /[?&]parentId=([^&]+)/.exec(hash);
    if (!m) return;
    const parentId = decodeURIComponent(m[1]);

    if (typeof ApiClient === "undefined" || !ApiClient.getItems) return;

    let res;
    try {
      res = await ApiClient.getItems(ApiClient.getCurrentUserId(), {
        ParentId: parentId,
        Fields: "Path",
        SortBy: "SortName",
        SortOrder: "Ascending",
        Limit: 5000,
      });
    } catch (err) {
      console.warn("[NVR] getItems failed", err);
      delete container.dataset.nvrPending;
      return;
    }
    try {
      if (!res || !res.Items || !res.Items.length) return;
      const clips = res.Items.map(parseClip).filter(Boolean);
      if (!clips.length) return;
      clips.sort((a, b) => a.startSec - b.startSec);
      // Guard once more — container might have been replaced during await.
      if (!container.isConnected) return;
      if (
        container.previousElementSibling &&
        container.previousElementSibling.classList &&
        container.previousElementSibling.classList.contains("nvr-timeline-strip")
      ) return;
      injectStyles();
      buildStrip(clips, container);
    } finally {
      delete container.dataset.nvrPending;
    }
  }

  // ---------- navigation hooks ----------
  function onNav() {
    // Defer; Jellyfin populates the items container after the route changes.
    let tries = 0;
    const tick = () => {
      if (findItemsContainer()) {
        tryRenderForCurrentView();
      } else if (++tries < 20) {
        setTimeout(tick, 150);
      }
    };
    tick();
  }

  window.addEventListener("hashchange", onNav);
  document.addEventListener("viewshow", onNav);
  // First load
  if (document.readyState === "complete") onNav();
  else window.addEventListener("load", onNav);
})();

For immediate assistance, please email our customer support: [email protected]

Download RAW File