diff --git a/static/css/style.css b/static/css/style.css index f04561c..0170045 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -95,6 +95,50 @@ header { 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 */ .player-panel { width: 100%; @@ -182,7 +226,7 @@ header { color: var(--text-secondary); padding: 0 4px; display: flex; - justify-content: space-between; + justify-content: flex-start; align-items: center; gap: 10px; flex-wrap: wrap; @@ -194,6 +238,48 @@ header { color: var(--accent); 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 diff --git a/static/js/app.js b/static/js/app.js index 89c2866..1eceb74 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -10,6 +10,10 @@ 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 regionBar = document.getElementById("regionBar"); const csBtns = document.querySelectorAll(".cs-btn[data-cam]"); // ---- 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 ---- function playClip(clip) { if (!clip) return; @@ -160,6 +191,7 @@ nowPlaying.hidden = false; npName.textContent = clip.filename; npTime.textContent = prettyTs(clip.timestamp); + npLocation.textContent = clip.location || ""; updateCamButtons(clip.cam); } @@ -180,6 +212,7 @@ badgeText.textContent = target.camDisplay; npName.textContent = target.filename; + npLocation.textContent = target.location || ""; player.addEventListener("loadedmetadata", function onMeta() { player.removeEventListener("loadedmetadata", onMeta); @@ -206,12 +239,7 @@ if (!currentClip) return; const sameCam = (allVideos[currentClip.cam] && allVideos[currentClip.cam].videos) || []; - const idx = sameCam.findIndex(function (c) { - return ( - c.timestamp === currentClip.timestamp && - c.filename === currentClip.filename - ); - }); + const idx = findClipIndex(currentClip); 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 = [ "front", "left_repeater", @@ -258,10 +286,78 @@ 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 ---- function init() { + // Fetch regions data for the filter bar + fetchRegions(); + fetch("/api/videos") .then(function (r) { return r.json(); diff --git a/templates/index.html b/templates/index.html index feaa039..c325d53 100644 --- a/templates/index.html +++ b/templates/index.html @@ -29,6 +29,12 @@
+ + +
@@ -60,6 +66,21 @@
{% set phidden = 'hidden' if not default_url else '' %}
+ Now Playing: — + +
diff --git a/videoplayer.py b/videoplayer.py index 0ce7eea..5e23798 100644 --- a/videoplayer.py +++ b/videoplayer.py @@ -12,6 +12,7 @@ play videos from different camera views. import os import re import time +import json import secrets 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} +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 - 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: dict: Keys are raw camera view identifiers (e.g. 'front'), values are lists of dictionaries containing file details sorted chronologically. """ 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 - # Recursive directory walk to catch nested folders in SavedClips/SentryClips - for root, _, files in os.walk(TESLACAM_DIRECTORY): + # ---- Pass 1: collect ALL event.json files first ---- + 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: match = VIDEO_FILENAME_REGEX.match(filename) - if match: - timestamp, camera_view = match.groups() + if not match: + continue + timestamp, camera_view = match.groups() + normalized_ts = _get_video_timestamp_string(timestamp) - # Get path relative to TESLACAM_DIRECTORY for safe web serving - full_path = os.path.join(root, filename) - rel_path = os.path.relpath( - full_path, TESLACAM_DIRECTORY).replace('\\', '/') + # Get path relative to TESLACAM_DIRECTORY for safe web serving + full_path = os.path.join(root, filename) + rel_path = os.path.relpath( + full_path, cam_dir).replace('\\', '/') - video_entry = { - 'filename': filename, - 'timestamp': timestamp, - 'rel_path': rel_path, - 'url': f'/video/{rel_path}' - } + video_entry = { + 'filename': filename, + 'timestamp': normalized_ts, + 'rel_path': rel_path, + 'url': f'/video/{rel_path}' + } - if camera_view not in videos_by_camera: - videos_by_camera[camera_view] = [] - videos_by_camera[camera_view].append(video_entry) + # Attach geodata if available for this timestamp + if normalized_ts in geodata_by_timestamp: + 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 for camera_view in videos_by_camera: @@ -218,11 +311,11 @@ def serve_video(filepath): """ 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): 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') @@ -253,6 +346,75 @@ def api_videos(): 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') def health(): """