diff --git a/README.md b/README.md index bb15f84..bde5120 100644 --- a/README.md +++ b/README.md @@ -5,24 +5,32 @@ A Flask web app for browsing and playing TeslaCam (Tesla Dashcam/Sentry) recordi ## Features - **Recursive scan** of the TeslaCam directory tree (`RecentClips`, `SavedClips`, `SentryClips`, etc.) for `.mp4` files matching the TeslaCam naming convention `YYYY-MM-DD_HH-MM-SS-${camera_view}.mp4`. -- **Categorization by camera view** — front, left repeater, right repeater, rear, left pillar, right pillar — with friendly display names. +- **Categorization by camera view** — front, left repeater, right repeater, back, left pillar, right pillar — with friendly display names. - **Multi-angle synchronized playback** — clips sharing the same timestamp are grouped so you can switch between camera angles for the same moment. -- **Search & sort** — filter clips by filename, sort oldest-first or A–Z. +- **3-column camera selector** with a top-down Model Y graphic showing camera positions. +- **Auto-play** — oldest front camera clip plays on page load. - **Safe streaming** — videos are served via Flask with path resolution relative to the configured TeslaCam root. +- **Password-protected** — simple session-based login page. - **Dark Tesla-themed UI** built with responsive HTML/CSS (no build step). ## Project Structure ```txt tesla-cam-view/ -├── videoplayer.py # Flask app: scanning, routing, video streaming +├── videoplayer.py # Flask app: scanning, routing, video streaming, auth ├── requirements.txt # Python dependencies ├── .env # Local configuration (git-ignored) ├── .env.example # Configuration template (committed) ├── .gitignore ├── templates/ -│ ├── index.html # Main page: camera tabs, player, clip list +│ ├── index.html # Main page: video player + 3-col camera selector +│ ├── login.html # Password login page │ └── camera_view.html # Single-camera view page +├── static/ +│ ├── css/ +│ │ └── style.css # Dark Tesla-themed styles +│ └── js/ +│ └── app.js # Video player, camera switching, prefetch └── README.md ``` @@ -68,6 +76,8 @@ process manager. | Variable | Default | Description | | -------------------- | --------------- | ----------------------------------------------- | | `TESLACAM_DIRECTORY` | `/mnt/TeslaCam` | Root directory of the TeslaCam USB drive | +| `LOGIN_PASSWORD` | `teslacam` | Password for the web login page | +| `SECRET_KEY` | auto-generated | Flask session secret (set in production) | | `FLASK_RUN_HOST` | `0.0.0.0` | Bind address (local dev server only) | | `FLASK_RUN_PORT` | `5000` | Port (local dev server only) | | `FLASK_DEBUG` | `0` | Enable Flask debug mode (local dev server only) | @@ -104,16 +114,14 @@ gunicorn -w 2 -b 0.0.0.0:5000 'videoplayer:app' For a systemd service or other process manager, set the variable in the unit file / environment rather than relying on `.env`. -- The **main page** lists all clips grouped by camera view with a player and searchable clip list. -- Click a clip to play it; use the camera switch buttons to jump between angles for the same timestamp. -- The `/camera/` route shows all clips for a single camera. +- The **main page** lists all clips grouped by camera view with a player. +- Click a play button to start playing; use the camera switch buttons to jump between angles for the same timestamp. ## Routes | Route | Description | | ------------------------ | ---------------------------------------------------- | | `/` | Main page with all videos categorized by camera view | -| `/camera/` | All videos for a single camera view | | `/video/` | Streams a video file from the TeslaCam directory | | `/refresh` | Forces a re-scan of the TeslaCam directory | @@ -124,7 +132,7 @@ file / environment rather than relying on `.env`. | `front` | Front | | `left_repeater` | Left Repeater | | `right_repeater` | Right Repeater | -| `rear` | Rear | +| `back` | Back | | `left_pillar` | Left Pillar | | `right_pillar` | Right Pillar | diff --git a/static/css/style.css b/static/css/style.css index f00550e..f04561c 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -82,75 +82,25 @@ header { text-transform: uppercase; } -.search-wrap { - flex: 1 1 320px; - max-width: 480px; - position: relative; -} -.search-wrap input { - width: 100%; - padding: 11px 40px 11px 40px; - background: var(--bg-tertiary); - border: 1px solid var(--border); - border-radius: 30px; - color: var(--text-primary); - font-size: 14px; - outline: none; - transition: - border-color 0.2s, - box-shadow 0.2s; -} -.search-wrap input:focus { - border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(227, 25, 55, 0.15); -} -.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; - line-height: 1; -} -.search-wrap .clear-btn.visible { - display: flex; -} - -/* Layout */ +/* Layout: single column, centered */ .layout { - display: grid; - grid-template-columns: minmax(0, 1.9fr) minmax(320px, 1fr); - gap: 20px; - padding: 20px 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; + padding: 24px; flex: 1; - align-items: start; + max-width: 960px; + margin: 0 auto; + width: 100%; } /* Video Player Section */ .player-panel { - position: sticky; - top: 84px; + width: 100%; display: flex; flex-direction: column; - gap: 14px; + gap: 12px; } .video-stage { @@ -161,6 +111,7 @@ header { box-shadow: var(--shadow); border: 1px solid var(--border); aspect-ratio: 16 / 9; + width: 100%; } .video-stage video { width: 100%; @@ -226,56 +177,6 @@ header { box-shadow: 0 0 8px var(--accent); } -/* Camera Synchronized Switch Bar */ -.cam-switch { - display: flex; - flex-wrap: wrap; - gap: 8px; - padding: 12px; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: var(--radius); -} -.cam-switch .cs-label { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 1px; - color: var(--text-muted); - width: 100%; - margin-bottom: 2px; -} -.cam-switch button { - flex: 1 1 auto; - min-width: 88px; - padding: 9px 10px; - background: var(--bg-tertiary); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - color: var(--text-secondary); - font-size: 12.5px; - font-weight: 600; - cursor: pointer; - transition: all 0.15s; - display: flex; - align-items: center; - justify-content: center; - gap: 6px; -} -.cam-switch button:hover:not(:disabled) { - background: var(--bg-elevated); - color: var(--text-primary); - border-color: #444; -} -.cam-switch button.active { - background: var(--accent); - border-color: var(--accent); - color: #fff; -} -.cam-switch button:disabled { - opacity: 0.3; - cursor: not-allowed; -} - .now-playing { font-size: 13px; color: var(--text-secondary); @@ -294,231 +195,112 @@ header { font-variant-numeric: tabular-nums; } -/* Sidebar & Clip List */ -.sidebar { - display: flex; - flex-direction: column; - gap: 14px; - min-width: 0; -} - -.cam-tabs { - display: flex; - flex-wrap: wrap; - gap: 8px; -} -.cam-tabs .tab { - padding: 8px 14px; +/* ============================================= + Camera Selector: 3-column grid + car graphic + ============================================= */ +.cam-selector { + width: 100%; background: var(--bg-secondary); border: 1px solid var(--border); - border-radius: 30px; + border-radius: var(--radius); + padding: 20px 24px; +} + +.cs-heading { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-muted); + margin-bottom: 16px; + text-align: center; +} + +.cs-grid { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 16px; + align-items: center; +} + +.cs-col { + display: flex; + flex-direction: column; + gap: 10px; +} + +.cs-col:first-child { + align-items: flex-end; +} + +.cs-col:last-child { + align-items: flex-start; +} + +.cs-col:nth-child(2) { + align-items: center; +} + +.cs-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); color: var(--text-secondary); font-size: 13px; font-weight: 600; cursor: pointer; transition: all 0.15s; - display: flex; - align-items: center; - gap: 7px; + white-space: nowrap; + min-width: 140px; + justify-content: center; } -.cam-tabs .tab:hover { - background: var(--bg-tertiary); + +.cs-btn:hover:not(:disabled) { + background: var(--bg-elevated); color: var(--text-primary); + border-color: #444; } -.cam-tabs .tab.active { + +.cs-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; } -.cam-tabs .tab .count { + +.cs-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.cs-btn-label { + flex: 1; +} + +.cs-btn-count { background: rgba(255, 255, 255, 0.12); - padding: 1px 7px; + padding: 1px 8px; border-radius: 20px; font-size: 11px; font-weight: 700; } -.cam-tabs .tab.active .count { + +.cs-btn.active .cs-btn-count { background: rgba(0, 0, 0, 0.25); } -.list-toolbar { +/* Model Y Top-Down Car Graphic */ +.car-graphic { 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; + justify-content: center; } -.clip-list { - display: flex; - flex-direction: column; - gap: 8px; - max-height: calc(100vh - 260px); - 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-list::-webkit-scrollbar-track { - background: transparent; -} - -.cam-group-header { - font-size: 12px; - text-transform: uppercase; - letter-spacing: 1px; - color: var(--text-muted); - padding: 10px 4px 4px; - font-weight: 700; - display: flex; - align-items: center; - justify-content: space-between; - position: sticky; - top: 0; - background: var(--bg-primary); - z-index: 2; -} -.cam-group-header a { - color: var(--accent); - text-decoration: none; - font-size: 11px; - font-weight: 600; - text-transform: none; - letter-spacing: 0; - display: inline-flex; - align-items: center; - gap: 4px; -} -.cam-group-header a:hover { - text-decoration: underline; -} - -.clip { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 12px; - background: var(--bg-secondary); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - cursor: pointer; - transition: all 0.15s; -} -.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 .thumb { - flex-shrink: 0; - width: 44px; - height: 44px; - border-radius: 8px; - background: var(--bg-tertiary); - display: grid; - place-items: center; - color: var(--accent); -} -.clip.playing .thumb { - background: rgba(227, 25, 55, 0.15); -} -.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: 2px; - display: flex; - gap: 8px; - flex-wrap: wrap; -} -.clip .meta .sub .cam-pill { - color: var(--text-secondary); - background: var(--bg-tertiary); - padding: 0 7px; - border-radius: 20px; - font-weight: 600; -} -.clip .play-ico { - color: var(--text-muted); - flex-shrink: 0; -} -.clip.playing .play-ico { - color: var(--accent); -} - -.empty { - text-align: center; - padding: 40px 20px; - color: var(--text-muted); - font-size: 14px; -} -.empty svg { - width: 40px; - height: 40px; - opacity: 0.3; - margin-bottom: 10px; -} - -@media (max-width: 960px) { - .layout { - grid-template-columns: 1fr; - } - .player-panel { - position: static; - } - .clip-list { - max-height: none; - } -} -@media (max-width: 560px) { - header { - padding: 12px 16px; - } - .layout { - padding: 14px 16px; - } - .brand small { - display: none; - } -} - -[hidden] { - display: none !important; +.car-svg { + width: 60px; + height: 200px; } /* ============================================= @@ -634,3 +416,57 @@ header { background: rgba(227, 25, 55, 0.08); border-radius: var(--radius-sm); } + +/* ============================================= + Responsive + ============================================= */ +@media (max-width: 700px) { + .cs-grid { + grid-template-columns: 1fr; + gap: 10px; + } + + .cs-col:first-child, + .cs-col:last-child { + flex-direction: row; + flex-wrap: wrap; + justify-content: center; + align-items: center; + } + + .cs-col:nth-child(2) { + flex-direction: row; + gap: 10px; + } + + .car-graphic { + order: -1; + } + + .car-svg { + width: 40px; + height: 140px; + } + + .cs-btn { + min-width: 120px; + font-size: 12px; + padding: 8px 12px; + } +} + +@media (max-width: 560px) { + header { + padding: 12px 16px; + } + .layout { + padding: 14px 16px; + } + .brand small { + display: none; + } +} + +[hidden] { + display: none !important; +} diff --git a/static/js/app.js b/static/js/app.js index 845eec2..89c2866 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -1,67 +1,32 @@ (function () { "use strict"; - const RAW = JSON.parse( - document.getElementById("videoData").textContent || "{}", - ); - - const cameras = {}; - const clips = []; - Object.keys(RAW).forEach(function (key) { - const cam = RAW[key]; - cameras[key] = cam.display; - (cam.videos || []).forEach(function (v) { - clips.push({ - cam: key, - camDisplay: cam.display, - filename: v.filename, - timestamp: v.timestamp, - rel_path: v.rel_path, - url: v.url, - }); - }); - }); - - // Index clips by timestamp for angle synchronization - const byTimestamp = {}; - clips.forEach(function (c) { - if (!byTimestamp[c.timestamp]) byTimestamp[c.timestamp] = {}; - byTimestamp[c.timestamp][c.cam] = c; - }); - + // ---- DOM refs ---- const player = document.getElementById("player"); const noiseCanvas = document.getElementById("noiseCanvas"); const placeholder = document.getElementById("videoPlaceholder"); const badge = document.getElementById("videoBadge"); const badgeText = document.getElementById("badgeText"); - const clipListEl = document.getElementById("clipList"); - const searchBox = document.getElementById("searchBox"); - const clearSearch = document.getElementById("clearSearch"); - const camTabsEl = document.getElementById("camTabs"); - const sortSelect = document.getElementById("sortSelect"); - const resultCount = document.getElementById("resultCount"); - const camSwitchBtns = document.getElementById("camSwitchButtons"); const nowPlaying = document.getElementById("nowPlaying"); const npName = document.getElementById("npName"); const npTime = document.getElementById("npTime"); - document.getElementById("countAll").textContent = clips.length; + const csBtns = document.querySelectorAll(".cs-btn[data-cam]"); - let activeCam = "all"; - let query = ""; - let sortMode = "oldest"; - let current = null; + // ---- State ---- + let allVideos = {}; // { camKey: { display, videos: [...] } } + let byTimestamp = {}; // { timestamp: { camKey: clip } } + let currentClip = null; // currently playing clip object + let currentCam = "front"; // active camera key - // ---- Static noise canvas for loading indicator ---- - - var noiseCtx = noiseCanvas.getContext("2d"); - var noiseAnimId = null; - var noiseRunning = false; + // ---- Static noise canvas ---- + const noiseCtx = noiseCanvas.getContext("2d"); + let noiseAnimId = null; + let noiseRunning = false; function noiseStart() { if (noiseRunning) return; noiseRunning = true; noiseCanvas.classList.remove("hidden"); - noiseAnimId = null; noiseTick(); } @@ -76,14 +41,12 @@ function noiseTick() { if (!noiseRunning) return; - var w = noiseCanvas.width; - var h = noiseCanvas.height; - var rect = noiseCanvas.getBoundingClientRect(); - // Render at reduced resolution (160px wide) for performance, - // CSS image-rendering: pixelated handles the upscale. - var targetW = Math.max(1, Math.min(160, (rect.width * 0.25) | 0)); - var ratio = rect.height / Math.max(1, rect.width); - var targetH = Math.max(1, (targetW * ratio) | 0); + let w = noiseCanvas.width; + let h = noiseCanvas.height; + const rect = noiseCanvas.getBoundingClientRect(); + const targetW = Math.max(1, Math.min(160, (rect.width * 0.25) | 0)); + const ratio = rect.height / Math.max(1, rect.width); + const targetH = Math.max(1, (targetW * ratio) | 0); if (w !== targetW || h !== targetH) { noiseCanvas.width = targetW; noiseCanvas.height = targetH; @@ -94,10 +57,10 @@ noiseAnimId = requestAnimationFrame(noiseTick); return; } - var imageData = noiseCtx.createImageData(w, h); - var data = imageData.data; - for (var i = 0; i < data.length; i += 4) { - var v = (Math.random() * 255) | 0; + const imageData = noiseCtx.createImageData(w, h); + const data = imageData.data; + for (let i = 0; i < data.length; i += 4) { + const v = (Math.random() * 255) | 0; data[i] = v; data[i + 1] = v; data[i + 2] = v; @@ -107,73 +70,7 @@ noiseAnimId = requestAnimationFrame(noiseTick); } - // Start noise immediately on page load (no video selected yet) - noiseStart(); - - // ---- Prefetch helpers ---- - - // Track already-prefetched URLs so we don't re-fetch - var prefetchedUrls = {}; - - /** - * Prefetch a video URL using a element. - * The browser downloads the resource in the background at low priority - * and caches it for instant playback later. - */ - function prefetchVideo(url) { - if (!url || prefetchedUrls[url]) return; - prefetchedUrls[url] = true; - var link = document.createElement("link"); - link.rel = "prefetch"; - link.as = "video"; - link.href = url; - document.head.appendChild(link); - } - - /** - * After the current video starts playing, prefetch: - * 1. All sibling camera angles for the same timestamp - * 2. The next clip in the same camera's timeline - */ - function schedulePrefetch(clip) { - // Use requestIdleCallback to run after the browser has handled the - // current frame (i.e. the video has started loading). Fall back to a - // short setTimeout if requestIdleCallback isn't available. - var doPrefetch = function () { - // 1. Sibling camera angles (same timestamp, different cameras) - var group = byTimestamp[clip.timestamp] || {}; - Object.keys(group).forEach(function (key) { - if (key !== clip.cam) { - prefetchVideo(group[key].url); - } - }); - - // 2. Next clip in the same camera's timeline - var sameCam = clips - .filter(function (c) { - return c.cam === clip.cam; - }) - .sort(function (a, b) { - if (sortMode === "name") return a.filename.localeCompare(b.filename); - var da = tsToDate(a.timestamp), - db = tsToDate(b.timestamp); - return (da ? da.getTime() : 0) - (db ? db.getTime() : 0); - }); - var idx = sameCam.findIndex(function (c) { - return c.timestamp === clip.timestamp && c.filename === clip.filename; - }); - if (idx > -1 && sameCam[idx + 1]) { - prefetchVideo(sameCam[idx + 1].url); - } - }; - - if (typeof requestIdleCallback === "function") { - requestIdleCallback(doPrefetch, { timeout: 2000 }); - } else { - setTimeout(doPrefetch, 100); - } - } - + // ---- Helpers ---- function tsToDate(ts) { const m = /^(\d{4})-(\d{2})-(\d{2})[_ ](\d{2})-(\d{2})-(\d{2})/.exec( ts || "", @@ -195,136 +92,50 @@ }); } - function esc(s) { - return String(s).replace(/[&<>"']/g, function (c) { - return { - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'", - }[c]; - }); + // ---- Prefetch ---- + const prefetchedUrls = {}; + + function prefetchVideo(url) { + if (!url || prefetchedUrls[url]) return; + prefetchedUrls[url] = true; + const link = document.createElement("link"); + link.rel = "prefetch"; + link.as = "video"; + link.href = url; + document.head.appendChild(link); } - function getFiltered() { - let out = clips.filter(function (c) { - if (activeCam !== "all" && c.cam !== activeCam) return false; - if (query) { - const hay = ( - c.filename + - " " + - c.timestamp + - " " + - c.camDisplay + - " " + - prettyTs(c.timestamp) - ).toLowerCase(); - if (hay.indexOf(query) === -1) return false; - } - return true; - }); - out.sort(function (a, b) { - if (sortMode === "name") return a.filename.localeCompare(b.filename); - const da = tsToDate(a.timestamp), - db = tsToDate(b.timestamp); - const ta = da ? da.getTime() : 0, - tb = db ? db.getTime() : 0; - return sortMode === "oldest" ? ta - tb : tb - ta; - }); - return out; - } - - function render() { - const list = getFiltered(); - resultCount.textContent = - list.length + (list.length === 1 ? " clip" : " clips"); - - if (!list.length) { - clipListEl.innerHTML = - '
No clips match your filters.
'; - return; - } - - let html = ""; - if (activeCam === "all") { - const groups = {}; - list.forEach(function (c) { - (groups[c.cam] = groups[c.cam] || []).push(c); + function schedulePrefetch(clip) { + const doPrefetch = function () { + // Sibling angles at same timestamp + const group = byTimestamp[clip.timestamp] || {}; + Object.keys(group).forEach(function (key) { + if (key !== clip.cam) prefetchVideo(group[key].url); }); - Object.keys(groups).forEach(function (key) { - html += groupHeader(key, groups[key].length); - groups[key].forEach(function (c) { - html += clipRow(c); - }); + // Next clip in same camera (oldest-first) + const sameCam = (allVideos[clip.cam] && allVideos[clip.cam].videos) || []; + const idx = sameCam.findIndex(function (c) { + return c.timestamp === clip.timestamp && c.filename === clip.filename; }); + if (idx > -1 && sameCam[idx + 1]) prefetchVideo(sameCam[idx + 1].url); + }; + if (typeof requestIdleCallback === "function") { + requestIdleCallback(doPrefetch, { timeout: 2000 }); } else { - html += groupHeader(activeCam, list.length); - list.forEach(function (c) { - html += clipRow(c); - }); + setTimeout(doPrefetch, 100); } - clipListEl.innerHTML = html; - highlightCurrent(); - } - - function groupHeader(key, n) { - return ( - '
' + - esc(cameras[key] || key) + - " · " + - n + - "" + - "
" - ); - } - - function clipRow(c) { - const id = c.cam + "|" + c.timestamp + "|" + c.filename; - return ( - '
' + - '
' + - '' + - "
" + - '
' + - '
' + - esc(c.filename) + - "
" + - '
' + - esc(c.camDisplay) + - "" + - "" + - esc(prettyTs(c.timestamp)) + - "
" + - "
" + - '
' + - '' + - "
" + - "
" - ); } + // ---- Playback ---- function playClip(clip) { if (!clip) return; - current = clip; + currentClip = clip; + currentCam = clip.cam; player.src = clip.url; player.load(); - // Show noise while loading, stop when video starts playing noiseStart(); - var prefetchDone = false; + let prefetchDone = false; function onPlaying() { noiseStop(); if (prefetchDone) return; @@ -334,7 +145,6 @@ } player.addEventListener("playing", onPlaying); player.play().catch(function () { - // Even if autoplay is blocked, still prefetch for when the user clicks play noiseStop(); if (!prefetchDone) { prefetchDone = true; @@ -351,57 +161,26 @@ npName.textContent = clip.filename; npTime.textContent = prettyTs(clip.timestamp); - buildCamSwitch(clip); - highlightCurrent(); + updateCamButtons(clip.cam); } - function buildCamSwitch(clip) { - const group = byTimestamp[clip.timestamp] || {}; - const availKeys = Object.keys(group); - - let html = ""; - Object.keys(cameras).forEach(function (key) { - const has = group[key]; - const isActive = key === clip.cam; - html += - '"; - }); - if (availKeys.length <= 1) { - html += - '' + - "Only one camera view recorded for this timestamp."; - } - camSwitchBtns.innerHTML = html; - } - - function switchCam(key) { - if (!current) return; - const group = byTimestamp[current.timestamp] || {}; - const target = group[key]; + function switchCam(camKey) { + if (!currentClip) return; + const group = byTimestamp[currentClip.timestamp] || {}; + const target = group[camKey]; if (!target) return; const savedTime = player.currentTime || 0; const wasPlaying = !player.paused; - current = target; + currentClip = target; + currentCam = camKey; player.src = target.url; player.load(); badgeText.textContent = target.camDisplay; npName.textContent = target.filename; - // Preserve video playback timestamp position across camera angles player.addEventListener("loadedmetadata", function onMeta() { player.removeEventListener("loadedmetadata", onMeta); try { @@ -412,124 +191,110 @@ if (wasPlaying) player.play().catch(function () {}); }); - camSwitchBtns.querySelectorAll("button").forEach(function (b) { - b.classList.toggle("active", b.getAttribute("data-switch") === key); - }); - highlightCurrent(); + updateCamButtons(camKey); } - function highlightCurrent() { - const rows = clipListEl.querySelectorAll(".clip"); - rows.forEach(function (r) { - const isCur = - current && - r.getAttribute("data-cam") === current.cam && - r.getAttribute("data-ts") === current.timestamp && - r.getAttribute("data-fn") === current.filename; - r.classList.toggle("playing", !!isCur); + function updateCamButtons(activeKey) { + csBtns.forEach(function (btn) { + const key = btn.getAttribute("data-cam"); + btn.classList.toggle("active", key === activeKey); }); } - clipListEl.addEventListener("click", function (e) { - const row = e.target.closest(".clip"); - if (!row) return; - playClip({ - cam: row.getAttribute("data-cam"), - camDisplay: cameras[row.getAttribute("data-cam")], - filename: row.getAttribute("data-fn"), - timestamp: row.getAttribute("data-ts"), - url: row.getAttribute("data-url"), + // ---- Auto-play next clip in same camera ---- + player.addEventListener("ended", function () { + if (!currentClip) return; + const sameCam = + (allVideos[currentClip.cam] && allVideos[currentClip.cam].videos) || []; + const idx = sameCam.findIndex(function (c) { + return ( + c.timestamp === currentClip.timestamp && + c.filename === currentClip.filename + ); }); + if (idx > -1 && sameCam[idx + 1]) playClip(sameCam[idx + 1]); }); - camSwitchBtns.addEventListener("click", function (e) { - const btn = e.target.closest("button[data-switch]"); + // ---- Camera button clicks ---- + document.querySelector(".cs-grid").addEventListener("click", function (e) { + const btn = e.target.closest(".cs-btn[data-cam]"); if (!btn || btn.disabled) return; - switchCam(btn.getAttribute("data-switch")); - }); + const camKey = btn.getAttribute("data-cam"); + if (camKey === currentCam) return; // already active - camTabsEl.addEventListener("click", function (e) { - const tab = e.target.closest(".tab"); - if (!tab) return; - camTabsEl.querySelectorAll(".tab").forEach(function (t) { - t.classList.remove("active"); - }); - tab.classList.add("active"); - activeCam = tab.getAttribute("data-cam"); - render(); - }); - - let searchTimer; - searchBox.addEventListener("input", function () { - clearSearch.classList.toggle("visible", !!searchBox.value); - clearTimeout(searchTimer); - searchTimer = setTimeout(function () { - query = searchBox.value.trim().toLowerCase(); - render(); - }, 120); - }); - - clearSearch.addEventListener("click", function () { - searchBox.value = ""; - query = ""; - clearSearch.classList.remove("visible"); - render(); - searchBox.focus(); - }); - - sortSelect.addEventListener("change", function () { - sortMode = sortSelect.value; - render(); - }); - - // Keyboard Shortcuts: 1-9 (Camera Switch), J/K (Next/Prev Clip) - document.addEventListener("keydown", function (e) { - if (/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) return; - if (e.key >= "1" && e.key <= "9") { - const keys = Object.keys(cameras); - const idx = +e.key - 1; - if (current && keys[idx]) switchCam(keys[idx]); - } else if (e.key === "j" || e.key === "k") { - const list = getFiltered(); - if (!list.length) return; - let i = list.findIndex(function (c) { - return ( - current && - c.cam === current.cam && - c.timestamp === current.timestamp && - c.filename === current.filename - ); - }); - i = e.key === "j" ? Math.min(list.length - 1, i + 1) : Math.max(0, i - 1); - if (list[i]) playClip(list[i]); + // Try same timestamp first, else fall back to oldest in that camera + const group = currentClip ? byTimestamp[currentClip.timestamp] || {} : {}; + const sameTs = group[camKey]; + if (sameTs) { + switchCam(camKey); + } else { + // No same-timestamp match: play oldest clip for this camera + const videos = (allVideos[camKey] && allVideos[camKey].videos) || []; + if (videos.length > 0) playClip(videos[0]); } }); - // Auto-play next clip of the same camera when video finishes - player.addEventListener("ended", function () { - if (!current) return; - // Build a list of clips for the SAME camera, sorted like the sidebar - const sameCam = clips - .filter(function (c) { - return c.cam === current.cam; - }) - .sort(function (a, b) { - if (sortMode === "name") return a.filename.localeCompare(b.filename); - const da = tsToDate(a.timestamp), - db = tsToDate(b.timestamp); - const ta = da ? da.getTime() : 0, - tb = db ? db.getTime() : 0; - return sortMode === "oldest" ? ta - tb : tb - ta; - }); - const i = sameCam.findIndex(function (c) { - return ( - c.cam === current.cam && - c.timestamp === current.timestamp && - c.filename === current.filename - ); - }); - if (i > -1 && sameCam[i + 1]) playClip(sameCam[i + 1]); + // ---- Keyboard shortcuts: 1-6 for camera angles ---- + const camOrder = [ + "front", + "left_repeater", + "right_repeater", + "back", + "left_pillar", + "right_pillar", + ]; + document.addEventListener("keydown", function (e) { + if (/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) return; + if (e.key >= "1" && e.key <= "6") { + const idx = +e.key - 1; + const camKey = camOrder[idx]; + if (!camKey) return; + const group = currentClip ? byTimestamp[currentClip.timestamp] || {} : {}; + const sameTs = group[camKey]; + if (sameTs) { + switchCam(camKey); + } else { + const videos = (allVideos[camKey] && allVideos[camKey].videos) || []; + if (videos.length > 0) playClip(videos[0]); + } + } }); - render(); + // ---- Init: fetch full video data, then auto-play front ---- + function init() { + fetch("/api/videos") + .then(function (r) { + return r.json(); + }) + .then(function (data) { + allVideos = data; + + // Build byTimestamp index + Object.keys(data).forEach(function (camKey) { + const videos = data[camKey].videos || []; + videos.forEach(function (v) { + v.cam = camKey; + v.camDisplay = data[camKey].display; + if (!byTimestamp[v.timestamp]) byTimestamp[v.timestamp] = {}; + byTimestamp[v.timestamp][camKey] = v; + }); + }); + + // Auto-play front if video element has a src + if (player.src && player.src !== window.location.href) { + const frontVideos = + (allVideos["front"] && allVideos["front"].videos) || []; + if (frontVideos.length > 0) { + playClip(frontVideos[0]); + } + } + }) + .catch(function (err) { + console.error("Failed to load video data:", err); + }); + } + + // Start noise immediately + noiseStart(); + init(); })(); diff --git a/templates/index.html b/templates/index.html index d1146fb..feaa039 100644 --- a/templates/index.html +++ b/templates/index.html @@ -25,45 +25,27 @@ Dashcam & Sentry Archive - -
- - - -
- +
- +
- + {% set src = "src='" ~ default_url ~ "'" if default_url else '' %} {% + set hidden = 'hidden' if not default_url else '' %} +
Front
-
+
-
Select a clip from the list to begin playback
+
No front camera footage found
- - -
- Camera Angles — Same Timestamp -
+ Now Playing: + {% if default_url %}Front — oldest clip{% else %}—{% endif + %} - Select a video to unlock synchronized camera switching. -
-
- -
- - +
- - - + diff --git a/videoplayer.py b/videoplayer.py index 66d0eb5..0ce7eea 100644 --- a/videoplayer.py +++ b/videoplayer.py @@ -46,11 +46,18 @@ CAMERA_VIEW_MAPPING = { 'front': 'Front', 'left_repeater': 'Left Repeater', 'right_repeater': 'Right Repeater', - 'rear': 'Rear', + 'back': 'Back', 'left_pillar': 'Left Pillar', 'right_pillar': 'Right Pillar' } +# Camera button layout order: three columns around the car graphic +CAMERA_LAYOUT = [ + ['left_repeater', 'left_pillar'], + ['front', 'back'], + ['right_repeater', 'right_pillar'], +] + # In-memory scan cache. Scanning a large TeslaCam tree on every request is # expensive, so the result is cached and only refreshed after this many seconds # (or when /refresh is hit). Override via the SCAN_CACHE_TTL env var. @@ -157,20 +164,34 @@ def logout(): @login_required def index(): """ - Main page listing all videos categorized by human-readable camera view. + Main page: video player with camera-angle switching via a 3-column + button grid around a top-down Model Y graphic. """ all_videos = scan_teslacam_videos() - # Map raw camera keys to display names for rendering - categorized_videos = {} - for camera_view, video_list in all_videos.items(): - display_name = get_camera_display_name(camera_view) - categorized_videos[display_name] = { - 'raw_key': camera_view, - 'videos': video_list - } + # Build camera info list for the button grid + camera_list = [] + for col in CAMERA_LAYOUT: + col_cams = [] + for key in col: + videos = all_videos.get(key, []) + col_cams.append({ + 'key': key, + 'display': get_camera_display_name(key), + 'count': len(videos), + 'first_url': videos[0]['url'] if videos else None, + }) + camera_list.append(col_cams) - return render_template('index.html', categorized_videos=categorized_videos) + # Default to the oldest front video + front_videos = all_videos.get('front', []) + default_url = front_videos[0]['url'] if front_videos else None + + return render_template( + 'index.html', + camera_list=camera_list, + default_url=default_url, + ) @app.route('/camera/') @@ -215,6 +236,23 @@ def refresh(): return 'Cache refreshed', 200 +@app.route('/api/videos') +@login_required +def api_videos(): + """ + Returns all scanned videos as JSON, grouped by camera view. + Used by the frontend for camera-angle switching. + """ + all_videos = scan_teslacam_videos() + result = {} + for cam_key, video_list in all_videos.items(): + result[cam_key] = { + 'display': get_camera_display_name(cam_key), + 'videos': video_list + } + return jsonify(result) + + @app.route('/health') def health(): """