Compare commits

..
18 Commits
Author SHA1 Message Date
zwitschi 9522f1e414 Refactor layout styles for improved responsiveness; add video height constraint on resize 2026-07-28 11:47:38 +02:00
zwitschi 082ef385bc Refactor CSS styles for improved layout; update logo and heading in login page; shorten camera view names in video player 2026-07-28 11:12:20 +02:00
zwitschi 1eb60f7c06 Refactor layout and styles in CSS; remove unused car SVG from assets 2026-07-28 10:59:35 +02:00
zwitschi 36040d4ddd Update styles and replace SVG logos with image assets 2026-07-28 10:37:10 +02:00
zwitschi 7af3673d8a fix region naming/numbering 2026-07-27 11:01:15 +02:00
zwitschi 891582b47a Add first clip button and update region names in video player 2026-07-27 10:59:40 +02:00
zwitschi 1c7ce1dd0a Add region filter bar and navigation buttons for video clips 2026-07-27 10:35:20 +02:00
zwitschi d12d5e5414 Refactor video player and camera angle switching
- Removed inline JSON data for video clips; replaced with API endpoint to fetch video data.
- Simplified camera angle switching UI with a 3-column button grid around a Model Y graphic.
- Updated camera view mapping to use 'back' instead of 'rear'.
- Enhanced noise canvas handling during video loading.
- Streamlined clip playback logic and added auto-play for next clips in the same camera.
- Improved search and sorting functionality by removing unnecessary elements.
- Updated HTML structure for better accessibility and user experience.
2026-07-25 00:43:43 +02:00
zwitschi 06dc4f5f51 Reorganize Flask app initialization and session secret key setup 2026-07-25 00:06:03 +02:00
zwitschi c237564f61 Add login functionality with password protection and create login page 2026-07-24 23:59:03 +02:00
zwitschi 443ed74093 add noise canvas for loading indicator and improve video loading experience 2026-07-24 15:31:20 +02:00
zwitschi b562b57bb4 implement video prefetching for improved playback performance 2026-07-24 14:49:36 +02:00
zwitschi cca5dd00ae Refactor HTML and JavaScript structure; add health-check endpoint
- Removed inline CSS from index.html and linked to external stylesheet.
- Moved JavaScript code to a separate app.js file for better organization.
- Added a health-check endpoint to the Flask application for monitoring purposes.
- Implemented a static file serving route to handle requests for static assets.
2026-07-24 14:38:40 +02:00
zwitschi 6a0929ac2e update .env.example to include SCAN_CACHE_TTL for directory scan caching 2026-07-24 14:00:29 +02:00
zwitschi 829c28ca69 implement caching for TeslaCam directory scan and add refresh endpoint 2026-07-24 12:58:58 +02:00
zwitschi a4202afe30 add .env.example and update README for environment variable configuration 2026-07-24 12:49:25 +02:00
zwitschi ae208bd39b adjust sorting 2026-07-24 12:39:16 +02:00
zwitschi a38964a1b4 full README 2026-07-24 12:38:59 +02:00
13 changed files with 1627 additions and 1840 deletions

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
# Root TeslaCam directory containing RecentClips/SavedClips/SentryClips
TESLACAM_DIRECTORY=/mnt/TeslaCam
# Local dev server only (ignored when run under a WSGI server like gunicorn)
FLASK_RUN_HOST=0.0.0.0
FLASK_RUN_PORT=5000
FLASK_DEBUG=0
# Seconds to cache the TeslaCam directory scan (default 300). Hit /refresh to
# force a re-scan.
SCAN_CACHE_TTL=3600
LOGIN_PASSWORD=patreon
+2 -1
View File
@@ -7,4 +7,5 @@ __pycache__/
.mypy_cache/ .mypy_cache/
.ruff_cache/ .ruff_cache/
.github/AGENTS.md .github/AGENTS.md
.github/instructions/ .github/instructions/
.env
+150
View File
@@ -1 +1,151 @@
# TeslaCam View # TeslaCam View
A Flask web app for browsing and playing TeslaCam (Tesla Dashcam/Sentry) recordings from a local drive. It scans a TeslaCam folder for video files, categorizes them by camera view, and presents them in a dark, Tesla-styled web interface with synchronized multi-angle playback.
## 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, 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.
- **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, auth
├── requirements.txt # Python dependencies
├── .env # Local configuration (git-ignored)
├── .env.example # Configuration template (committed)
├── .gitignore
├── templates/
│ ├── 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
```
## Requirements
- Python 3.x
- See `requirements.txt`: Flask, python-dotenv, gunicorn
## Setup
1. Create and activate a virtual environment (optional but recommended):
```bash
python -m venv venv
# Windows
venv\Scripts\activate
# macOS/Linux
source venv/bin/activate
```
2. Install dependencies:
```bash
pip install -r requirements.txt
```
3. Configure the app via environment variables. Copy the example file and edit it:
```bash
cp .env.example .env
```
`TESLACAM_DIRECTORY` should point to the root folder containing your
`RecentClips`, `SavedClips`, and `SentryClips` subfolders. Variables set in
the real environment take precedence over `.env`.
## Configuration
All settings are read from environment variables, with sensible defaults. You
can set them in a `.env` file, export them in the shell, or pass them to the
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) |
| `SCAN_CACHE_TTL` | `3600` | Seconds to cache the directory scan. Hit |
| | | `/refresh` to force a re-scan. |
## Usage
### Local development server
```bash
python videoplayer.py
```
Then open [http://localhost:5000](http://localhost:5000) in your browser.
### Production (WSGI server on Linux)
For production, run the app behind a WSGI server such as **gunicorn**. The
`app` object in `videoplayer.py` is the WSGI entry point:
```bash
# Two workers are sufficient for this app
gunicorn -w 2 -b 0.0.0.0:5000 'videoplayer:app'
```
Make sure `TESLACAM_DIRECTORY` is set for the gunicorn process, e.g.:
```bash
export TESLACAM_DIRECTORY=/mnt/TeslaCam
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.
- 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 |
| `/video/<path:filepath>` | Streams a video file from the TeslaCam directory |
| `/refresh` | Forces a re-scan of the TeslaCam directory |
## Camera Views
| Key | Display Name |
| ---------------- | -------------- |
| `front` | Front |
| `left_repeater` | Left Repeater |
| `right_repeater` | Right Repeater |
| `back` | Back |
| `left_pillar` | Left Pillar |
| `right_pillar` | Right Pillar |
## Notes
- The directory scan is cached in memory for `SCAN_CACHE_TTL` seconds
(default 3600) to avoid re-walking the tree on every request. Hit
`/refresh` to force a re-scan after copying new clips onto the drive.
- Under gunicorn with multiple workers, each worker keeps its own cache; a
`/refresh` only refreshes the worker that handles the request.
- Debug mode is off by default (`FLASK_DEBUG=0`) and is only used by the local
dev server. It has no effect when running under a WSGI server like gunicorn.
- The TeslaCam directory must be accessible from the machine running the app
(e.g., a mounted USB drive or network share).
- Filename parsing assumes the standard TeslaCam naming format; files not
matching the regex are ignored.
+3 -1
View File
@@ -1 +1,3 @@
flask flask
python-dotenv
gunicorn
+541
View File
@@ -0,0 +1,541 @@
:root {
--black: #120d0a;
--deep-burgundy: #231611;
--burnt-maroon: #351f16;
--rust: #ff9b54;
--golden-amber: #ffbe78;
--neon-yellow: #ffbe78;
--text-main: #f5e7d0;
--text-muted: #d7c5aa;
--surface: rgba(35, 22, 17, 0.82);
--border: rgba(255, 212, 164, 0.18);
--shadow: rgba(0, 0, 0, 0.28);
--radius: 24px;
--radius-sm: 18px;
--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(--black);
color: var(--text-main);
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: 10px 24px;
backdrop-filter: blur(18px);
background: rgba(18, 13, 10, 0.7);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 50;
gap: 12px;
flex-wrap: wrap;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
font-weight: 700;
font-size: 18px;
letter-spacing: 0.5px;
}
.brand .logo {
width: 30px;
height: 30px;
display: grid;
place-items: center;
}
.brand .logo img {
width: 100%;
height: 100%;
object-fit: contain;
}
.brand small {
display: block;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
letter-spacing: 1.5px;
text-transform: uppercase;
}
/* Layout: row with split, collapse on small */
.layout {
display: flex;
flex-direction: row;
align-items: stretch;
gap: 20px;
padding: 20px 24px;
flex: 1;
max-width: 100%;
margin: 0 auto;
width: 100%;
min-height: 0;
}
/* Region filter bar */
.region-bar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.region-label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
margin-right: 2px;
}
.region-btn {
padding: 4px 12px;
border: 1px solid var(--border);
border-radius: 20px;
background: var(--surface);
color: var(--text-muted);
font-size: 12px;
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
font-family: var(--font);
}
.region-btn:hover {
border-color: var(--text-main);
}
.region-btn.active {
background: var(--rust);
color: #fff;
border-color: var(--rust);
}
/* Video Player Section */
.player-panel {
flex: 1 1 80%;
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
min-height: 0;
}
.video-stage {
position: relative;
background: #000;
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow);
border: 1px solid var(--border);
width: 100%;
flex: 1;
min-height: 0;
}
.video-stage video {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
background: #000;
}
.noise-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: none;
opacity: 1;
image-rendering: pixelated;
transition: opacity 0.3s ease;
}
.noise-canvas.hidden {
opacity: 0;
}
.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;
}
.video-badge {
position: absolute;
top: 12px;
left: 12px;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(6px);
padding: 6px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
display: none;
align-items: center;
gap: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.video-badge.visible {
display: flex;
}
.video-badge .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--rust);
box-shadow: 0 0 8px var(--rust);
}
.now-playing {
font-size: 13px;
color: var(--text-muted);
padding: 0 4px;
display: flex;
justify-content: flex-start;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.now-playing strong {
color: var(--text-main);
}
.now-playing .ts {
font-variant-numeric: tabular-nums;
}
.now-playing .loc {
color: var(--text-muted);
font-size: 12px;
margin-left: auto;
}
.now-playing .loc::before {
content: "📍 ";
font-size: 11px;
}
/* Nav buttons (prev/next) */
.nav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--border);
border-radius: 999px;
background: rgba(255, 255, 255, 0.03);
color: var(--text-muted);
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
flex-shrink: 0;
padding: 0;
}
.nav-btn svg {
width: 16px;
height: 16px;
}
.nav-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: var(--text-main);
border-color: var(--text-main);
}
.nav-btn:active {
background: var(--rust);
color: #fff;
}
/* =============================================
Camera Selector: 3-column grid + car graphic
============================================= */
.cam-selector {
flex: 1 1 20%;
min-width: 240px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 18px;
align-self: flex-start;
}
.cs-heading {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--text-muted);
margin-bottom: 10px;
text-align: center;
}
.cs-grid {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 2px;
align-items: center;
}
.cs-col {
display: flex;
flex-direction: column;
gap: 2px;
}
.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: 6px;
padding: 0.2rem 0.75rem;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-muted);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition:
transform 0.25s ease,
background 0.25s ease,
color 0.25s ease,
border-color 0.25s ease;
white-space: nowrap;
min-width: 64px;
justify-content: center;
}
.cs-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
color: var(--text-main);
border-color: var(--golden-amber);
transform: translateY(-2px);
}
.cs-btn.active {
background: linear-gradient(135deg, var(--rust), #ff6b35);
border-color: var(--rust);
color: #170d0a;
box-shadow: 0 10px 22px rgba(255, 155, 84, 0.16);
}
.cs-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
/* =============================================
Login Page
============================================= */
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: radial-gradient(ellipse at 50% 30%, #1a1a22 0%, var(--black) 70%);
}
.login-card {
width: 100%;
max-width: 380px;
padding: 40px 36px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.login-brand {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
margin-bottom: 32px;
}
.login-brand .logo {
width: 290px;
height: 158px;
display: grid;
place-items: center;
}
.login-brand .logo img {
width: 100%;
height: 100%;
object-fit: contain;
}
.login-brand h1 {
font-size: 20px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-main);
}
.login-form {
display: flex;
flex-direction: column;
gap: 14px;
}
.login-form label {
font-size: 13px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.8px;
}
.login-form input[type="password"] {
width: 100%;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--border);
border-radius: 999px;
color: var(--text-main);
font-size: 15px;
outline: none;
transition:
border-color 0.2s,
box-shadow 0.2s;
}
.login-form input[type="password"]:focus {
border-color: var(--text-main);
box-shadow: 0 0 0 3px rgba(227, 25, 55, 0.15);
}
.login-form button {
width: 100%;
padding: 12px;
background: var(--rust);
border: none;
border-radius: var(--radius-sm);
color: #fff;
font-size: 15px;
font-weight: 700;
cursor: pointer;
transition: background 0.15s;
margin-top: 4px;
}
.login-form button:hover {
background: #cc2a3e;
}
.login-error {
font-size: 13px;
text-align: center;
padding: 8px;
background: rgba(227, 25, 55, 0.08);
border-radius: var(--radius-sm);
}
/* =============================================
Responsive
============================================= */
@media (max-width: 700px) {
.layout {
flex-direction: column;
align-items: center;
}
.player-panel {
flex: none;
width: 100%;
}
.cam-selector {
flex: none;
width: 100%;
}
.cs-grid {
grid-template-columns: 1fr;
gap: 6px;
}
.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: 6px;
}
.cs-btn {
min-width: 100px;
font-size: 11px;
padding: 6px 10px;
}
}
@media (max-width: 560px) {
header {
padding: 10px 16px;
}
.layout {
padding: 12px 16px;
}
.brand small {
display: none;
}
}
[hidden] {
display: none !important;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+424
View File
@@ -0,0 +1,424 @@
(function () {
"use strict";
// ---- 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 nowPlaying = document.getElementById("nowPlaying");
const npName = document.getElementById("npName");
const npTime = document.getElementById("npTime");
const npLocation = document.getElementById("npLocation");
const prevBtn = document.getElementById("prevBtn");
const nextBtn = document.getElementById("nextBtn");
const firstBtn = document.getElementById("firstBtn");
const regionBar = document.getElementById("regionBar");
const csBtns = document.querySelectorAll(".cs-btn[data-cam]");
// ---- 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 ----
const noiseCtx = noiseCanvas.getContext("2d");
let noiseAnimId = null;
let noiseRunning = false;
function noiseStart() {
if (noiseRunning) return;
noiseRunning = true;
noiseCanvas.classList.remove("hidden");
noiseTick();
}
function noiseStop() {
noiseRunning = false;
noiseCanvas.classList.add("hidden");
if (noiseAnimId) {
cancelAnimationFrame(noiseAnimId);
noiseAnimId = null;
}
}
function noiseTick() {
if (!noiseRunning) return;
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;
w = targetW;
h = targetH;
}
if (w === 0 || h === 0) {
noiseAnimId = requestAnimationFrame(noiseTick);
return;
}
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;
data[i + 3] = 255;
}
noiseCtx.putImageData(imageData, 0, 0);
noiseAnimId = requestAnimationFrame(noiseTick);
}
// ---- Helpers ----
function tsToDate(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 prettyTs(ts) {
const d = tsToDate(ts);
if (!d) return ts || "Unknown";
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
// ---- 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 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);
});
// 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 {
setTimeout(doPrefetch, 100);
}
}
// ---- Prev/Next navigation ----
function findClipIndex(clip) {
const sameCam = (allVideos[clip.cam] && allVideos[clip.cam].videos) || [];
return sameCam.findIndex(function (c) {
return c.timestamp === clip.timestamp && c.filename === clip.filename;
});
}
function prevClip() {
if (!currentClip) return;
const sameCam =
(allVideos[currentClip.cam] && allVideos[currentClip.cam].videos) || [];
const idx = findClipIndex(currentClip);
if (idx > 0) playClip(sameCam[idx - 1]);
}
function nextClip() {
if (!currentClip) return;
const sameCam =
(allVideos[currentClip.cam] && allVideos[currentClip.cam].videos) || [];
const idx = findClipIndex(currentClip);
if (idx > -1 && sameCam[idx + 1]) playClip(sameCam[idx + 1]);
}
function firstClip() {
if (!currentClip) return;
const sameCam =
(allVideos[currentClip.cam] && allVideos[currentClip.cam].videos) || [];
if (sameCam.length > 0) playClip(sameCam[0]);
}
prevBtn.addEventListener("click", prevClip);
nextBtn.addEventListener("click", nextClip);
firstBtn.addEventListener("click", firstClip);
// ---- Playback ----
function playClip(clip) {
if (!clip) return;
currentClip = clip;
currentCam = clip.cam;
player.src = clip.url;
player.load();
noiseStart();
let prefetchDone = false;
function onPlaying() {
noiseStop();
if (prefetchDone) return;
prefetchDone = true;
player.removeEventListener("playing", onPlaying);
schedulePrefetch(clip);
}
player.addEventListener("playing", onPlaying);
player.play().catch(function () {
noiseStop();
if (!prefetchDone) {
prefetchDone = true;
player.removeEventListener("playing", onPlaying);
schedulePrefetch(clip);
}
});
placeholder.hidden = true;
badge.classList.add("visible");
badgeText.textContent = clip.camDisplay;
nowPlaying.hidden = false;
npName.textContent = clip.filename;
npTime.textContent = prettyTs(clip.timestamp);
npLocation.textContent = clip.location || "";
updateCamButtons(clip.cam);
}
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;
currentClip = target;
currentCam = camKey;
player.src = target.url;
player.load();
badgeText.textContent = target.camDisplay;
npName.textContent = target.filename;
npLocation.textContent = target.location || "";
player.addEventListener("loadedmetadata", function onMeta() {
player.removeEventListener("loadedmetadata", onMeta);
try {
if (savedTime && savedTime < player.duration) {
player.currentTime = savedTime;
}
} catch (e) {}
if (wasPlaying) player.play().catch(function () {});
});
updateCamButtons(camKey);
}
function updateCamButtons(activeKey) {
csBtns.forEach(function (btn) {
const key = btn.getAttribute("data-cam");
btn.classList.toggle("active", key === activeKey);
});
}
// ---- 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 = findClipIndex(currentClip);
if (idx > -1 && sameCam[idx + 1]) playClip(sameCam[idx + 1]);
});
// ---- 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;
const camKey = btn.getAttribute("data-cam");
if (camKey === currentCam) return; // already active
// 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]);
}
});
// ---- Keyboard shortcuts: 1-6 for camera angles, ArrowLeft/Right for prev/next ----
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]);
}
}
if (e.key === "ArrowLeft") {
e.preventDefault();
prevClip();
}
if (e.key === "ArrowRight") {
e.preventDefault();
nextClip();
}
});
// ---- Region filter ----
let regionData = {};
function fetchRegions() {
fetch("/api/regions")
.then(function (r) {
return r.json();
})
.then(function (data) {
regionData = data.regions || {};
buildRegionButtons();
})
.catch(function (err) {
console.error("Failed to load regions:", err);
});
}
function buildRegionButtons() {
// Remove existing region buttons, but preserve #firstBtn (no data-region)
var existing = regionBar.querySelectorAll(
".region-btn[data-region]:not([data-region=''])",
);
existing.forEach(function (b) {
b.remove();
});
Object.keys(regionData).forEach(function (name) {
var btn = document.createElement("button");
btn.className = "region-btn";
btn.setAttribute("data-region", name);
btn.textContent = name + " (" + regionData[name].count + ")";
regionBar.appendChild(btn);
});
}
regionBar.addEventListener("click", function (e) {
var btn = e.target.closest(".region-btn");
if (!btn) return;
var region = btn.getAttribute("data-region");
// Update active state
regionBar.querySelectorAll(".region-btn").forEach(function (b) {
b.classList.toggle("active", b === btn);
});
if (!region) return; // "All" selected — no jump
var info = regionData[region];
if (!info || !info.first_timestamp) return;
// Jump to first front-camera clip for this region
var frontVideos = (allVideos["front"] && allVideos["front"].videos) || [];
var clip = frontVideos.find(function (c) {
return c.timestamp === info.first_timestamp;
});
if (clip) playClip(clip);
});
// ---- Resize: keep video-stage within viewport ----
function constrainVideoHeight() {
const stage = document.querySelector(".video-stage");
const panel = document.querySelector(".player-panel");
if (!stage || !panel) return;
const header = document.querySelector("header");
const headerH = header ? header.getBoundingClientRect().height : 0;
const layout = document.querySelector(".layout");
if (!layout) return;
const layoutStyle = getComputedStyle(layout);
const padTop = parseFloat(layoutStyle.paddingTop) || 0;
const padBot = parseFloat(layoutStyle.paddingBottom) || 0;
const gap = parseFloat(layoutStyle.gap) || 0;
const availH = window.innerHeight - headerH - padTop - padBot - gap;
panel.style.maxHeight = Math.max(200, availH) + "px";
}
// ---- Init: fetch full video data, then auto-play front ----
function init() {
constrainVideoHeight();
window.addEventListener("resize", constrainVideoHeight);
// Fetch regions data for the filter bar
fetchRegions();
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();
})();
-790
View File
@@ -1,790 +0,0 @@
<!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/rear"
class="{% if camera_view_key == 'rear' %}active{% endif %}"
>Rear</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="newest">Newest first</option>
<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 = "newest";
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>
+102 -1006
View File
File diff suppressed because it is too large. Load diff
+42
View File
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TeslaCam Player — Login</title>
<link
rel="stylesheet"
href="{{ url_for('static', filename='css/style.css') }}"
/>
</head>
<body class="login-page">
<div class="login-card">
<div class="login-brand">
<span class="logo" aria-hidden="true">
<img
src="{{ url_for('static', filename='img/open_mic_odyssey.png') }}"
alt="Logo"
/>
</span>
<h1>Driving Footage</h1>
</div>
<form method="POST" action="{{ url_for('login') }}" class="login-form">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
placeholder="Enter password"
autocomplete="current-password"
autofocus
required
/>
{% if error %}
<p class="login-error">{{ error }}</p>
{% endif %}
<button type="submit">Sign In</button>
</form>
</div>
</body>
</html>
+350 -42
View File
@@ -11,12 +11,31 @@ play videos from different camera views.
import os import os
import re import re
from flask import Flask, render_template, send_from_directory, abort import time
import json
import secrets
from functools import wraps
from dotenv import load_dotenv
from flask import (
Flask, render_template, send_from_directory, abort, jsonify,
request, session, redirect, url_for
)
# Load variables from a .env file if present (does not override real env vars)
load_dotenv()
# Password for web login. Default: 'patreon'. Override via LOGIN_PASSWORD env var.
LOGIN_PASSWORD = os.environ.get('LOGIN_PASSWORD', 'patreon')
# Root TeslaCam directory containing RecentClips, SavedClips, SentryClips, etc.
# Override via the TESLACAM_DIRECTORY environment variable (see .env.example).
TESLACAM_DIRECTORY = os.environ.get('TESLACAM_DIRECTORY', '/mnt/TeslaCam')
app = Flask(__name__) app = Flask(__name__)
# Root TeslaCam directory containing RecentClips, SavedClips, SentryClips, etc. # Session secret key — MUST be set in production. Generate with: python -c "import secrets; print(secrets.token_hex(32))"
TESLACAM_DIRECTORY = '/mnt/TeslaCam' app.secret_key = os.environ.get('SECRET_KEY', secrets.token_hex(32))
# Regular expression matching TeslaCam filenames: YYYY-MM-DD_HH-MM-SS-${camera_view}.mp4 # Regular expression matching TeslaCam filenames: YYYY-MM-DD_HH-MM-SS-${camera_view}.mp4
VIDEO_FILENAME_REGEX = re.compile( VIDEO_FILENAME_REGEX = re.compile(
@@ -26,50 +45,156 @@ VIDEO_FILENAME_REGEX = re.compile(
# Friendly display names for camera views # Friendly display names for camera views
CAMERA_VIEW_MAPPING = { CAMERA_VIEW_MAPPING = {
'front': 'Front', 'front': 'Front',
'left_repeater': 'Left Repeater', 'left_repeater': 'L Rep.',
'right_repeater': 'Right Repeater', 'right_repeater': 'R Rep.',
'rear': 'Rear', 'back': 'Back',
'left_pillar': 'Left Pillar', 'left_pillar': 'L Pillar',
'right_pillar': 'Right Pillar' 'right_pillar': 'R Pillar'
} }
# Camera button layout order: three columns around the car graphic
CAMERA_LAYOUT = [
['left_pillar', 'left_repeater'],
['front', 'back'],
['right_pillar', 'right_repeater'],
]
def scan_teslacam_videos(): # 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.
SCAN_CACHE_TTL = int(os.environ.get('SCAN_CACHE_TTL', '3600'))
_scan_cache = {'data': None, 'expires_at': 0.0}
def _normalize_path(path):
"""Normalize a filesystem path to use forward slashes and no trailing slash."""
return os.path.normpath(path).replace('\\', '/').rstrip('/')
def _parse_event_json(event_path):
"""
Parse an event.json file from a SavedClips subfolder.
Returns a dict mapping normalized timestamps (YYYY-MM-DD_HH-MM-SS) to
geodata dicts with lat, lon, and location fields.
"""
result = {}
try:
with open(event_path, 'r') as f:
data = json.load(f)
except (json.JSONDecodeError, IOError):
return result
# Handle both a single event object and a list of events
events = data if isinstance(data, list) else [data]
for evt in events:
ts_raw = evt.get('timestamp', '')
# Normalize ISO timestamp
ts_normalized = _normalize_timestamp(ts_raw)
lat = evt.get('est_lat', '')
lon = evt.get('est_lon', '')
city = evt.get('city', '')
street = evt.get('street', '')
parts = [p for p in [street, city] if p]
location = ', '.join(parts) if parts else ''
result[ts_normalized] = {
'lat': lat,
'lon': lon,
'location': location
}
return result
def _normalize_timestamp(ts_string):
"""
Normalizes a timestamp string to the format YYYYMMDD_HHMM.
"""
try:
ts = ts_string.replace('T', '_')
ts = ts.replace('-', '')
ts = ts.replace(':', '')
ts = ts.split('.')[0]
# remove last 2 digits
ts = ts[:-2]
# set last 2 digits to '00' to represent the minute
ts = ts[:-2] + '00'
return ts
except Exception:
return None
def _get_video_timestamp_string(ts_string):
"""
normalizes a timestamp string to the format YYYYMMDD_HHMM, or returns None if invalid.
"""
try:
ts = ts_string.replace('T', '_')
ts = ts.replace('-', '')
ts = ts.replace(':', '')
ts = ts.split('.')[0]
# remove last 2 digits
ts = ts[:-2]
# set last 2 digits to '00' to represent the minute
ts = ts[:-2] + '00'
return ts
except Exception:
return None
def _scan_teslacam_videos_uncached():
""" """
Recursively scans the TeslaCam directory tree for video files and categorizes Recursively scans the TeslaCam directory tree for video files and categorizes
them by camera view. them by camera view. Also parses event.json files in SavedClips subfolders
to attach geodata (lat, lon, location) to matching video entries.
Two-pass approach: first collect ALL event.json geodata across the entire
tree, then process videos and attach matching geodata.
Returns: Returns:
dict: Keys are raw camera view identifiers (e.g. 'front'), values are lists dict: Keys are raw camera view identifiers (e.g. 'front'), values are lists
of dictionaries containing file details sorted chronologically. of dictionaries containing file details sorted chronologically.
""" """
videos_by_camera = {} videos_by_camera = {}
cam_dir = _normalize_path(TESLACAM_DIRECTORY)
if not os.path.exists(TESLACAM_DIRECTORY): if not os.path.exists(cam_dir):
return videos_by_camera return videos_by_camera
# Recursive directory walk to catch nested folders in SavedClips/SentryClips # ---- Pass 1: collect ALL event.json files first ----
for root, _, files in os.walk(TESLACAM_DIRECTORY): geodata_by_timestamp = {} # normalized_ts -> {lat, lon, location}
for root, _, files in os.walk(cam_dir):
if 'event.json' in files:
event_path = _normalize_path(os.path.join(root, 'event.json'))
geodata_by_timestamp.update(_parse_event_json(event_path))
# ---- Pass 2: process video files, attach geodata from fully populated dict ----
for root, _, files in os.walk(cam_dir):
for filename in files: for filename in files:
match = VIDEO_FILENAME_REGEX.match(filename) match = VIDEO_FILENAME_REGEX.match(filename)
if match: if not match:
timestamp, camera_view = match.groups() continue
timestamp, camera_view = match.groups()
normalized_ts = _get_video_timestamp_string(timestamp)
# Get path relative to TESLACAM_DIRECTORY for safe web serving # Get path relative to TESLACAM_DIRECTORY for safe web serving
full_path = os.path.join(root, filename) full_path = os.path.join(root, filename)
rel_path = os.path.relpath( rel_path = os.path.relpath(
full_path, TESLACAM_DIRECTORY).replace('\\', '/') full_path, cam_dir).replace('\\', '/')
video_entry = { video_entry = {
'filename': filename, 'filename': filename,
'timestamp': timestamp, 'timestamp': normalized_ts,
'rel_path': rel_path, 'rel_path': rel_path,
'url': f'/video/{rel_path}' 'url': f'/video/{rel_path}'
} }
if camera_view not in videos_by_camera: # Attach geodata if available for this timestamp
videos_by_camera[camera_view] = [] if normalized_ts in geodata_by_timestamp:
videos_by_camera[camera_view].append(video_entry) video_entry.update(geodata_by_timestamp[normalized_ts])
if camera_view not in videos_by_camera:
videos_by_camera[camera_view] = []
videos_by_camera[camera_view].append(video_entry)
# Sort each camera list chronologically by filename/timestamp # Sort each camera list chronologically by filename/timestamp
for camera_view in videos_by_camera: for camera_view in videos_by_camera:
@@ -78,31 +203,92 @@ def scan_teslacam_videos():
return videos_by_camera return videos_by_camera
def scan_teslacam_videos(force_refresh=False):
"""
Returns the cached TeslaCam video scan, refreshing it when the TTL expires
or when `force_refresh` is True. Safe to call on every request.
"""
now = time.time()
if (
_scan_cache['data'] is None
or now >= _scan_cache['expires_at']
or force_refresh
):
_scan_cache['data'] = _scan_teslacam_videos_uncached()
_scan_cache['expires_at'] = now + SCAN_CACHE_TTL
return _scan_cache['data']
def get_camera_display_name(camera_view): def get_camera_display_name(camera_view):
"""Returns human-readable title for a camera view key.""" """Returns human-readable title for a camera view key."""
return CAMERA_VIEW_MAPPING.get(camera_view, camera_view.replace('_', ' ').title()) return CAMERA_VIEW_MAPPING.get(camera_view, camera_view.replace('_', ' ').title())
def login_required(f):
"""Decorator: redirect to /login if not authenticated."""
@wraps(f)
def decorated_function(*args, **kwargs):
if not session.get('logged_in'):
return redirect(url_for('login'))
return f(*args, **kwargs)
return decorated_function
@app.route('/login', methods=['GET', 'POST'])
def login():
"""Simple password-based login page."""
error = None
if request.method == 'POST':
if request.form.get('password') == LOGIN_PASSWORD:
session['logged_in'] = True
return redirect(url_for('index'))
error = 'Invalid password. Please try again.'
return render_template('login.html', error=error)
@app.route('/logout')
def logout():
"""Clear session and return to login page."""
session.pop('logged_in', None)
return redirect(url_for('login'))
@app.route('/') @app.route('/')
@login_required
def index(): 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() all_videos = scan_teslacam_videos()
# Map raw camera keys to display names for rendering # Build camera info list for the button grid
categorized_videos = {} camera_list = []
for camera_view, video_list in all_videos.items(): for col in CAMERA_LAYOUT:
display_name = get_camera_display_name(camera_view) col_cams = []
categorized_videos[display_name] = { for key in col:
'raw_key': camera_view, videos = all_videos.get(key, [])
'videos': video_list 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/<camera_view>') @app.route('/camera/<camera_view>')
@login_required
def camera_view(camera_view): def camera_view(camera_view):
""" """
Displays all available videos for a single camera view. Displays all available videos for a single camera view.
@@ -120,17 +306,139 @@ def camera_view(camera_view):
@app.route('/video/<path:filepath>') @app.route('/video/<path:filepath>')
@login_required
def serve_video(filepath): def serve_video(filepath):
""" """
Safely streams video files from the TeslaCam directory structure. Safely streams video files from the TeslaCam directory structure.
""" """
full_path = os.path.join(TESLACAM_DIRECTORY, filepath) full_path = os.path.join(_normalize_path(TESLACAM_DIRECTORY), filepath)
if not os.path.exists(full_path): if not os.path.exists(full_path):
abort(404, description="Video file not found") abort(404, description="Video file not found")
return send_from_directory(TESLACAM_DIRECTORY, filepath) return send_from_directory(_normalize_path(TESLACAM_DIRECTORY), filepath)
@app.route('/refresh')
@login_required
def refresh():
"""
Forces a re-scan of the TeslaCam directory, bypassing the cache TTL.
Useful after copying new clips onto the drive.
"""
scan_teslacam_videos(force_refresh=True)
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)
# US region boundaries for geodata classification
# West: lon <= -104 (Pacific + Mountain)
# Midwest: -104 < lon <= -80 AND lat >= 37
# South: lat < 37 AND lon > -104
# East: lon > -80
US_REGION_BOUNDS = [
('4-Back West', lambda lat, lon: lon <= -104),
('3-Midwest', lambda lat, lon: -104 < lon <= -80 and lat >= 37),
('1-Texas', lambda lat, lon: lat < 37 and lon > -104),
('2-East Coast', lambda lat, lon: lon > -80),
]
def _classify_region(lat, lon):
"""Classify a lat/lon pair into a US region name, or None if unclassified."""
try:
lat_f = float(lat)
lon_f = float(lon)
except (ValueError, TypeError):
return None
for name, check in US_REGION_BOUNDS:
if check(lat_f, lon_f):
return name
return None
@app.route('/api/regions')
@login_required
def api_regions():
"""
Returns available US regions with clip counts and a representative
front-camera timestamp for jumping to region.
"""
all_videos = scan_teslacam_videos()
regions = {}
for cam_key, video_list in all_videos.items():
for v in video_list:
lat = v.get('lat', '')
lon = v.get('lon', '')
if not lat or not lon:
continue
region = _classify_region(lat, lon)
if not region:
continue
ts = v['timestamp']
if region not in regions:
regions[region] = {'count': 0, 'timestamps': set()}
regions[region]['count'] += 1
regions[region]['timestamps'].add(ts)
# Convert sets to sorted lists, find earliest front-cam timestamp per region
front_videos = all_videos.get('front', [])
front_ts_set = {v['timestamp'] for v in front_videos}
result = {}
for name, data in sorted(regions.items()):
ts_list = sorted(data['timestamps'])
# First timestamp that has a front camera clip
first_ts = next((ts for ts in ts_list if ts in front_ts_set),
ts_list[0] if ts_list else None)
result[name] = {
'count': data['count'],
'first_timestamp': first_ts,
}
return jsonify({'regions': result})
@app.route('/health')
def health():
"""
Health-check endpoint for load balancers / monitoring.
Returns 200 with a JSON status when the app is running.
"""
return jsonify({'status': 'ok'})
@app.route('/static/<path:filename>')
def serve_static(filename):
"""
Serves static assets (CSS, JS, images) from the static/ directory.
"""
static_dir = app.static_folder or os.path.join(app.root_path, 'static')
return send_from_directory(static_dir, filename)
if __name__ == '__main__': if __name__ == '__main__':
host = '0.0.0.0' # Local dev server. For production, run behind a WSGI server instead, e.g.:
app.run(debug=True, host=host, port=5000) # gunicorn -w 4 -b 0.0.0.0:5000 'videoplayer:app'
# or
# waitress-serve --host=0.0.0.0 --port=5000 videoplayer:app
host = os.environ.get('FLASK_RUN_HOST', '0.0.0.0')
port = int(os.environ.get('FLASK_RUN_PORT', '5000'))
debug = os.environ.get('FLASK_DEBUG', '0').lower() in ('1', 'true', 'yes')
app.run(debug=debug, host=host, port=port)