Files
tesla-cam-view/templates/camera_view.html
T
2026-07-24 12:39:16 +02:00

790 lines
21 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ camera_view_name }} Camera - TeslaCam</title>
<style>
:root {
--bg-primary: #0a0a0c;
--bg-secondary: #17171a;
--bg-tertiary: #202024;
--bg-elevated: #2a2a2f;
--accent: #e31937; /* Tesla Red */
--accent-hover: #ff2b4d;
--text-primary: #f4f4f5;
--text-secondary: #a1a1aa;
--text-muted: #6b6b73;
--border: #303036;
--radius: 12px;
--radius-sm: 8px;
--shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
--font:
"Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Helvetica, Arial, sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
height: 100%;
background: var(--bg-primary);
color: var(--text-primary);
font-family: var(--font);
-webkit-font-smoothing: antialiased;
}
body {
display: flex;
flex-direction: column;
min-height: 100vh;
}
/* Header */
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 24px;
background: linear-gradient(
180deg,
#131316 0%,
var(--bg-secondary) 100%
);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 50;
gap: 16px;
flex-wrap: wrap;
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.btn-back {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 30px;
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
text-decoration: none;
transition: all 0.15s ease;
}
.btn-back:hover {
background: var(--bg-elevated);
border-color: var(--accent);
color: #fff;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
font-size: 17px;
}
.brand .logo {
width: 24px;
height: 24px;
display: grid;
place-items: center;
}
.brand .logo svg {
width: 100%;
height: 100%;
fill: var(--accent);
}
.search-wrap {
flex: 1 1 300px;
max-width: 420px;
position: relative;
}
.search-wrap input {
width: 100%;
padding: 10px 38px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 30px;
color: var(--text-primary);
font-size: 13.5px;
outline: none;
transition: border-color 0.2s;
}
.search-wrap input:focus {
border-color: var(--accent);
}
.search-wrap .search-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
pointer-events: none;
}
.search-wrap .clear-btn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: var(--bg-elevated);
border: none;
color: var(--text-secondary);
width: 22px;
height: 22px;
border-radius: 50%;
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
font-size: 14px;
}
.search-wrap .clear-btn.visible {
display: flex;
}
/* Camera Banner Bar */
.cam-banner {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
gap: 16px;
flex-wrap: wrap;
}
.cam-title-area {
display: flex;
align-items: center;
gap: 12px;
}
.cam-title-area h1 {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
letter-spacing: -0.3px;
}
.cam-badge {
background: var(--accent);
color: #fff;
padding: 3px 10px;
border-radius: 20px;
font-size: 12px;
font-weight: 700;
}
.nav-cameras {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.nav-cameras span {
font-size: 12px;
color: var(--text-muted);
margin-right: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.nav-cameras a {
padding: 6px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 20px;
color: var(--text-secondary);
font-size: 12px;
font-weight: 600;
text-decoration: none;
transition: all 0.15s;
}
.nav-cameras a:hover {
background: var(--bg-elevated);
color: var(--text-primary);
}
.nav-cameras a.active {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
/* Layout Grid */
.layout {
display: grid;
grid-template-columns: minmax(0, 1.9fr) minmax(320px, 1fr);
gap: 20px;
padding: 20px 24px;
flex: 1;
align-items: start;
}
/* Player Panel */
.player-panel {
position: sticky;
top: 84px;
display: flex;
flex-direction: column;
gap: 14px;
}
.video-stage {
position: relative;
background: #000;
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
border: 1px solid var(--border);
aspect-ratio: 16 / 9;
}
.video-stage video {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
background: #000;
}
.video-placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: var(--text-muted);
text-align: center;
padding: 20px;
}
.video-placeholder svg {
width: 54px;
height: 54px;
opacity: 0.4;
}
.playback-meta {
padding: 12px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.playback-meta .info {
min-width: 0;
}
.playback-meta .fn {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.playback-meta .ts {
font-size: 12px;
color: var(--accent);
margin-top: 2px;
}
/* Sidebar & Video Queue */
.sidebar {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
font-size: 12px;
color: var(--text-muted);
}
.list-toolbar select {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-secondary);
border-radius: var(--radius-sm);
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
outline: none;
}
.clip-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: calc(100vh - 240px);
overflow-y: auto;
padding-right: 4px;
}
.clip-list::-webkit-scrollbar {
width: 8px;
}
.clip-list::-webkit-scrollbar-thumb {
background: var(--bg-elevated);
border-radius: 10px;
}
.clip {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s ease;
}
.clip:hover {
background: var(--bg-tertiary);
border-color: #3d3d44;
transform: translateX(2px);
}
.clip.playing {
border-color: var(--accent);
background: linear-gradient(
90deg,
rgba(227, 25, 55, 0.12),
var(--bg-tertiary)
);
}
.clip .icon {
width: 38px;
height: 38px;
border-radius: 8px;
background: var(--bg-tertiary);
display: grid;
place-items: center;
color: var(--accent);
flex-shrink: 0;
}
.clip.playing .icon {
background: rgba(227, 25, 55, 0.18);
}
.clip .meta {
min-width: 0;
flex: 1;
}
.clip .meta .fn {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-primary);
}
.clip .meta .sub {
font-size: 11.5px;
color: var(--text-muted);
margin-top: 3px;
}
.empty {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
font-size: 14px;
}
@media (max-width: 960px) {
.layout {
grid-template-columns: 1fr;
}
.player-panel {
position: static;
}
.clip-list {
max-height: none;
}
}
</style>
</head>
<body>
<!-- Header -->
<header>
<div class="header-left">
<a href="/" class="btn-back">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
All Cameras
</a>
<div class="brand">
<span class="logo" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path
d="M12 3C7 3 3.2 4.4 3.2 4.4l.9 1.7s3.1-1 5-.9l.7 1.3C7.9 7.7 6 9 6 9s.6 2.4 3 4.6c2.5-2.2 3-4.6 3-4.6s-1.9-1.3-3.7-1.5l.6-1.3c1.9-.1 5 .9 5 .9l.9-1.7S17 3 12 3z"
/>
</svg>
</span>
TeslaCam
</div>
</div>
<div class="search-wrap">
<span class="search-icon" aria-hidden="true">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
</span>
<input
type="text"
id="searchBox"
placeholder="Search {{ camera_view_name }} clips…"
autocomplete="off"
aria-label="Search clips"
/>
<button class="clear-btn" id="clearSearch" aria-label="Clear search">
&times;
</button>
</div>
</header>
<!-- Camera Route Banner -->
<div class="cam-banner">
<div class="cam-title-area">
<h1>{{ camera_view_name }} Camera</h1>
<span class="cam-badge" id="totalBadge"
>{{ videos | length }} Clips</span
>
</div>
<div class="nav-cameras">
<span>Views:</span>
<a
href="/camera/front"
class="{% if camera_view_key == 'front' %}active{% endif %}"
>Front</a
>
<a
href="/camera/left_repeater"
class="{% if camera_view_key == 'left_repeater' %}active{% endif %}"
>Left Repeater</a
>
<a
href="/camera/right_repeater"
class="{% if camera_view_key == 'right_repeater' %}active{% endif %}"
>Right Repeater</a
>
<a
href="/camera/back"
class="{% if camera_view_key == 'back' %}active{% endif %}"
>Back</a
>
<a
href="/camera/left_pillar"
class="{% if camera_view_key == 'left_pillar' %}active{% endif %}"
>Left Pillar</a
>
<a
href="/camera/right_pillar"
class="{% if camera_view_key == 'right_pillar' %}active{% endif %}"
>Right Pillar</a
>
</div>
</div>
<!-- Main Content -->
<div class="layout">
<!-- Left: Dedicated Player Stage -->
<section class="player-panel">
<div class="video-stage">
<video id="player" controls preload="metadata" playsinline></video>
<div class="video-placeholder" id="placeholder">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<rect x="2" y="6" width="14" height="12" rx="2" />
<path d="M16 10l6-3v10l-6-3" />
</svg>
<div>Select a video clip from the queue to play</div>
</div>
</div>
<div class="playback-meta" id="playbackMeta" style="display: none">
<div class="info">
<div class="fn" id="currentFn">—</div>
<div class="ts" id="currentTs">—</div>
</div>
</div>
</section>
<!-- Right: Video Clip Queue -->
<aside class="sidebar">
<div class="list-toolbar">
<span id="resultCount">0 clips</span>
<select id="sortSelect" aria-label="Sort options">
<option value="oldest">Oldest first</option>
<option value="name">Filename A–Z</option>
</select>
</div>
<div class="clip-list" id="clipList">
<!-- Dynamically populated -->
</div>
</aside>
</div>
<!-- JSON Payload passed from Flask route -->
<script type="application/json" id="videoData">
[
{% for v in videos %}
{
"filename": {{ v.filename | tojson }},
"timestamp": {{ v.timestamp | tojson }},
"rel_path": {{ v.rel_path | tojson }},
"url": {{ v.url | tojson }}
}{% if not loop.last %},{% endif %}
{% endfor %}
]
</script>
<script>
(function () {
"use strict";
const rawData = JSON.parse(
document.getElementById("videoData").textContent || "[]",
);
const player = document.getElementById("player");
const placeholder = document.getElementById("placeholder");
const playbackMeta = document.getElementById("playbackMeta");
const currentFn = document.getElementById("currentFn");
const currentTs = document.getElementById("currentTs");
const clipListEl = document.getElementById("clipList");
const searchBox = document.getElementById("searchBox");
const clearSearch = document.getElementById("clearSearch");
const sortSelect = document.getElementById("sortSelect");
const resultCount = document.getElementById("resultCount");
let query = "";
let sortMode = "oldest";
let currentVideo = null;
function parseTimestamp(ts) {
const m = /^(\d{4})-(\d{2})-(\d{2})[_ ](\d{2})-(\d{2})-(\d{2})/.exec(
ts || "",
);
if (!m) return null;
return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6]);
}
function formatPrettyTs(ts) {
const d = parseTimestamp(ts);
if (!d) return ts || "Unknown Timestamp";
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function escapeHtml(str) {
return String(str).replace(/[&<>"']/g, function (m) {
return {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
}[m];
});
}
function getFilteredVideos() {
let list = rawData.filter(function (item) {
if (!query) return true;
const searchHay = (
item.filename +
" " +
item.timestamp +
" " +
formatPrettyTs(item.timestamp)
).toLowerCase();
return searchHay.indexOf(query) !== -1;
});
list.sort(function (a, b) {
if (sortMode === "name")
return a.filename.localeCompare(b.filename);
const da = parseTimestamp(a.timestamp),
db = parseTimestamp(b.timestamp);
const ta = da ? da.getTime() : 0,
tb = db ? db.getTime() : 0;
return sortMode === "oldest" ? ta - tb : tb - ta;
});
return list;
}
function renderList() {
const list = getFilteredVideos();
resultCount.textContent =
list.length + (list.length === 1 ? " clip" : " clips");
if (!list.length) {
clipListEl.innerHTML =
'<div class="empty">No matching clips found</div>';
return;
}
let html = "";
list.forEach(function (v) {
const isPlaying =
currentVideo && currentVideo.filename === v.filename;
html +=
'<div class="clip ' +
(isPlaying ? "playing" : "") +
'" data-fn="' +
escapeHtml(v.filename) +
'">' +
'<div class="icon">' +
'<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>' +
"</div>" +
'<div class="meta">' +
'<div class="fn">' +
escapeHtml(v.filename) +
"</div>" +
'<div class="sub">' +
escapeHtml(formatPrettyTs(v.timestamp)) +
"</div>" +
"</div>" +
"</div>";
});
clipListEl.innerHTML = html;
}
function playVideo(videoItem) {
if (!videoItem) return;
currentVideo = videoItem;
player.src = videoItem.url;
player.load();
player.play().catch(function () {});
placeholder.style.display = "none";
playbackMeta.style.display = "flex";
currentFn.textContent = videoItem.filename;
currentTs.textContent = formatPrettyTs(videoItem.timestamp);
renderList();
}
// Delegate clip selection click
clipListEl.addEventListener("click", function (e) {
const row = e.target.closest(".clip");
if (!row) return;
const fn = row.getAttribute("data-fn");
const target = rawData.find(function (item) {
return item.filename === fn;
});
if (target) playVideo(target);
});
// Search input with auto debounce
let searchTimer;
searchBox.addEventListener("input", function () {
clearSearch.classList.toggle("visible", !!searchBox.value);
clearTimeout(searchTimer);
searchTimer = setTimeout(function () {
query = searchBox.value.trim().toLowerCase();
renderList();
}, 100);
});
clearSearch.addEventListener("click", function () {
searchBox.value = "";
query = "";
clearSearch.classList.remove("visible");
renderList();
searchBox.focus();
});
sortSelect.addEventListener("change", function () {
sortMode = sortSelect.value;
renderList();
});
// Auto-play next clip in line when video finishes
player.addEventListener("ended", function () {
const list = getFilteredVideos();
if (!currentVideo || !list.length) return;
const currentIndex = list.findIndex(function (item) {
return item.filename === currentVideo.filename;
});
if (currentIndex > -1 && list[currentIndex + 1]) {
playVideo(list[currentIndex + 1]);
}
});
// Keyboard navigation (J = next clip, K = prev clip)
document.addEventListener("keydown", function (e) {
if (/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName))
return;
if (e.key === "j" || e.key === "k") {
const list = getFilteredVideos();
if (!list.length) return;
let idx = list.findIndex(function (item) {
return currentVideo && item.filename === currentVideo.filename;
});
idx =
e.key === "j"
? Math.min(list.length - 1, idx + 1)
: Math.max(0, idx - 1);
if (list[idx]) playVideo(list[idx]);
}
});
// Initial render
renderList();
})();
</script>
</body>
</html>