diff --git a/README.md b/README.md index 12df3ec..bb15f84 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,12 @@ A Flask web app for browsing and playing TeslaCam (Tesla Dashcam/Sentry) recordi ## Project Structure ```txt -tesla-cam-view-1/ +tesla-cam-view/ ├── videoplayer.py # Flask app: scanning, routing, video streaming -├── requirements.txt # Python dependencies (Flask) +├── 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 │ └── camera_view.html # Single-camera view page @@ -26,7 +29,7 @@ tesla-cam-view-1/ ## Requirements - Python 3.x -- Flask, python-dotenv, gunicorn (see `requirements.txt`) +- See `requirements.txt`: Flask, python-dotenv, gunicorn ## Setup @@ -46,24 +49,31 @@ tesla-cam-view-1/ pip install -r requirements.txt ``` -3. Configure the TeslaCam directory via environment variables. Copy the example - file and edit it: +3. Configure the app via environment variables. Copy the example file and edit it: ```bash cp .env.example .env ``` - ```ini - TESLACAM_DIRECTORY=/mnt/TeslaCam - FLASK_RUN_HOST=0.0.0.0 - FLASK_RUN_PORT=5000 - FLASK_DEBUG=0 - ``` - `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 | +| `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 @@ -80,15 +90,15 @@ For production, run the app behind a WSGI server such as **gunicorn**. The `app` object in `videoplayer.py` is the WSGI entry point: ```bash -gunicorn -w 4 -b 0.0.0.0:5000 'videoplayer:app' +# Two workers are sufficient for this app +gunicorn -w 2 -b 0.0.0.0:5000 'videoplayer:app' ``` -`TESLACAM_DIRECTORY` is read from the environment (or `.env`), so make sure it -is set for the gunicorn process, e.g.: +Make sure `TESLACAM_DIRECTORY` is set for the gunicorn process, e.g.: ```bash export TESLACAM_DIRECTORY=/mnt/TeslaCam -gunicorn -w 4 -b 0.0.0.0:5000 'videoplayer:app' +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 @@ -121,9 +131,13 @@ file / environment rather than relying on `.env`. ## Notes - The directory scan is cached in memory for `SCAN_CACHE_TTL` seconds - (default 300) to avoid re-walking the tree on every request. Hit + (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. +- 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. diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..fa7722e --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,505 @@ +: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; +} + +.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; + color: var(--accent); +} +.brand .logo svg { + width: 100%; + height: 100%; + fill: var(--accent); +} +.brand small { + display: block; + font-size: 11px; + font-weight: 500; + color: var(--text-muted); + letter-spacing: 1.5px; + 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 { + display: grid; + grid-template-columns: minmax(0, 1.9fr) minmax(320px, 1fr); + gap: 20px; + padding: 20px 24px; + flex: 1; + align-items: start; +} + +/* Video Player Section */ +.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; +} +.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(--accent); + 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); + padding: 0 4px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.now-playing strong { + color: var(--text-primary); +} +.now-playing .ts { + color: var(--accent); + 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; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 30px; + color: var(--text-secondary); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s; + display: flex; + align-items: center; + gap: 7px; +} +.cam-tabs .tab:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} +.cam-tabs .tab.active { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} +.cam-tabs .tab .count { + background: rgba(255, 255, 255, 0.12); + padding: 1px 7px; + border-radius: 20px; + font-size: 11px; + font-weight: 700; +} +.cam-tabs .tab.active .count { + background: rgba(0, 0, 0, 0.25); +} + +.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 - 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; +} \ No newline at end of file diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..aee332d --- /dev/null +++ b/static/js/app.js @@ -0,0 +1,394 @@ +(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; + }); + + const player = document.getElementById("player"); + 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; + + let activeCam = "all"; + let query = ""; + let sortMode = "oldest"; + let current = null; + + 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", + }); + } + + function esc(s) { + return String(s).replace(/[&<>"']/g, function (c) { + return { + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + }[c]; + }); + } + + 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 = + '