parent
649633c62c
commit
915b6979e5
@ -0,0 +1,21 @@
|
||||
######################################################################
|
||||
# @CCOSTAN - Follow Me on X
|
||||
# For more info visit https://www.vcloudinfo.com/click-here
|
||||
# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig
|
||||
# -------------------------------------------------------------------
|
||||
# Frigate Timeline Cameras - explicit recording camera order
|
||||
# Shared by Home, the camera grid, and the recording viewer.
|
||||
# -------------------------------------------------------------------
|
||||
# Notes: Keep cameras visible even when they have no recent detections.
|
||||
# Notes: Birdseye is a composite feed; Blink is not a Frigate recorder.
|
||||
######################################################################
|
||||
|
||||
- frontdoorbell
|
||||
- garagecam
|
||||
- frontlawn
|
||||
- driveway
|
||||
- kidsgate
|
||||
- bedroomgate
|
||||
- filtergate
|
||||
- conico_wireless
|
||||
- poolcam
|
||||
@ -0,0 +1,208 @@
|
||||
// A paged, labeled review surface around the installed Frigate Events Card.
|
||||
import "/local/frigate-events-auth.js?v=2";
|
||||
|
||||
const Card = customElements.get("frigate-events-card");
|
||||
const installed = Symbol.for("frigate-events-card.clip-review");
|
||||
|
||||
if (Card && !Card.prototype[installed]) {
|
||||
Card.prototype[installed] = true;
|
||||
const loadEvents = Card.prototype._loadEvents;
|
||||
const updated = Card.prototype.updated;
|
||||
const states = new WeakMap();
|
||||
|
||||
function stateFor(card) {
|
||||
if (!states.has(card)) states.set(card, { page: 0, cursors: [null], camera: "", request: 0 });
|
||||
return states.get(card);
|
||||
}
|
||||
|
||||
Card.prototype._loadEvents = async function () {
|
||||
if (!this._config?.review_clips) return loadEvents.call(this);
|
||||
if (!this.hass) return;
|
||||
const state = stateFor(this);
|
||||
const cursor = state.cursors[state.page];
|
||||
const key = JSON.stringify([state.page, state.camera, cursor]);
|
||||
if (state.loading && state.key === key) return;
|
||||
const request = ++state.request;
|
||||
state.busy = state.key !== key || !this._events?.length;
|
||||
state.key = key;
|
||||
state.loading = true;
|
||||
state.error = false;
|
||||
this._loading = true;
|
||||
this.requestUpdate();
|
||||
try {
|
||||
const size = this._config.event_count || 60;
|
||||
const query = { type: "frigate/events/get", instance_id: this._config.frigate_client_id || "frigate",
|
||||
cameras: state.camera ? [state.camera] : this._config.cameras,
|
||||
has_clip: true, limit: size + 1 + (cursor?.exclude.length || 0) };
|
||||
if (cursor) query.before = cursor.before;
|
||||
const response = await this.hass.callWS(query);
|
||||
if (request !== state.request || !this.isConnected) return;
|
||||
const excluded = new Set(cursor?.exclude || []);
|
||||
const rows = (typeof response === "string" ? JSON.parse(response) : response)
|
||||
.filter(event => !excluded.has(event.id)).sort((a, b) => b.start_time - a.start_time);
|
||||
this._events = rows.slice(0, size);
|
||||
state.more = rows.length > size;
|
||||
if (state.more) {
|
||||
const boundary = Math.floor(this._events.at(-1).start_time);
|
||||
const exclude = new Set(cursor?.before === boundary + 1 ? cursor.exclude : []);
|
||||
this._events.filter(event => event.start_time >= boundary).forEach(event => exclude.add(event.id));
|
||||
// The HA WebSocket API accepts integer boundaries. Overlap the last
|
||||
// second and exclude seen IDs so fractional-second clips are retained.
|
||||
state.cursors[state.page + 1] = { before: boundary + 1, exclude: [...exclude] };
|
||||
}
|
||||
this._error = undefined;
|
||||
} catch (error) {
|
||||
if (request !== state.request) return;
|
||||
state.error = true;
|
||||
console.warn("Recorded clip review failed", error?.message);
|
||||
} finally {
|
||||
if (request === state.request) {
|
||||
state.loading = false;
|
||||
state.busy = false;
|
||||
this._loading = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function decorate(card) {
|
||||
const root = card.shadowRoot;
|
||||
const surface = root?.querySelector("ha-card");
|
||||
if (!surface) return;
|
||||
const state = stateFor(card);
|
||||
let toolbar = root.querySelector(".clip-review-toolbar");
|
||||
if (!toolbar) {
|
||||
toolbar = document.createElement("div");
|
||||
toolbar.className = "clip-review-toolbar";
|
||||
toolbar.innerHTML = '<div class="review-heading"><strong>Recorded clips</strong><span class="review-status" aria-live="polite"></span></div><div class="review-actions"><select aria-label="Filter clips by camera"><option value="">All cameras</option></select><button data-review="latest">Latest</button><button data-review="newer">Newer</button><button data-review="older">Older</button><button data-review="now" title="Open recent saved footage for the selected camera">Recent footage</button></div>';
|
||||
const select = toolbar.querySelector("select");
|
||||
for (const camera of card._config.cameras || []) {
|
||||
const option = document.createElement("option");
|
||||
option.value = camera;
|
||||
option.textContent = card._formatCameraName(camera);
|
||||
select.appendChild(option);
|
||||
}
|
||||
select.addEventListener("change", () => {
|
||||
state.camera = select.value;
|
||||
state.page = 0;
|
||||
state.cursors = [null];
|
||||
card._loadEvents();
|
||||
});
|
||||
toolbar.addEventListener("click", async event => {
|
||||
const action = event.target.closest("[data-review]")?.dataset.review;
|
||||
if (!action) return;
|
||||
if (action === "now") {
|
||||
const button = toolbar.querySelector('[data-review="now"]');
|
||||
const camera = state.camera || card._events?.[0]?.camera || card._config.cameras[0];
|
||||
button.disabled = true;
|
||||
button.textContent = "Opening...";
|
||||
try {
|
||||
const now = Date.now() / 1000;
|
||||
const response = await card.hass.callWS({ type: "frigate/recordings/get",
|
||||
instance_id: card._config.frigate_client_id || "frigate", camera,
|
||||
after: Math.floor(now - 86400), before: Math.floor(now) });
|
||||
if (!card.isConnected) return;
|
||||
const recordings = (typeof response === "string" ? JSON.parse(response) : response)
|
||||
.filter(recording => recording.end_time > recording.start_time)
|
||||
.sort((a, b) => b.end_time - a.end_time);
|
||||
const nearNow = recordings.some(recording => now - 30 >= recording.start_time && now - 30 <= recording.end_time);
|
||||
const latest = recordings[0];
|
||||
// Motion-based retention can leave Now empty. Choose saved footage
|
||||
// explicitly here; historical camera switches still keep its time.
|
||||
const target = !nearNow && latest ? Math.max(latest.start_time, latest.end_time - 10) : undefined;
|
||||
await card._showTimelineModal(camera, target);
|
||||
if (Number.isFinite(target)) card.dispatchEvent(new CustomEvent("hass-notification", {
|
||||
detail: { message: `No recording near now. Opened latest saved footage from ${new Date(target * 1000).toLocaleString()}.` },
|
||||
bubbles: true, composed: true,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn("Recent recording playback failed", error?.message);
|
||||
card.dispatchEvent(new CustomEvent("hass-notification", {
|
||||
detail: { message: "Unable to open recent footage. Please try again." }, bubbles: true, composed: true,
|
||||
}));
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.textContent = "Recent footage";
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (action === "older" && state.more) state.page++;
|
||||
else if (action === "newer") state.page = Math.max(0, state.page - 1);
|
||||
else if (action === "latest") { state.page = 0; state.cursors = [null]; }
|
||||
card._loadEvents();
|
||||
root.querySelector(".events")?.scrollTo({ top: 0 });
|
||||
});
|
||||
surface.prepend(toolbar);
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
.clip-review-toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 0; }
|
||||
.clip-review-toolbar[hidden] { display: none; }
|
||||
.review-heading { display: flex; flex-direction: column; gap: 4px; }
|
||||
.review-status { font-size: 12px; color: var(--secondary-text-color); }
|
||||
.review-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.review-actions button, .review-actions select { min-height: 36px; padding: 6px 10px; border: 1px solid var(--divider-color, #aaa); border-radius: 8px; background: var(--card-background-color); color: var(--primary-text-color); font: inherit; font-size: 13px; cursor: pointer; }
|
||||
.review-actions button:disabled { opacity: .4; cursor: default; }
|
||||
.events.grid { grid-template-columns: repeat(auto-fill, minmax(min(100%, 220px), 1fr)) !important; max-height: var(--review-height, 75vh); overflow-y: auto; padding: 3px; }
|
||||
.events .event { aspect-ratio: auto; }
|
||||
.events .event img { height: 150px; }
|
||||
.events .event video { height: 150px !important; }
|
||||
.review-caption { display: flex; flex-direction: column; gap: 4px; padding: 9px; color: var(--primary-text-color); font-size: 13px; line-height: 1.35; }
|
||||
.review-caption span { color: var(--secondary-text-color); font-size: 12px; }
|
||||
.placeholder { display: none; }
|
||||
[data-review-compact] .events.grid { grid-template-columns: repeat(4, minmax(0, 1fr)) !important; }
|
||||
[data-review-compact] .events .event img, [data-review-compact] .events .event video { height: 100px !important; }
|
||||
@media (max-width: 600px) {
|
||||
[data-review-compact] .events.grid { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
|
||||
}
|
||||
`;
|
||||
root.appendChild(style);
|
||||
}
|
||||
surface.style.setProperty("--review-height", card._config.grid_max_height || "75vh");
|
||||
surface.toggleAttribute("data-review-compact", !!card._config.review_compact);
|
||||
toolbar.hidden = !!card._config.review_compact;
|
||||
const size = card._config.event_count || 60;
|
||||
const count = card._events?.length || 0;
|
||||
toolbar.querySelector(".review-status").textContent = state.busy ? "Loading recorded clips..."
|
||||
: state.error ? "Could not load clips. Use Latest to retry."
|
||||
: count ? `Page ${state.page + 1} · ${count} recorded clips · newest first` : "No recorded clips for this camera.";
|
||||
toolbar.querySelector('[data-review="newer"]').disabled = state.busy || state.page === 0;
|
||||
toolbar.querySelector('[data-review="older"]').disabled = state.busy || !state.more;
|
||||
toolbar.querySelector('[data-review="latest"]').disabled = state.busy;
|
||||
root.querySelectorAll(".events .event").forEach((tile, index) => {
|
||||
const event = card._events[index];
|
||||
if (!event || index >= size) return;
|
||||
for (const node of [...tile.childNodes]) {
|
||||
if (node.nodeType === Node.TEXT_NODE && node.textContent.includes("}}")) node.remove();
|
||||
}
|
||||
let caption = tile.querySelector(".review-caption");
|
||||
if (card._config.review_compact) {
|
||||
caption?.remove();
|
||||
caption = null;
|
||||
} else if (!caption) {
|
||||
caption = document.createElement("div");
|
||||
caption.className = "review-caption";
|
||||
caption.append(document.createElement("strong"), document.createElement("span"));
|
||||
tile.appendChild(caption);
|
||||
}
|
||||
if (!tile.hasAttribute("role")) {
|
||||
tile.tabIndex = 0;
|
||||
tile.setAttribute("role", "button");
|
||||
tile.addEventListener("keydown", keyboard => {
|
||||
if (keyboard.key === "Enter" || keyboard.key === " ") { keyboard.preventDefault(); tile.click(); }
|
||||
});
|
||||
}
|
||||
const label = `${card._formatCameraName(event.camera)} · ${event.label}`;
|
||||
const time = new Date(event.start_time * 1000).toLocaleString();
|
||||
if (caption) {
|
||||
caption.querySelector("strong").textContent = label;
|
||||
caption.querySelector("span").textContent = time;
|
||||
}
|
||||
tile.setAttribute("aria-label", `${label}, ${time}`);
|
||||
});
|
||||
}
|
||||
|
||||
Card.prototype.updated = function (...args) {
|
||||
updated.apply(this, args);
|
||||
if (this._config?.review_clips) decorate(this);
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
// Load the shared timeline helpers and keep one Clips player open at a time.
|
||||
import "/local/frigate-events-auth.js?v=2";
|
||||
import "/local/frigate-timeline-time.js?v=5";
|
||||
import "/local/frigate-clip-review.js?v=5";
|
||||
|
||||
// Keep existing bookmarks useful while the visible routes describe each page.
|
||||
const renamedRoute = {
|
||||
"/lovelace/camera": "/lovelace/live-cameras",
|
||||
"/lovelace/cameras": "/lovelace/clips",
|
||||
}[location.pathname.replace(/\/$/, "")];
|
||||
if (renamedRoute) location.replace(renamedRoute + location.search + location.hash);
|
||||
|
||||
const Card = customElements.get("frigate-events-card");
|
||||
const installed = Symbol.for("frigate-events-card.dashboard-navigation");
|
||||
|
||||
if (Card?.prototype._showTimelineModal && !Card.prototype[installed]) {
|
||||
Card.prototype[installed] = true;
|
||||
const showTimeline = Card.prototype._showTimelineModal;
|
||||
const removeTimeline = Card.prototype._removeTimelineModal;
|
||||
const updated = Card.prototype.updated;
|
||||
const cleanup = Card.prototype._cleanup;
|
||||
const autoOpened = new WeakSet();
|
||||
let activeCard;
|
||||
|
||||
function reportError(error) {
|
||||
console.warn("Unable to open camera timeline", error?.message);
|
||||
document.querySelector("home-assistant")?.dispatchEvent(new CustomEvent("hass-notification", {
|
||||
detail: { message: "Unable to open camera history. Please try again." },
|
||||
bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
async function openCamera(card, requestedCamera) {
|
||||
const cameras = card._getAvailableCameras();
|
||||
const camera = typeof requestedCamera === "string" ? requestedCamera
|
||||
: card._events?.find(event => cameras.includes(event.camera))?.camera || cameras[0];
|
||||
if (!camera || !cameras.includes(camera)) return;
|
||||
if (!card.isConnected) return;
|
||||
card._removeTimelineModal();
|
||||
await card._showTimelineModal(camera);
|
||||
}
|
||||
|
||||
function findCard(root) {
|
||||
const card = root.querySelector("frigate-events-card");
|
||||
if (card) return card;
|
||||
for (const element of root.querySelectorAll("*")) {
|
||||
if (element.shadowRoot) {
|
||||
const found = findCard(element.shadowRoot);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Home Assistant's fire-dom-event action supplies the configured payload.
|
||||
document.addEventListener("ll-custom", (event) => {
|
||||
const camera = event.detail?.frigate_timeline;
|
||||
if (camera !== true && typeof camera !== "string") return;
|
||||
const card = findCard(document);
|
||||
if (card?.hass) {
|
||||
event.stopPropagation();
|
||||
openCamera(card, camera).catch(reportError);
|
||||
}
|
||||
});
|
||||
|
||||
Card.prototype._showTimelineModal = function (...args) {
|
||||
if (this._config?.show_timeline === false) return;
|
||||
if (activeCard && activeCard !== this) activeCard._removeTimelineModal();
|
||||
activeCard = this;
|
||||
return showTimeline.apply(this, args);
|
||||
};
|
||||
Card.prototype._removeTimelineModal = function (...args) {
|
||||
if (activeCard === this) activeCard = undefined;
|
||||
return removeTimeline.apply(this, args);
|
||||
};
|
||||
Card.prototype.updated = function (...args) {
|
||||
updated.apply(this, args);
|
||||
if (this._config?.auto_open_timeline && this.hass && this.isConnected && !autoOpened.has(this)) {
|
||||
autoOpened.add(this);
|
||||
openCamera(this, this._config.timeline_camera).catch(reportError);
|
||||
}
|
||||
};
|
||||
Card.prototype._cleanup = function (...args) {
|
||||
autoOpened.delete(this);
|
||||
this._removeTimelineModal();
|
||||
return cleanup.apply(this, args);
|
||||
};
|
||||
|
||||
// Nine cameras should remain discoverable on both desktop and phones.
|
||||
const style = document.createElement("style");
|
||||
style.textContent = `
|
||||
.frigate-timeline-modal .timeline-camera-tabs { flex-wrap: wrap; overflow-x: visible; }
|
||||
.frigate-timeline-modal .timeline-transport-controls { flex-wrap: wrap; min-width: 0; }
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@ -0,0 +1,264 @@
|
||||
// Keep one wall-clock selection across cameras, including recording gaps.
|
||||
import "/local/frigate-events-auth.js?v=2";
|
||||
|
||||
const Card = customElements.get("frigate-events-card");
|
||||
const installed = Symbol.for("frigate-events-card.shared-time");
|
||||
const RECORDING_DELAY = 30;
|
||||
|
||||
if (Card?.prototype._showTimelineModal && !Card.prototype[installed]) {
|
||||
Card.prototype[installed] = true;
|
||||
const show = Card.prototype._showTimelineModal;
|
||||
const render = Card.prototype._renderTimelineContent;
|
||||
const load = Card.prototype._loadTimelineVideo;
|
||||
const updatePlayhead = Card.prototype._updateTimelinePlayheadUI;
|
||||
const offsetToClock = Card.prototype._videoOffsetToWallClock;
|
||||
const advance = Card.prototype._advanceToNextTimelineWindow;
|
||||
const states = new WeakMap();
|
||||
const containers = new WeakSet();
|
||||
|
||||
function selectedTime(card, state) {
|
||||
const video = card._timelineVideoEl;
|
||||
if (!state.loading && !state.gap && video?.readyState >= 2) {
|
||||
return card._videoOffsetToWallClock(video.currentTime);
|
||||
}
|
||||
return state.target;
|
||||
}
|
||||
|
||||
function showMode(card, state) {
|
||||
card._timelineContainer?.querySelector('[data-jump="now"]')
|
||||
?.setAttribute("aria-pressed", String(state.now));
|
||||
}
|
||||
|
||||
async function changeCamera(card, camera) {
|
||||
const state = states.get(card);
|
||||
if (!state || camera === card._timelineCamera) return;
|
||||
state.target = state.now ? Date.now() / 1000 - RECORDING_DELAY : selectedTime(card, state);
|
||||
state.pauseAfterLoad = state.loading ? state.pauseAfterLoad : !!card._timelineVideoEl?.paused;
|
||||
state.loading = true;
|
||||
const request = ++state.request;
|
||||
const container = card._timelineContainer;
|
||||
// Do not interpret an outgoing video's offset using the incoming camera's
|
||||
// recording map, or expose its first frame before the requested seek.
|
||||
card._timelineVideoEl?.pause();
|
||||
card._clearTimelineSpeedInterval();
|
||||
if (card._timelineVideoEl) card._timelineVideoEl.hidden = true;
|
||||
const loading = container.querySelector(".timeline-player-loading");
|
||||
loading.style.display = "flex";
|
||||
loading.textContent = "Loading camera at the selected time...";
|
||||
card._isAdvancingTimeline = false;
|
||||
card._timelineCamera = camera;
|
||||
container.querySelectorAll(".timeline-camera-tab").forEach(tab => {
|
||||
tab.classList.toggle("active", tab.dataset.camera === camera);
|
||||
});
|
||||
container.querySelector(".timeline-camera-badge").textContent = card._formatCameraName(camera);
|
||||
if (state.now) {
|
||||
card._timelineEndTs = Math.floor(Date.now() / 1000);
|
||||
card._timelineStartTs = card._timelineEndTs - card._timelineWindowDurationSec;
|
||||
}
|
||||
card._updateTimelineWindowUI();
|
||||
await card._fetchTimelineEvents();
|
||||
if (request !== state.request || card._timelineContainer !== container) return;
|
||||
card._updateTimelineScrubberEvents();
|
||||
await card._loadTimelineVideo(state.target);
|
||||
}
|
||||
|
||||
Card.prototype._showTimelineModal = function (camera, timestamp, event) {
|
||||
const now = !Number.isFinite(timestamp);
|
||||
const state = { now, target: now ? Date.now() / 1000 - RECORDING_DELAY : timestamp,
|
||||
loading: true, gap: false, request: 0, loadRequest: 0 };
|
||||
states.set(this, state);
|
||||
const result = show.call(this, camera, state.target, event);
|
||||
showMode(this, state);
|
||||
return result;
|
||||
};
|
||||
|
||||
Card.prototype._advanceToNextTimelineWindow = async function () {
|
||||
const state = states.get(this);
|
||||
if (!state || !this.hass) return advance.call(this);
|
||||
if (state.advancePending || this._isAdvancingTimeline || !this._timelineContainer) return;
|
||||
const container = this._timelineContainer;
|
||||
const camera = this._timelineCamera;
|
||||
const end = this._timelineEndTs;
|
||||
const request = state.request;
|
||||
state.advancePending = true;
|
||||
try {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
let hasLaterFootage = false;
|
||||
if (end < now - RECORDING_DELAY) {
|
||||
const response = await this.hass.callWS({ type: "frigate/recordings/get",
|
||||
instance_id: this._config.frigate_client_id || "frigate", camera,
|
||||
after: Math.floor(end), before: now });
|
||||
const recordings = typeof response === "string" ? JSON.parse(response) : response;
|
||||
hasLaterFootage = recordings.some(recording => recording.end_time > end);
|
||||
}
|
||||
if (states.get(this) !== state || state.request !== request || this._timelineContainer !== container
|
||||
|| this._timelineCamera !== camera || this._timelineEndTs !== end) return;
|
||||
// Keep the last frame when saved footage ends. The vendor otherwise
|
||||
// requests an empty window as real time advances during playback.
|
||||
if (hasLaterFootage) return advance.call(this);
|
||||
this._timelineVideoEl?.pause();
|
||||
this._clearTimelineSpeedInterval();
|
||||
this._updateTimelinePlayheadUI();
|
||||
} catch (error) {
|
||||
console.warn("Unable to check for later footage", error?.message);
|
||||
} finally {
|
||||
state.advancePending = false;
|
||||
}
|
||||
};
|
||||
|
||||
Card.prototype._renderTimelineContent = function (container) {
|
||||
render.call(this, container);
|
||||
if (containers.has(container)) return;
|
||||
containers.add(container);
|
||||
container.addEventListener("playing", event => {
|
||||
const state = states.get(this);
|
||||
const video = event.target;
|
||||
const hls = this._timelineActiveVideo === "a" ? this._timelineHlsA : this._timelineHlsB;
|
||||
const source = hls?.url || video.currentSrc || video.src || "";
|
||||
if (state && video === this._timelineVideoEl && !video.paused
|
||||
&& source.includes(`/${encodeURIComponent(this._timelineCamera)}/`)) {
|
||||
state.loading = false;
|
||||
video.hidden = false;
|
||||
const loading = container.querySelector(".timeline-player-loading");
|
||||
if (loading) loading.style.display = "none";
|
||||
if (state.pauseAfterLoad) {
|
||||
state.pauseAfterLoad = false;
|
||||
this._timelineVideoEl?.pause();
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
container.addEventListener("click", event => {
|
||||
const state = states.get(this);
|
||||
if (!state) return;
|
||||
const tab = event.target.closest(".timeline-camera-tab");
|
||||
if (tab) {
|
||||
event.stopImmediatePropagation();
|
||||
changeCamera(this, tab.dataset.camera).catch(error => console.warn("Camera time selection failed", error?.message));
|
||||
return;
|
||||
}
|
||||
const jump = event.target.closest("[data-jump]");
|
||||
if (jump?.dataset.jump === "now") {
|
||||
event.stopImmediatePropagation();
|
||||
state.now = true;
|
||||
state.target = Date.now() / 1000 - RECORDING_DELAY;
|
||||
state.loading = true;
|
||||
this._isAdvancingTimeline = false;
|
||||
this._timelineEndTs = Math.floor(Date.now() / 1000);
|
||||
this._timelineStartTs = this._timelineEndTs - this._timelineWindowDurationSec;
|
||||
this._updateTimelineWindowUI();
|
||||
showMode(this, state);
|
||||
const request = ++state.request;
|
||||
this._fetchTimelineEvents().then(() => {
|
||||
if (request !== state.request || this._timelineContainer !== container) return;
|
||||
this._updateTimelineScrubberEvents();
|
||||
return this._loadTimelineVideo(state.target);
|
||||
}).catch(error => console.warn("Timeline Now selection failed", error?.message));
|
||||
return;
|
||||
}
|
||||
const skip = event.target.closest("[data-skip]");
|
||||
const marker = event.target.closest(".timeline-event-marker");
|
||||
if (state.gap && (skip || marker)) {
|
||||
event.stopImmediatePropagation();
|
||||
state.now = false;
|
||||
const target = marker ? Number(marker.dataset.eventStart) : state.target + Number(skip.dataset.skip);
|
||||
this._loadTimelineVideo(Math.max(this._timelineStartTs, Math.min(this._timelineEndTs, target)));
|
||||
return;
|
||||
}
|
||||
if (jump || event.target.closest("[data-skip],.timeline-event-marker")) {
|
||||
state.now = false;
|
||||
showMode(this, state);
|
||||
}
|
||||
if (event.target.closest('[data-action="toggle-play"],.timeline-player-container')
|
||||
&& !this._timelineVideoEl?.paused) {
|
||||
state.target = selectedTime(this, state);
|
||||
state.now = false;
|
||||
showMode(this, state);
|
||||
}
|
||||
}, true);
|
||||
container.addEventListener("pointerdown", event => {
|
||||
if (event.target.closest(".timeline-track-container")) {
|
||||
const state = states.get(this);
|
||||
state.now = false;
|
||||
showMode(this, state);
|
||||
}
|
||||
}, true);
|
||||
container.addEventListener("pointerup", event => {
|
||||
const state = states.get(this);
|
||||
const track = event.target.closest(".timeline-track-container");
|
||||
if (!state?.gap || !track || event.target.closest(".timeline-event-marker")) return;
|
||||
const bounds = track.getBoundingClientRect();
|
||||
const fraction = Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width));
|
||||
this._loadTimelineVideo(this._timelineStartTs + fraction * (this._timelineEndTs - this._timelineStartTs));
|
||||
}, true);
|
||||
container.addEventListener("change", event => {
|
||||
if (event.target.matches(".timeline-datetime-input")) {
|
||||
const state = states.get(this);
|
||||
state.now = false;
|
||||
showMode(this, state);
|
||||
}
|
||||
}, true);
|
||||
};
|
||||
|
||||
Card.prototype._loadTimelineVideo = async function (target, autoAdvance = false) {
|
||||
const state = states.get(this);
|
||||
const request = state ? ++state.loadRequest : 0;
|
||||
if (state && Number.isFinite(target) && !autoAdvance) {
|
||||
state.target = target;
|
||||
state.loading = true;
|
||||
state.gap = !(this._timelineRecordings || []).some(recording =>
|
||||
target >= recording.start_time - 0.05 && target <= recording.end_time + 0.05);
|
||||
}
|
||||
if (!autoAdvance) for (const video of [this._timelineVideoA, this._timelineVideoB]) {
|
||||
if (video) { video.pause(); video.hidden = true; }
|
||||
}
|
||||
if (!autoAdvance) this._clearTimelineSpeedInterval();
|
||||
await load.call(this, target, autoAdvance);
|
||||
if (!state || states.get(this) !== state || request !== state.loadRequest || !this._timelineContainer) return;
|
||||
if (state.gap && !autoAdvance) {
|
||||
for (const field of ["_timelineHlsA", "_timelineHlsB"]) {
|
||||
this[field]?.destroy();
|
||||
this[field] = null;
|
||||
}
|
||||
for (const video of [this._timelineVideoA, this._timelineVideoB]) {
|
||||
if (!video) continue;
|
||||
video.pause();
|
||||
video.hidden = true;
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
}
|
||||
for (const field of ["_timelineLoadingTimeout", "_timelineAdvanceTimeout", "_timelineSlotTransitionTimeout"]) {
|
||||
clearTimeout(this[field]);
|
||||
this[field] = undefined;
|
||||
}
|
||||
this._isAdvancingTimeline = false;
|
||||
const loading = this._timelineContainer.querySelector(".timeline-player-loading");
|
||||
loading.style.display = "flex";
|
||||
loading.textContent = `No saved footage at ${new Date(state.target * 1000).toLocaleTimeString()}. Choose another camera or time.`;
|
||||
this._updateTimelinePlayheadUI();
|
||||
}
|
||||
showMode(this, state);
|
||||
};
|
||||
|
||||
Card.prototype._updateTimelinePlayheadUI = function (...args) {
|
||||
const state = states.get(this);
|
||||
if ((!state?.gap && !state?.loading) || !this._timelineContainer) return updatePlayhead.apply(this, args);
|
||||
if (state.loading && !state.gap) {
|
||||
const loading = this._timelineContainer.querySelector(".timeline-player-loading");
|
||||
if (loading) loading.style.display = "flex";
|
||||
}
|
||||
const span = this._timelineEndTs - this._timelineStartTs;
|
||||
const head = this._timelineContainer.querySelector(".timeline-playhead");
|
||||
if (head && span > 0) head.style.left = `${Math.max(0, Math.min(100, (state.target - this._timelineStartTs) / span * 100))}%`;
|
||||
const badge = this._timelineContainer.querySelector("[data-timeline-current-time]");
|
||||
if (badge) badge.textContent = new Date(state.target * 1000).toLocaleTimeString();
|
||||
};
|
||||
|
||||
Card.prototype._videoOffsetToWallClock = function (offset) {
|
||||
const state = states.get(this);
|
||||
return state?.gap || state?.loading ? state.target : offsetToClock.call(this, offset);
|
||||
};
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.textContent = '.frigate-timeline-modal [data-jump="now"][aria-pressed="true"] { background: #1e3a5f; border-color: #3b82f6; color: #bfdbfe; } .frigate-timeline-modal video[hidden] { display: none; }';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
Loading…
Reference in new issue