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]