Add region filter bar and navigation buttons for video clips

This commit is contained in:
zwitschi committed 2026-07-27 10:35:20 +02:00
1 parent d12d5e5414
commit 1c7ce1dd0a
4 files changed
+410 -29

No files matched your search

+87 -1
View File
@@ -95,6 +95,50 @@ header {
width: 100%; width: 100%;
} }
/* Region filter bar */
.region-bar {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 0 4px;
}
.region-label {
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 600;
}
.region-btn {
padding: 5px 14px;
border: 1px solid var(--border);
border-radius: 20px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
font-family: var(--font);
}
.region-btn:hover {
border-color: var(--accent);
color: var(--text-primary);
}
.region-btn.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
/* Video Player Section */ /* Video Player Section */
.player-panel { .player-panel {
width: 100%; width: 100%;
@@ -182,7 +226,7 @@ header {
color: var(--text-secondary); color: var(--text-secondary);
padding: 0 4px; padding: 0 4px;
display: flex; display: flex;
justify-content: space-between; justify-content: flex-start;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
flex-wrap: wrap; flex-wrap: wrap;
@@ -194,6 +238,48 @@ header {
color: var(--accent); color: var(--accent);
font-variant-numeric: tabular-nums; 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: 6px;
background: var(--bg-tertiary);
color: var(--text-secondary);
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: var(--bg-elevated);
color: var(--text-primary);
border-color: var(--accent);
}
.nav-btn:active {
background: var(--accent);
color: #fff;
}
/* ============================================= /* =============================================
Camera Selector: 3-column grid + car graphic Camera Selector: 3-column grid + car graphic
+103 -7
View File
@@ -10,6 +10,10 @@
const nowPlaying = document.getElementById("nowPlaying"); const nowPlaying = document.getElementById("nowPlaying");
const npName = document.getElementById("npName"); const npName = document.getElementById("npName");
const npTime = document.getElementById("npTime"); const npTime = document.getElementById("npTime");
const npLocation = document.getElementById("npLocation");
const prevBtn = document.getElementById("prevBtn");
const nextBtn = document.getElementById("nextBtn");
const regionBar = document.getElementById("regionBar");
const csBtns = document.querySelectorAll(".cs-btn[data-cam]"); const csBtns = document.querySelectorAll(".cs-btn[data-cam]");
// ---- State ---- // ---- State ----
@@ -126,6 +130,33 @@
} }
} }
// ---- 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]);
}
prevBtn.addEventListener("click", prevClip);
nextBtn.addEventListener("click", nextClip);
// ---- Playback ---- // ---- Playback ----
function playClip(clip) { function playClip(clip) {
if (!clip) return; if (!clip) return;
@@ -160,6 +191,7 @@
nowPlaying.hidden = false; nowPlaying.hidden = false;
npName.textContent = clip.filename; npName.textContent = clip.filename;
npTime.textContent = prettyTs(clip.timestamp); npTime.textContent = prettyTs(clip.timestamp);
npLocation.textContent = clip.location || "";
updateCamButtons(clip.cam); updateCamButtons(clip.cam);
} }
@@ -180,6 +212,7 @@
badgeText.textContent = target.camDisplay; badgeText.textContent = target.camDisplay;
npName.textContent = target.filename; npName.textContent = target.filename;
npLocation.textContent = target.location || "";
player.addEventListener("loadedmetadata", function onMeta() { player.addEventListener("loadedmetadata", function onMeta() {
player.removeEventListener("loadedmetadata", onMeta); player.removeEventListener("loadedmetadata", onMeta);
@@ -206,12 +239,7 @@
if (!currentClip) return; if (!currentClip) return;
const sameCam = const sameCam =
(allVideos[currentClip.cam] && allVideos[currentClip.cam].videos) || []; (allVideos[currentClip.cam] && allVideos[currentClip.cam].videos) || [];
const idx = sameCam.findIndex(function (c) { const idx = findClipIndex(currentClip);
return (
c.timestamp === currentClip.timestamp &&
c.filename === currentClip.filename
);
});
if (idx > -1 && sameCam[idx + 1]) playClip(sameCam[idx + 1]); if (idx > -1 && sameCam[idx + 1]) playClip(sameCam[idx + 1]);
}); });
@@ -234,7 +262,7 @@
} }
}); });
// ---- Keyboard shortcuts: 1-6 for camera angles ---- // ---- Keyboard shortcuts: 1-6 for camera angles, ArrowLeft/Right for prev/next ----
const camOrder = [ const camOrder = [
"front", "front",
"left_repeater", "left_repeater",
@@ -258,10 +286,78 @@
if (videos.length > 0) playClip(videos[0]); 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() {
// Keep "All" button, remove any existing region buttons
var existing = regionBar.querySelectorAll(
".region-btn: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);
}); });
// ---- Init: fetch full video data, then auto-play front ---- // ---- Init: fetch full video data, then auto-play front ----
function init() { function init() {
// Fetch regions data for the filter bar
fetchRegions();
fetch("/api/videos") fetch("/api/videos")
.then(function (r) { .then(function (r) {
return r.json(); return r.json();
+37
View File
@@ -29,6 +29,12 @@
<!-- Main Layout: single column, centered --> <!-- Main Layout: single column, centered -->
<div class="layout"> <div class="layout">
<!-- Region filter bar -->
<nav class="region-bar" id="regionBar">
<span class="region-label">Region:</span>
<button class="region-btn active" data-region="">All</button>
</nav>
<!-- Video Player --> <!-- Video Player -->
<section class="player-panel"> <section class="player-panel">
<div class="video-stage"> <div class="video-stage">
@@ -60,6 +66,21 @@
</div> </div>
{% set phidden = 'hidden' if not default_url else '' %} {% set phidden = 'hidden' if not default_url else '' %}
<div class="now-playing" id="nowPlaying" {{phidden}}> <div class="now-playing" id="nowPlaying" {{phidden}}>
<button
class="nav-btn"
id="prevBtn"
title="Previous clip (←)"
aria-label="Previous clip"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<span <span
>Now Playing: >Now Playing:
<strong id="npName" <strong id="npName"
@@ -68,6 +89,22 @@
></span ></span
> >
<span class="ts" id="npTime">—</span> <span class="ts" id="npTime">—</span>
<span class="loc" id="npLocation"></span>
<button
class="nav-btn"
id="nextBtn"
title="Next clip (→)"
aria-label="Next clip"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div> </div>
</section> </section>
+183 -21
View File
@@ -12,6 +12,7 @@ play videos from different camera views.
import os import os
import re import re
import time import time
import json
import secrets import secrets
from functools import wraps from functools import wraps
@@ -66,42 +67,134 @@ SCAN_CACHE_TTL = int(os.environ.get('SCAN_CACHE_TTL', '3600'))
_scan_cache = {'data': None, 'expires_at': 0.0} _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(): 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:
@@ -218,11 +311,11 @@ 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') @app.route('/refresh')
@@ -253,6 +346,75 @@ def api_videos():
return jsonify(result) 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 = [
('West', lambda lat, lon: lon <= -104),
('Midwest', lambda lat, lon: -104 < lon <= -80 and lat >= 37),
('South', lambda lat, lon: lat < 37 and lon > -104),
('East', 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') @app.route('/health')
def health(): def health():
""" """