diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css index 8c68365e3..e5656fd84 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.css @@ -159,34 +159,6 @@ color: white; } -.show-preserved-button { - background-color: var(--input-bg); - border: none; - border-radius: var(--border-radius-lg); - color: var(--text-color); - font-size: var(--font-size-base); - font-weight: var(--font-weight-demi-bold); - margin-top: var(--margin-sm); - padding: var(--padding-sm) var(--padding-base); - text-align: center; - transition: - background-color var(--transition-fast), - box-shadow var(--transition-fast), - transform var(--transition-fast); - width: auto; -} - -.show-preserved-button:hover { - background-color: var(--success-hover-bg); - box-shadow: var(--shadow-md); - transform: var(--hover-scale-sm); -} - -.show-preserved-button[disabled] { - cursor: not-allowed; - opacity: var(--disabled-opacity); -} - .delete-all-button { background-color: var(--danger-bg); border: none; @@ -244,6 +216,166 @@ transform: var(--hover-scale-sm); } +.dialog-box.route-logs-dialog { + border: var(--border-width-thin) solid var(--sidebar-border-color); + box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.55); + display: flex; + flex-direction: column; + max-height: min(42rem, calc(100vh - 2rem)); + max-width: none; + min-width: 0; + overflow: hidden; + padding: 0; + text-align: left; + width: min(36rem, calc(100vw - 2rem)); +} + +.route-logs-toolbar { + align-items: center; + background: var(--input-bg); + border-bottom: var(--border-width-thin) solid var(--sidebar-border-color); + display: flex; + flex-shrink: 0; + justify-content: space-between; + padding: var(--padding-base) var(--padding-lg); +} + +.route-logs-toolbar h2, +.route-logs-eyebrow { + margin: 0; +} + +.route-logs-toolbar h2 { + color: var(--text-color); + font-size: var(--font-size-xl); + line-height: 1.2; +} + +.route-logs-eyebrow { + color: var(--text-muted); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + letter-spacing: 0.08em; + margin-bottom: var(--margin-xs); + text-transform: uppercase; +} + +.route-logs-close { + align-items: center; + background: transparent; + border: 0; + border-radius: 50%; + color: var(--text-muted); + cursor: pointer; + display: flex; + font-size: 2rem; + height: 2.5rem; + justify-content: center; + line-height: 1; + transition: background-color var(--transition-fast), color var(--transition-fast); + width: 2.5rem; +} + +.route-logs-close:hover, +.route-logs-close:focus-visible { + background: var(--sidebar-active-bg); + color: var(--text-color); + outline: none; +} + +.route-logs-content { + overflow-y: auto; + padding: var(--padding-lg); +} + +.route-logs-message { + color: var(--text-color); + font-size: var(--font-size-base); + line-height: var(--line-height-base); + margin: 0; + padding: var(--padding-lg) var(--padding-sm); + text-align: center; +} + +.route-logs-error { + color: var(--danger-fg); +} + +.route-logs-summary { + align-items: center; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + display: flex; + justify-content: space-between; + gap: var(--gap-md); + padding: var(--padding-base); +} + +.route-logs-summary > div, +.route-log-details { + display: flex; + flex-direction: column; + gap: var(--gap-xs); + min-width: 0; +} + +.route-logs-summary strong, +.route-log-details strong { + color: var(--text-color); + font-size: var(--font-size-base); +} + +.route-logs-summary div span, +.route-log-details span { + color: var(--text-muted); + font-size: var(--font-size-base); + line-height: 1.4; +} + +.route-logs-download-all, +.route-log-download { + background: var(--main-fg); + border-radius: var(--border-radius-md); + color: var(--text-on-primary); + flex-shrink: 0; + font-size: var(--font-size-base); + font-weight: var(--font-weight-demi-bold); + padding: var(--padding-sm) var(--padding-base); + text-decoration: none; + transition: filter var(--transition-fast), transform var(--transition-fast); +} + +.route-logs-download-all span { + opacity: 0.75; +} + +.route-logs-download-all:hover, +.route-log-download:hover, +.route-logs-download-all:focus-visible, +.route-log-download:focus-visible { + filter: brightness(1.15); + outline: none; + transform: translateY(-1px); +} + +.route-logs-list { + display: grid; + gap: var(--gap-sm); + list-style: none; + margin: var(--margin-base) 0 0; + padding: 0; +} + +.route-logs-list li { + align-items: center; + border-bottom: var(--border-width-thin) solid var(--sidebar-border-color); + display: flex; + justify-content: space-between; + gap: var(--gap-md); + padding: var(--padding-sm) var(--padding-xs) var(--padding-base); +} + @media only screen and (max-width: 768px) and (orientation: portrait) { .media-player-content { width: 90%; @@ -266,4 +398,1128 @@ text-align: center; width: 100%; } + + .route-logs-toolbar, + .route-logs-content { + padding: var(--padding-base); + } + + .route-logs-summary, + .route-logs-list li { + flex-direction: column; + align-items: stretch; + } + + .route-logs-download-all, + .route-log-download { + text-align: center; + } +} + +/* Date-organized route library */ +.screen-recordings-wrapper.dashcam-routes-wrapper { + padding: 0 var(--padding-base) var(--padding-xl); + width: 100%; +} + +.screen-recordings-widget.dashcam-library { + align-items: stretch; + max-width: 92rem; + padding: clamp(1rem, 2vw, 2rem); +} + +.screen-recordings-widget.dashcam-library:hover { + transform: none; +} + +.dashcam-library-header, +.dashcam-toolbar, +.dashcam-results-summary, +.dashcam-danger-zone, +.dashcam-player-header, +.dashcam-player-actions, +.dashcam-camera-selector { + align-items: center; + display: flex; +} + +.dashcam-library-header { + justify-content: space-between; + align-items: flex-start; + gap: var(--gap-md); + flex-wrap: wrap; +} + +.dashcam-header-info { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.dashcam-library-header h1, +.dashcam-library-eyebrow, +.dashcam-player-header h2, +.dashcam-player-eyebrow { + margin: 0; +} + +.dashcam-library-header h1 { + color: var(--text-color); + font-size: clamp(1.5rem, 3vw, 2.25rem); + line-height: 1.15; +} + +.dashcam-library-eyebrow, +.dashcam-player-eyebrow { + color: var(--text-muted); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-bold); + letter-spacing: 0.08em; + margin-bottom: var(--margin-xs); + text-transform: uppercase; +} + +/* Stats Chips in Header */ +.dashcam-stats-bar { + display: flex; + flex-wrap: wrap; + gap: var(--gap-sm); + margin-top: 0.4rem; +} + +.dashcam-stat-chip { + align-items: center; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-xl); + color: var(--text-muted); + display: inline-flex; + font-size: var(--font-size-xs); + gap: 0.35rem; + padding: 0.3rem 0.75rem; +} + +.dashcam-stat-chip strong { + color: var(--text-color); + font-weight: var(--font-weight-demi-bold); +} + +.dashcam-stat-chip i { + color: var(--main-fg); + font-size: 0.85rem; +} + +.dashcam-stat-chip.stat-chip-preserved i { + color: #ef6078; +} + +.dashcam-header-controls { + display: flex; + align-items: center; + gap: var(--gap-sm); +} + +.dashcam-refresh-button { + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + color: var(--text-color); + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-demi-bold); + padding: 0.6rem 0.95rem; + transition: background-color var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast); +} + +.dashcam-refresh-button:hover { + background: var(--sidebar-active-bg); + border-color: var(--main-fg); + transform: translateY(-1px); +} + +.dashcam-refresh-button:disabled { + cursor: not-allowed; + opacity: var(--disabled-opacity); +} + +/* Modern Toolbar */ +.dashcam-toolbar { + flex-wrap: wrap; + gap: var(--gap-sm); + margin-top: var(--margin-lg); +} + +.dashcam-search-box { + align-items: center; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + display: flex; + flex: 1 1 18rem; + gap: var(--gap-sm); + padding: 0 0.8rem; + position: relative; + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); +} + +.dashcam-search-box:focus-within { + border-color: var(--main-fg); + box-shadow: 0 0 0 2px rgba(139, 108, 197, 0.2); +} + +.dashcam-search-icon { + color: var(--text-muted); + font-size: 0.95rem; +} + +.dashcam-search-box input { + background: transparent; + border: 0; + color: var(--text-color); + font: inherit; + font-size: var(--font-size-sm); + min-width: 0; + outline: 0; + padding: 0.65rem 0; + width: 100%; +} + +.dashcam-search-clear { + background: transparent; + border: 0; + color: var(--text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0.2rem; + border-radius: 50%; + font-size: 1.1rem; + line-height: 1; + transition: color var(--transition-fast), background-color var(--transition-fast); +} + +.dashcam-search-clear:hover { + color: var(--text-color); + background: var(--sidebar-active-bg); +} + +.dashcam-filter-group { + display: inline-flex; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + padding: 2px; + gap: 2px; +} + +.dashcam-filter-pill { + background: transparent; + border: 0; + border-radius: calc(var(--border-radius-md) - 2px); + color: var(--text-muted); + cursor: pointer; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-demi-bold); + padding: 0.5rem 0.85rem; + transition: background-color var(--transition-fast), color var(--transition-fast); +} + +.dashcam-filter-pill.active { + background: var(--sidebar-active-bg); + color: var(--text-color); + box-shadow: inset 0 0 0 1px var(--main-fg); +} + +.dashcam-filter-pill .bi-heart-fill { + color: #ef6078; + margin-right: 0.25rem; +} + +.dashcam-sort { + align-items: center; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + display: flex; + gap: var(--gap-sm); + padding: 0.5rem 0.85rem; +} + +.dashcam-sort span { + color: var(--text-muted); + font-size: var(--font-size-xs); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.dashcam-sort select { + background: transparent; + border: 0; + color: var(--text-color); + cursor: pointer; + font: inherit; + font-size: var(--font-size-sm); + outline: 0; +} + +.dashcam-view-toggle { + display: inline-flex; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + padding: 2px; + gap: 2px; +} + +.dashcam-view-btn { + background: transparent; + border: 0; + border-radius: calc(var(--border-radius-md) - 2px); + color: var(--text-muted); + cursor: pointer; + font-size: 1.1rem; + padding: 0.4rem 0.65rem; + display: flex; + align-items: center; + justify-content: center; + transition: background-color var(--transition-fast), color var(--transition-fast); +} + +.dashcam-view-btn.active { + background: var(--sidebar-active-bg); + color: var(--main-fg); + box-shadow: inset 0 0 0 1px var(--main-fg); +} + +.dashcam-results-summary { + color: var(--text-muted); + font-size: var(--font-size-xs); + justify-content: space-between; + margin-top: var(--margin-base); + min-height: 1.5rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.dashcam-date-groups, +.dashcam-date-group { + width: 100%; +} + +.dashcam-date-group { + margin-top: var(--margin-lg); +} + +.dashcam-date-group-header { + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: var(--border-width-thin) solid var(--sidebar-border-color); + padding-bottom: var(--padding-xs); + margin-bottom: var(--margin-sm); +} + +.dashcam-date-group-header h2 { + color: var(--text-color); + font-size: var(--font-size-base); + font-weight: var(--font-weight-bold); + margin: 0; +} + +.dashcam-date-group-count { + color: var(--text-muted); + font-size: var(--font-size-xs); +} + +/* ============================================================ + Modernized List View (Data & Telemetry Focused) + ============================================================ */ +.dashcam-routes-list { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + +.dashcam-route-row { + align-items: center; + background: var(--card-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + box-shadow: var(--shadow-xs); + cursor: pointer; + display: flex; + gap: var(--gap-md); + padding: 0.75rem 1rem; + position: relative; + transition: border-color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-fast); +} + +.dashcam-route-row:hover { + background: rgba(18, 18, 36, 0.95); + border-color: rgba(139, 108, 197, 0.4); + box-shadow: var(--shadow-sm); + transform: translateY(-1px); +} + +.dashcam-route-row.is-preserved { + border-left: 3px solid #ef6078; +} + +/* Compact Mini Preview (Non-dominant) */ +.dashcam-mini-preview { + aspect-ratio: 16 / 9; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-sm); + flex-shrink: 0; + height: 52px; + overflow: hidden; + position: relative; + width: 92px; +} + +.dashcam-mini-fallback { + align-items: center; + color: var(--text-muted); + display: flex; + inset: 0; + justify-content: center; + position: absolute; + font-size: 1.25rem; +} + +.dashcam-mini-preview:not(.thumbnail-failed) .dashcam-mini-fallback { + visibility: hidden; +} + +.dashcam-mini-img { + background: var(--sidebar-bg); + height: 100%; + object-fit: cover; + position: absolute; + width: 100%; + z-index: 1; +} + +.dashcam-mini-play-overlay { + align-items: center; + background: rgba(10, 10, 22, 0.6); + color: white; + display: flex; + font-size: 1.2rem; + inset: 0; + justify-content: center; + opacity: 0; + position: absolute; + transition: opacity var(--transition-fast); + z-index: 2; +} + +.dashcam-route-row:hover .dashcam-mini-play-overlay { + opacity: 1; +} + +/* Route Info & Telemetry */ +.dashcam-route-info { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.25rem; + min-width: 0; +} + +.dashcam-route-title-row { + align-items: center; + display: flex; + gap: 0.5rem; + min-width: 0; +} + +.dashcam-route-title { + color: var(--text-color); + font-size: var(--font-size-base); + font-weight: var(--font-weight-demi-bold); + line-height: 1.3; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashcam-custom-badge { + align-items: center; + background: rgba(139, 108, 197, 0.15); + border: 1px solid rgba(139, 108, 197, 0.35); + border-radius: var(--border-radius-sm); + color: var(--main-fg); + display: inline-flex; + font-size: 0.7rem; + font-weight: var(--font-weight-bold); + gap: 0.25rem; + padding: 0.1rem 0.4rem; + text-transform: uppercase; + flex-shrink: 0; +} + +.dashcam-route-subdate { + color: var(--text-muted); + font-size: var(--font-size-xs); + margin: 0; + display: flex; + align-items: center; + gap: 0.35rem; +} + +.dashcam-route-meta-pills { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.15rem; +} + +.meta-pill { + align-items: center; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-sm); + color: var(--text-muted); + display: inline-flex; + font-size: var(--font-size-xs); + gap: 0.3rem; + padding: 0.15rem 0.5rem; + white-space: nowrap; +} + +.meta-pill.duration-pill { + color: var(--text-color); +} + +.meta-pill.duration-pill i { + color: var(--main-fg); +} + +.meta-pill.segments-pill i { + color: var(--text-muted); +} + +.meta-pill.preserved-pill { + background: rgba(239, 96, 120, 0.12); + border-color: rgba(239, 96, 120, 0.35); + color: #ef6078; +} + +.meta-pill.id-pill { + font-family: var(--font-mono); + font-size: 0.7rem; + letter-spacing: 0.02em; + opacity: 0.85; +} + +/* Route Action Buttons */ +.dashcam-route-actions { + align-items: center; + display: flex; + flex-shrink: 0; + gap: 0.4rem; +} + +.btn-route-action { + align-items: center; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + color: var(--text-color); + cursor: pointer; + display: inline-flex; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-demi-bold); + gap: 0.35rem; + justify-content: center; + padding: 0.45rem 0.75rem; + transition: background-color var(--transition-fast), border-color var(--transition-fast), transform var(--transition-fast); +} + +.btn-route-action:hover { + background: var(--sidebar-active-bg); + border-color: var(--main-fg); + transform: translateY(-1px); +} + +.btn-route-action.btn-play { + background: var(--main-fg); + border-color: var(--main-fg); + color: var(--text-on-primary); +} + +.btn-route-action.btn-play:hover { + filter: brightness(1.12); +} + +.btn-route-action.btn-preserve { + padding: 0.45rem 0.6rem; + color: var(--text-muted); +} + +.btn-route-action.btn-preserve.active { + background: rgba(239, 96, 120, 0.15); + border-color: rgba(239, 96, 120, 0.45); + color: #ef6078; +} + +.btn-route-action.btn-preserve:hover { + color: #ef6078; +} + +.btn-route-action.btn-icon { + color: var(--text-muted); + padding: 0.45rem 0.6rem; +} + +.btn-route-action.btn-icon:hover { + color: var(--text-color); +} + +.btn-route-action.btn-danger-action:hover { + background: rgba(224, 85, 119, 0.15); + border-color: var(--danger-bg); + color: var(--danger-bg); +} + +/* ============================================================ + Compact Grid View Mode + ============================================================ */ +.dashcam-library .screen-recordings-grid.dashcam-routes-grid { + gap: clamp(0.8rem, 1.5vw, 1.1rem); + grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); + margin-top: var(--margin-sm); +} + +.dashcam-library .recording-card.dashcam-route-card { + background: var(--card-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + box-shadow: var(--shadow-xs); + display: flex; + flex-direction: column; + overflow: hidden; + position: relative; + text-align: left; + transition: border-color var(--transition-fast), transform var(--transition-fast), box-shadow var(--transition-fast); +} + +.dashcam-library .recording-card.dashcam-route-card:hover { + border-color: rgba(139, 108, 197, 0.45); + box-shadow: var(--shadow-sm); + transform: translateY(-2px); +} + +.dashcam-card-top-bar { + align-items: center; + background: var(--secondary-bg); + border-bottom: var(--border-width-thin) solid var(--sidebar-border-color); + display: flex; + justify-content: space-between; + padding: 0.35rem 0.6rem; +} + +.dashcam-card-top-bar .btn-preserve { + background: transparent; + border: 0; + padding: 0.15rem 0.3rem; +} + +.dashcam-route-card .dashcam-preview { + aspect-ratio: 16 / 9; + background: linear-gradient(135deg, var(--sidebar-bg), var(--input-bg)); + max-height: 120px; + overflow: hidden; + position: relative; + width: 100%; +} + +.dashcam-preview-fallback { + align-items: center; + color: var(--text-muted); + display: flex; + flex-direction: column; + gap: var(--gap-xs); + inset: 0; + justify-content: center; + position: absolute; +} + +.dashcam-preview-fallback i { + font-size: 1.6rem; +} + +.dashcam-preview:not(.thumbnail-failed) .dashcam-preview-fallback { + visibility: hidden; +} + +.dashcam-preview img { + background: var(--sidebar-bg); + height: 100%; + object-fit: cover; + width: 100%; + z-index: 1; +} + +.dashcam-grid-play-overlay { + align-items: center; + background: rgba(10, 10, 22, 0.55); + color: white; + display: flex; + font-size: 1.8rem; + inset: 0; + justify-content: center; + opacity: 0; + position: absolute; + transition: opacity var(--transition-fast); + z-index: 2; +} + +.dashcam-route-card:hover .dashcam-grid-play-overlay { + opacity: 1; +} + +.dashcam-card-body { + display: flex; + flex: 1; + flex-direction: column; + gap: 0.4rem; + padding: 0.75rem; +} + +.dashcam-card-body h3 { + color: var(--text-color); + font-size: var(--font-size-base); + font-weight: var(--font-weight-demi-bold); + line-height: 1.3; + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dashcam-card-date { + color: var(--text-muted); + font-size: var(--font-size-xs); + margin: 0; +} + +.dashcam-card-actions { + align-items: center; + border-top: var(--border-width-thin) solid var(--sidebar-border-color); + display: flex; + gap: 0.3rem; + margin-top: auto; + padding-top: 0.5rem; +} + +.dashcam-card-actions .btn-play { + flex: 1; +} + +/* Empty & Loading States */ +.dashcam-loading, +.dashcam-empty-state { + align-items: center; + color: var(--text-muted); + display: flex; + flex-direction: column; + justify-content: center; + min-height: 15rem; + width: 100%; +} + +.dashcam-loading span { + animation: dashcam-spin 0.8s linear infinite; + border: 3px solid var(--sidebar-border-color); + border-radius: 50%; + border-top-color: var(--main-fg); + height: 2.5rem; + width: 2.5rem; +} + +.dashcam-empty-state i { + font-size: 3rem; +} + +.dashcam-error { + color: var(--danger-fg); +} + +@keyframes dashcam-spin { + to { transform: rotate(360deg); } +} + +.dashcam-danger-zone { + border-top: var(--border-width-thin) solid var(--sidebar-border-color); + gap: var(--gap-md); + justify-content: space-between; + margin-top: 2.5rem; + padding-top: var(--padding-lg); +} + +.dashcam-danger-zone > div { + display: flex; +} + +.dashcam-danger-copy { + flex-direction: column; + gap: var(--gap-xs); +} + +.dashcam-danger-actions { + align-items: center; + flex-wrap: wrap; + gap: var(--gap-sm); + justify-content: flex-end; +} + +.dashcam-danger-actions .delete-all-button { + margin: 0; +} + +.delete-all-button.delete-non-preserved-button { + background: transparent; + border: var(--border-width-thin) solid var(--danger-bg); + color: var(--danger-bg); +} + +.delete-all-button.delete-non-preserved-button:hover { + background: rgba(224, 85, 119, 0.15); +} + +.dashcam-danger-zone span { + color: var(--text-muted); + font-size: var(--font-size-sm); +} + +/* Focused route player */ +.dashcam-player-overlay .media-player-content.dashcam-player { + background: var(--secondary-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.55); + max-height: calc(100vh - 2rem); + max-width: calc(100vw - 2rem); + overflow-y: auto; + padding: 0; + text-align: left; + width: min(62rem, calc(100vw - 2rem)); +} + +.dashcam-player-header { + justify-content: space-between; + padding: var(--padding-base) var(--padding-lg); +} + +.dashcam-player-header h2 { + color: var(--text-color); + font-size: var(--font-size-xl); + line-height: 1.25; +} + +.dashcam-player-overlay .dashcam-player-close, +.dashcam-player-close { + align-items: center; + background: transparent; + border: 0; + box-shadow: none; + color: var(--danger-fg, #e05577); + cursor: pointer; + display: inline-flex; + font-size: 2.75rem; + font-weight: 300; + justify-content: center; + line-height: 0.8; + margin: 0; + padding: 0 0.25rem; + transition: color var(--transition-fast, 0.15s ease), transform var(--transition-fast, 0.15s ease), opacity var(--transition-fast, 0.15s ease); +} + +.dashcam-player-overlay .dashcam-player-close:hover, +.dashcam-player-overlay .dashcam-player-close:focus-visible, +.dashcam-player-close:hover, +.dashcam-player-close:focus-visible { + background: transparent; + color: var(--danger-hover-bg, #ff6b8b); + outline: none; + transform: scale(1.15); +} + +.dashcam-video-shell { + /* Match qcamera.ts so the visible frame does not widen during the full-quality swap. */ + aspect-ratio: 526 / 330; + background: #07080b; + contain: layout paint; + overflow: hidden; + position: relative; + width: 100%; +} + +.dashcam-player-overlay .dashcam-player .dashcam-video-shell video { + border-radius: 0; + display: block; + height: 100% !important; + inset: 0; + max-height: none; + max-width: none; + /* Crop both encodes into the same viewport instead of letting intrinsic media sizing win. */ + object-fit: cover; + object-position: center center; + position: absolute; + width: 100% !important; +} + +.dashcam-player-overlay .dashcam-player .dashcam-video-shell.qcamera-framing video { + /* qcamera.ts is a full-frame, non-uniform scale; mirror it to prevent a crop/FOV jump. */ + object-fit: fill; +} + +.dashcam-player-overlay .dashcam-player .dashcam-video-shell video.active { + opacity: 1; + pointer-events: auto; + z-index: 1; +} + +.dashcam-player-overlay .dashcam-player .dashcam-video-shell video.staging { + opacity: 0; + pointer-events: none; + z-index: 0; +} + +.dashcam-player-state { + align-items: center; + background: rgba(7, 8, 11, 0.78); + color: white; + display: flex; + inset: 0; + justify-content: center; + position: absolute; + text-align: center; + z-index: 2; +} + +.dashcam-player-state.error { + color: #ff9aaa; +} + +.dashcam-player-state[hidden], +.dashcam-segment-bar[hidden], +.dashcam-camera-selector button[hidden] { + display: none; +} + +.dashcam-player-title-row { + align-items: center; + display: flex; + gap: var(--gap-xs); +} + +.dashcam-title-rename { + background: transparent; + border: 0; + color: var(--text-muted); + cursor: pointer; + font-size: var(--font-size-base); + line-height: 1; + padding: 0.25rem; +} + +.dashcam-title-rename:hover { + color: var(--text-color); +} + +/* Cameras left, segments centred, actions right - the side columns stay equal so the + segment controls sit in the true centre regardless of how wide either side is. */ +.dashcam-player-toolbar { + align-items: center; + background: var(--sidebar-bg); + border-bottom: var(--border-width-thin) solid var(--sidebar-border-color); + display: grid; + gap: var(--gap-sm); + grid-template-columns: 1fr auto 1fr; + padding: 0.65rem var(--padding-lg); +} + +.dashcam-segment-bar { + align-items: center; + display: flex; + gap: var(--gap-xs); + justify-self: center; +} + +.dashcam-segment-bar .segment-step { + align-items: center; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + color: var(--text-color); + cursor: pointer; + display: flex; + font-size: var(--font-size-base); + justify-content: center; + padding: 0.35rem 0.7rem; +} + +.dashcam-segment-bar .segment-step:disabled { + cursor: default; + opacity: 0.4; +} + +.dashcam-segment-bar .segment-select { + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + color: var(--text-color); + cursor: pointer; + font-size: var(--font-size-sm); + padding: 0.35rem 0.6rem; +} + + +.dashcam-camera-selector { + gap: var(--gap-xs); + justify-self: start; +} + +.dashcam-camera-selector button { + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + color: var(--text-color); + cursor: pointer; + font-size: var(--font-size-sm); + padding: 0.35rem 0.7rem; +} + +.dashcam-player-actions button { + align-items: center; + background: var(--input-bg); + border: var(--border-width-thin) solid var(--sidebar-border-color); + border-radius: var(--border-radius-md); + color: var(--text-color); + cursor: pointer; + display: flex; + font-size: var(--font-size-base); + justify-content: center; + padding: 0.35rem 0.6rem; +} + +.dashcam-camera-selector button.active { + background: var(--sidebar-active-bg); + box-shadow: inset 0 0 0 1px var(--main-fg); +} + +.dashcam-player-actions { + gap: var(--gap-xs); + justify-self: end; +} + +.dashcam-player-actions .action-download:not(:disabled) { + background: var(--color-confirm); +} + +.dashcam-player-actions button:disabled { + cursor: default; + opacity: 0.4; +} + +.dashcam-player-actions .action-delete { + background: var(--danger-bg); +} + +/* Responsive Adaptations */ +@media only screen and (max-width: 768px) { + .screen-recordings-wrapper.dashcam-routes-wrapper { + padding: 0 var(--padding-sm) var(--padding-lg); + } + + .screen-recordings-widget.dashcam-library { + margin-top: var(--padding-base); + padding: var(--padding-base); + } + + .dashcam-library-header, + .dashcam-danger-zone { + align-items: stretch; + flex-direction: column; + } + + .dashcam-danger-actions { + align-items: stretch; + flex-direction: column; + } + + .dashcam-toolbar { + flex-direction: column; + align-items: stretch; + } + + .dashcam-search-box { + flex: 1 1 auto; + width: 100%; + } + + .dashcam-filter-group, + .dashcam-sort, + .dashcam-view-toggle { + justify-content: center; + } + + .dashcam-sort { + display: flex; + flex: 1; + } + + .dashcam-route-row { + flex-direction: column; + align-items: stretch; + gap: var(--gap-sm); + } + + .dashcam-mini-preview { + width: 100%; + height: auto; + max-height: 140px; + } + + .dashcam-route-actions { + border-top: var(--border-width-thin) solid var(--sidebar-border-color); + padding-top: var(--padding-xs); + justify-content: space-between; + } + + .dashcam-library .screen-recordings-grid.dashcam-routes-grid { + grid-template-columns: 1fr; + } + + .dashcam-player-overlay .media-player-content.dashcam-player { + max-height: calc(100vh - 1rem); + max-width: calc(100vw - 1rem); + width: calc(100vw - 1rem); + } + + .dashcam-player-header, + .dashcam-player-toolbar { + padding-left: var(--padding-base); + padding-right: var(--padding-base); + } + + /* Too narrow for three columns: stack them and centre each row. */ + .dashcam-player-toolbar { + grid-template-columns: 1fr; + justify-items: center; + } + + .dashcam-camera-selector, + .dashcam-player-actions { + justify-self: center; + } } diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js index e0ccefd2e..749697c7e 100644 --- a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes.js @@ -1,165 +1,103 @@ import { html, reactive } from "/assets/vendor/arrow-core.js" -import { isGalaxyTunnel } from "/assets/js/utils.js" -import { getOrdinalSuffix } from "/assets/components/navigation/navigation_utilities.js" -import { Modal } from "/assets/components/modal.js"; +import { escapeHtml, isGalaxyTunnel } from "/assets/js/utils.js" +import { Modal } from "/assets/components/modal.js" +import { + buildRouteView, + cameraVideoUrl, + computeRouteStats, + formatApproxDuration, + getSegmentOptions, + shouldUpgradeFromHeight, + supportsLowQuality, + groupRoutesForView, + MAX_RENDERED_ROUTES, + normalizeRoute, + routeMetadataErrorMessage, + routeViewRenderKey, +} from "/assets/components/recordings/dashcam_routes_helpers.js" const state = reactive({ loading: true, error: null, routes: [], selectedRoute: null, + searchQuery: "", + sortOrder: "newest", showPreservedOnly: false, + viewMode: "list", progress: 0, total: 0, - showDeleteAllModal: false, + deleteMode: null, isDeletingAll: false, - truncated: false, }) -const MAX_RENDERED_ROUTES = 250 -const ROUTE_FLUSH_INTERVAL_MS = 120 - let routesAbortController = null let routesRequestToken = 0 -let pendingRoutes = [] -let flushTimerId = null let seenRouteNames = new Set() +let overlay = null +const routeLogsCache = new Map() +const FULL_QUALITY_RETRIES = 2 +const FULL_QUALITY_RETRY_MS = 1000 +// Only ask for the full stream once the viewer settles, so scrubbing queues no remuxes. +const FULL_QUALITY_SETTLE_MS = 1200 -function formatRouteDate(dateString) { - if (!dateString) { - return "Unknown Date" - } - - const date = new Date(dateString) - if (isNaN(date.getTime())) { - return dateString - } - const month = date.toLocaleString("en-US", { month: "long" }) - const day = date.getDate() - const year = date.getFullYear() - let hour = date.getHours() - const minute = date.getMinutes() - const ampm = hour >= 12 ? "pm" : "am" - hour = hour % 12 - hour = hour || 12 - const minuteStr = minute < 10 ? "0" + minute : minute - return `${month} ${day}${getOrdinalSuffix(day)}, ${year} - ${hour}:${minuteStr}${ampm}` +function routeLabel(route) { + return route.displayName || route.displayDate || route.name } -function resetRouteStreamState() { - pendingRoutes = [] - if (flushTimerId !== null) { - clearTimeout(flushTimerId) - flushTimerId = null - } - seenRouteNames = new Set() -} - -function flushPendingRoutes() { - if (pendingRoutes.length === 0) return - - const availableSlots = Math.max(MAX_RENDERED_ROUTES - state.routes.length, 0) - if (availableSlots <= 0) { - pendingRoutes = [] - state.truncated = true - return - } - - const toAppend = pendingRoutes.slice(0, availableSlots) - pendingRoutes = [] - if (toAppend.length > 0) { - state.routes = [...state.routes, ...toAppend] - } - if (state.routes.length >= MAX_RENDERED_ROUTES) { - state.truncated = true - } -} - -function enqueueRoutes(rawRoutes) { +function mergeRoutes(rawRoutes) { if (!Array.isArray(rawRoutes) || rawRoutes.length === 0) return - - const nextRoutes = [] - for (const route of rawRoutes) { - const name = String(route?.name || "") + const additions = [] + for (const rawRoute of rawRoutes) { + const name = String(rawRoute?.name || "") if (!name || seenRouteNames.has(name)) continue seenRouteNames.add(name) - nextRoutes.push({ - ...route, - timestamp: formatRouteDate(route.timestamp), - }) - } - - if (nextRoutes.length === 0) return - pendingRoutes.push(...nextRoutes) - - if (flushTimerId === null) { - flushTimerId = setTimeout(() => { - flushTimerId = null - flushPendingRoutes() - }, ROUTE_FLUSH_INTERVAL_MS) + additions.push(normalizeRoute(rawRoute)) } + // Worker completion order is irrelevant: buildRouteView sorts the list at render time. + if (additions.length) state.routes = [...state.routes, ...additions] } async function fetchRoutes() { const requestToken = ++routesRequestToken - if (routesAbortController) { - routesAbortController.abort() - } + routesAbortController?.abort() const controller = new AbortController() routesAbortController = controller try { - const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; - const response = await fetch(`/api/routes?timezone=${encodeURIComponent(userTimezone)}`, { - signal: controller.signal, - }); - if (!response.ok) throw new Error(); - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ""; + const response = await fetch("/api/routes", { signal: controller.signal }) + if (!response.ok || !response.body) throw new Error(`Route request failed (${response.status})`) + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = "" while (true) { - const { value, done } = await reader.read(); - if (done) break; - + const { value, done } = await reader.read() + if (done) break if (requestToken !== routesRequestToken) return - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split(/\r?\n\r?\n/); - buffer = lines.pop(); - - for (const line of lines) { - if (line.startsWith("data:")) { - try { - const payload = line.substring(5).trim() - if (!payload) continue - const data = JSON.parse(payload); - if (data.progress !== undefined && data.total !== undefined) { - state.progress = data.progress; - state.total = data.total; - } - if (data.routes) { - enqueueRoutes(data.routes) - } - } catch (e) { - console.error("Failed to parse JSON:", e); - } + buffer += decoder.decode(value, { stream: true }) + const events = buffer.split(/\r?\n\r?\n/) + buffer = events.pop() || "" + for (const event of events) { + const dataLines = event.split(/\r?\n/).filter(line => line.startsWith("data:")) + if (!dataLines.length) continue + try { + const payload = JSON.parse(dataLines.map(line => line.slice(5).trimStart()).join("\n")) + if (Number.isFinite(payload.progress)) state.progress = payload.progress + if (Number.isFinite(payload.total)) state.total = payload.total + mergeRoutes(payload.routes) + } catch (error) { + console.error("Failed to parse route stream event:", error) } } } - flushPendingRoutes() } catch (error) { - if (error?.name !== "AbortError") { - state.error = "Couldn't load routes. Please try again later..." - } + if (error?.name !== "AbortError") state.error = "Couldn't load routes. Try refreshing." } finally { if (requestToken === routesRequestToken) { - flushPendingRoutes() state.loading = false - if (routesAbortController === controller) { - routesAbortController = null - } + if (routesAbortController === controller) routesAbortController = null } } } @@ -170,244 +108,713 @@ function refresh() { state.routes = [] state.progress = 0 state.total = 0 - state.truncated = false - resetRouteStreamState() - fetchRoutes() + seenRouteNames = new Set() + return fetchRoutes() } -refresh() - -let overlay = null - -function openDialog(htmlStr) { - const o = document.createElement("div") - o.className = "dialog-overlay" - o.innerHTML = htmlStr - document.body.appendChild(o) - return o +function changeSortOrder(event) { + const sortOrder = String(event.target.value || "") + if (!["newest", "oldest", "longest", "shortest"].includes(sortOrder)) return + state.sortOrder = sortOrder } -function closeDialog(o) { - if (o) o.remove() +if (!isGalaxyTunnel()) refresh() + +function openDialog(htmlString) { + const dialog = document.createElement("div") + dialog.className = "dialog-overlay" + dialog.innerHTML = htmlString + document.body.appendChild(dialog) + return dialog +} + +function closeDialog(dialog) { + dialog?.remove() +} + +function replaceRoute(updatedRoute) { + // Rows bind to this exact object, so replacing it would strand them on the stale one. + const existing = state.routes.find(route => route.name === updatedRoute.name) + if (existing) Object.assign(existing, updatedRoute) + const selected = state.selectedRoute + if (selected?.name === updatedRoute.name && selected !== existing) { + Object.assign(selected, updatedRoute) + } + // Reassigning state.routes notifies ArrowJS to re-render views that depend on the routes array + state.routes = [...state.routes] } async function deleteRoute(route) { - const dlg = openDialog(` + const dialog = openDialog(`
-

Delete “${route.timestamp}”?

+

Delete “${escapeHtml(routeLabel(route))}”?

- - + +
`) - dlg.querySelector(".btn-cancel").onclick = () => closeDialog(dlg) - dlg.querySelector(".btn-del").onclick = async () => { - const res = await fetch(`/api/routes/${route.name}`, { method: "DELETE" }) - if (res.ok) { - state.routes = state.routes.filter(r => r.name !== route.name) - closeDialog(dlg) - closeOverlay() - refresh() - showSnackbar("Route deleted!") - } else { + dialog.querySelector(".btn-cancel").onclick = () => closeDialog(dialog) + dialog.querySelector(".btn-del").onclick = async () => { + const response = await fetch(`/api/routes/${route.name}`, { method: "DELETE" }) + if (!response.ok) { showSnackbar("Delete failed...", "error") + return } + closeDialog(dialog) + closeOverlay() + await refresh() + showSnackbar("Route deleted!") } } -async function resetRouteName(route, dlg) { - const res = await fetch(`/api/routes/reset_name`, { +async function resetRouteName(route, dialog) { + const response = await fetch("/api/routes/reset_name", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: route.name }) - }); - if (res.ok) { - const { timestamp } = await res.json(); - closeDialog(dlg); - const routeInList = state.routes.find(r => r.name === route.name); - if (routeInList) { - routeInList.timestamp = formatRouteDate(timestamp); - } - route.timestamp = formatRouteDate(timestamp); - const overlayTitleSpan = overlay.querySelector(".media-player-title span"); - if (overlayTitleSpan) { - overlayTitleSpan.textContent = formatRouteDate(timestamp); - } - showSnackbar("Route name reset!"); - } else { - showSnackbar("Resetting name failed...", "error"); + body: JSON.stringify({ name: route.name }), + }) + if (!response.ok) { + showSnackbar("Resetting name failed...", "error") + return } + + const { timestamp } = await response.json() + const updatedRoute = normalizeRoute({ ...route, timestamp, isCustomName: false }) + replaceRoute(updatedRoute) + closeDialog(dialog) + const title = overlay?.querySelector(".media-player-title-text") + if (title) title.textContent = routeLabel(updatedRoute) + showSnackbar("Route name reset!") } async function renameRoute(route) { - const dlg = openDialog(` + const dialog = openDialog(`
-

Rename "${route.timestamp}"

- +

Rename “${escapeHtml(routeLabel(route))}”

+
- - - + + +
-
`); - dlg.querySelector(".btn-cancel").onclick = () => closeDialog(dlg); - dlg.querySelector(".btn-reset").onclick = () => resetRouteName(route, dlg); - dlg.querySelector(".btn-save").onclick = async () => { - const newName = dlg.querySelector(".rn-input").value.trim(); - if (!newName) return; - const res = await fetch(`/api/routes/rename`, { + `) + dialog.querySelector(".btn-cancel").onclick = () => closeDialog(dialog) + dialog.querySelector(".btn-reset").onclick = () => resetRouteName(route, dialog) + dialog.querySelector(".btn-save").onclick = async () => { + const newName = dialog.querySelector(".rn-input").value.trim() + if (!newName) return + const response = await fetch("/api/routes/rename", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ old: route.name, new: newName }) - }); - if (res.ok) { - closeDialog(dlg); - const routeInList = state.routes.find(r => r.name === route.name); - if (routeInList) { - routeInList.timestamp = newName; - } - route.timestamp = newName; - const overlayTitleSpan = overlay.querySelector(".media-player-title span"); - if (overlayTitleSpan) { - overlayTitleSpan.textContent = newName; - } - showSnackbar("Route renamed!"); - } else { - showSnackbar("Rename failed...", "error"); + body: JSON.stringify({ old: route.name, new: newName }), + }) + if (!response.ok) { + showSnackbar("Rename failed...", "error") + return } - }; + + const payload = await response.json().catch(() => ({})) + const savedName = payload.name || newName + const updatedRoute = normalizeRoute({ ...route, timestamp: savedName, isCustomName: true }) + replaceRoute(updatedRoute) + closeDialog(dialog) + const title = overlay?.querySelector(".media-player-title-text") + if (title) title.textContent = savedName + showSnackbar("Route renamed!") + } +} + +function formatBytes(bytes) { + if (!bytes) return "0 MB" + const megabytes = bytes / 1e6 + return megabytes >= 1000 ? `${(megabytes / 1000).toFixed(2)} GB` : `${megabytes.toFixed(1)} MB` +} + +function openLogsDialog(route, logsButton, getCachedLogs, setCachedLogs) { + const logsDialog = openDialog(` + `) + const content = logsDialog.querySelector(".route-logs-content") + const closeButton = logsDialog.querySelector(".route-logs-close") + const closeLogsDialog = () => { + document.removeEventListener("keydown", handleKeydown) + closeDialog(logsDialog) + logsButton?.focus() + } + const handleKeydown = event => { if (event.key === "Escape") closeLogsDialog() } + closeButton.onclick = closeLogsDialog + logsDialog.addEventListener("click", event => { if (event.target === logsDialog) closeLogsDialog() }) + document.addEventListener("keydown", handleKeydown) + closeButton.focus() + + const renderLogs = data => { + content.innerHTML = ` +
+
${data.segments.length} segment${data.segments.length === 1 ? "" : "s"}${formatBytes(data.totalBytes)} total download
+ Download all .tar +
+ ` + } + + const cachedLogs = getCachedLogs?.() + if (cachedLogs) { + renderLogs(cachedLogs) + return + } + + fetch(`/api/routes/${route.name}/logs`) + .then(async response => ({ response, data: await response.json() })) + .then(({ response, data }) => { + if (!response.ok) { + if (content.isConnected) content.innerHTML = `

${escapeHtml(data.error || "Could not read logs.")}

` + return + } + setCachedLogs?.(data) + if (content.isConnected) renderLogs(data) + }) + .catch(error => { + if (content.isConnected) content.innerHTML = `

Could not reach the device: ${escapeHtml(error.message)}

` + }) +} + +function openLogsFromRow(route, event) { + const button = event.currentTarget + openLogsDialog( + route, + button, + () => routeLogsCache.get(route.name) || null, + value => { routeLogsCache.set(route.name, value) }, + ) } async function openOverlay(route) { - if (overlay) return; - - overlay = document.createElement("div"); - overlay.className = "media-player-overlay"; + if (overlay) return + overlay = document.createElement("div") + overlay.className = "media-player-overlay dashcam-player-overlay" overlay.innerHTML = ` -
-
- ${route.timestamp} - + ` + document.body.appendChild(overlay) - overlay.addEventListener("click", e => { - if (e.target === overlay) closeOverlay(); - }); - overlay.querySelector(".action-rename-icon").onclick = () => renameRoute(route); - overlay.querySelector(".action-close").onclick = closeOverlay; - overlay.querySelector(".action-delete").onclick = () => deleteRoute(route); + const [videoA, videoB] = overlay.querySelectorAll(".dashcam-video") + let activeVideo = videoA + let stagingVideo = videoB + const videoShell = overlay.querySelector(".dashcam-video-shell") + const playerState = overlay.querySelector(".dashcam-player-state") + const segmentBar = overlay.querySelector(".dashcam-segment-bar") + const segmentSelect = overlay.querySelector(".segment-select") + const prevSegmentButton = overlay.querySelector(".action-prev-segment") + const nextSegmentButton = overlay.querySelector(".action-next-segment") + const downloadButton = overlay.querySelector(".action-download") + const logsButton = overlay.querySelector(".action-logs") + const cameraButtons = [...overlay.querySelectorAll(".camera-button")] + let segments = [] + let current = 0 + let selectedCamera = null + let logsData = null + let showingPreview = false + let wantsPlayback = true + let qualityToken = 0 + let upgradeController = null + let upgradeTimer = null + let isUpgrading = false - const vid = overlay.querySelector("video"); - const downloadButton = overlay.querySelector(".action-download"); + const clearDeferredNativeControls = video => { + if (typeof video._dashcamControlsCleanup === "function") video._dashcamControlsCleanup() + } + const setNativeControls = (video, enabled) => { + clearDeferredNativeControls(video) + video.controls = enabled + } + const deferNativeControlsUntilInteraction = video => { + clearDeferredNativeControls(video) + video.controls = false - let segments; - let current = 0; - let selectedCamera = "forward"; + const restore = () => { + if (video !== activeVideo || !overlay) return + setNativeControls(video, true) + } + const events = ["pointermove", "pointerdown", "focus"] + const cleanup = () => { + events.forEach(eventName => video.removeEventListener(eventName, restore)) + delete video._dashcamControlsCleanup + } + video._dashcamControlsCleanup = cleanup + events.forEach(eventName => video.addEventListener(eventName, restore)) + } + const setPlayerMessage = (message, isError = false) => { + playerState.textContent = message + playerState.hidden = !message + playerState.classList.toggle("error", isError) + } + const syncSegmentControls = () => { + segmentSelect.value = String(current) + segmentSelect.disabled = segments.length < 2 + prevSegmentButton.disabled = current <= 0 + nextSegmentButton.disabled = current >= segments.length - 1 + } + const buildSegmentPicker = () => { + segmentSelect.innerHTML = getSegmentOptions(segments) + .map(option => ``) + .join("") + segmentBar.hidden = !segments.length + } + + const cancelUpgrade = () => { + qualityToken += 1 + isUpgrading = false + clearTimeout(upgradeTimer) + upgradeTimer = null + upgradeController?.abort() + upgradeController = null + stagingVideo.pause() + stagingVideo.removeAttribute("src") + setNativeControls(stagingVideo, false) + stagingVideo.load() + } + + const performSeamlessUpgrade = (fullUrl, token) => { + if (token !== qualityToken || !showingPreview || !overlay) return + isUpgrading = true + + stagingVideo.muted = activeVideo.muted + stagingVideo.volume = activeVideo.volume + stagingVideo.playbackRate = activeVideo.playbackRate + stagingVideo.src = fullUrl + stagingVideo.preload = "auto" + stagingVideo.load() + + const cleanupStaging = () => { + stagingVideo.removeEventListener("loadedmetadata", onMetadata) + stagingVideo.removeEventListener("error", onStagingError) + } + + const onStagingError = () => { + cleanupStaging() + if (token !== qualityToken) return + isUpgrading = false + showingPreview = false + } + + const onMetadata = () => { + if (token !== qualityToken || !showingPreview || !overlay) { + cleanupStaging() + return + } + + const syncAndSwap = () => { + if (token !== qualityToken || !showingPreview || !overlay) { + cleanupStaging() + return + } + + const applySwap = () => { + if (token !== qualityToken || !showingPreview || !overlay) { + cleanupStaging() + return + } + cleanupStaging() + + const targetTime = Number.isFinite(activeVideo.currentTime) ? activeVideo.currentTime : 0 + const isPlaying = !activeVideo.paused && !activeVideo.ended + + stagingVideo.muted = activeVideo.muted + stagingVideo.volume = activeVideo.volume + stagingVideo.playbackRate = activeVideo.playbackRate + + if (Math.abs(stagingVideo.currentTime - targetTime) > 0.15) { + try { + stagingVideo.currentTime = targetTime + } catch (_) {} + } + + if (isPlaying && stagingVideo.paused) { + stagingVideo.play().catch(() => {}) + } else if (!isPlaying && !stagingVideo.paused) { + stagingVideo.pause() + } + + activeVideo.classList.remove("active") + activeVideo.classList.add("staging") + setNativeControls(activeVideo, false) + + stagingVideo.classList.remove("staging") + stagingVideo.classList.add("active") + if (isPlaying) deferNativeControlsUntilInteraction(stagingVideo) + else setNativeControls(stagingVideo, true) + + const oldActive = activeVideo + activeVideo = stagingVideo + stagingVideo = oldActive + + stagingVideo.pause() + stagingVideo.removeAttribute("src") + stagingVideo.load() + + showingPreview = false + isUpgrading = false + setPlayerMessage("") + } + + const isPlaying = !activeVideo.paused && !activeVideo.ended + if (isPlaying) { + stagingVideo.play().then(() => { + if ("requestVideoFrameCallback" in stagingVideo) { + stagingVideo.requestVideoFrameCallback(() => applySwap()) + } else { + stagingVideo.addEventListener("timeupdate", applySwap, { once: true }) + } + }).catch(() => { + applySwap() + }) + } else { + if ("requestVideoFrameCallback" in stagingVideo) { + stagingVideo.requestVideoFrameCallback(() => applySwap()) + } else { + applySwap() + } + } + } + + const playbackTime = Number.isFinite(activeVideo.currentTime) ? activeVideo.currentTime : 0 + if (playbackTime > 0) { + stagingVideo.addEventListener("seeked", syncAndSwap, { once: true }) + try { + stagingVideo.currentTime = Math.min(playbackTime, Number.isFinite(stagingVideo.duration) ? stagingVideo.duration : playbackTime) + } catch (_) { + syncAndSwap() + } + } else { + syncAndSwap() + } + } + + stagingVideo.addEventListener("loadedmetadata", onMetadata, { once: true }) + stagingVideo.addEventListener("error", onStagingError, { once: true }) + } + + // Request the real stream behind the playing preview without interrupting playback. + const requestFullQuality = (segmentUrl, camera, attempt = 0) => { + const token = ++qualityToken + upgradeController?.abort() + const controller = new AbortController() + upgradeController = controller + const fullUrl = cameraVideoUrl(segmentUrl, camera) + const stillCurrent = () => + token === qualityToken && showingPreview && segments[current] === segmentUrl && selectedCamera === camera && Boolean(overlay) + + fetch(fullUrl, { method: "HEAD", signal: controller.signal }) + .then(response => { + if (!stillCurrent()) return + if (upgradeController === controller) upgradeController = null + if (response.ok) { + performSeamlessUpgrade(fullUrl, token) + return + } + if (response.status === 503 && attempt < FULL_QUALITY_RETRIES) { + upgradeTimer = setTimeout(() => { + if (stillCurrent()) requestFullQuality(segmentUrl, camera, attempt + 1) + }, FULL_QUALITY_RETRY_MS) + } + }) + .catch(error => { + if (upgradeController === controller) upgradeController = null + if (error?.name !== "AbortError") console.error("Could not prepare full-quality route video:", error) + }) + } + + const scheduleUpgrade = (segmentUrl, camera) => { + cancelUpgrade() + upgradeTimer = setTimeout(() => { + if (!showingPreview || segments[current] !== segmentUrl || selectedCamera !== camera) return + requestFullQuality(segmentUrl, camera) + }, FULL_QUALITY_SETTLE_MS) + } + + const loadSegment = (autoplay, { message, preview } = {}) => { + const segmentUrl = segments[current] + const camera = selectedCamera + if (!segmentUrl || !camera) return + cancelUpgrade() + wantsPlayback = autoplay + showingPreview = preview === undefined ? supportsLowQuality(camera) : preview + // qcamera.ts scales the complete road frame to 526x330. Keep that same mapping + // after the full stream arrives so its slightly wider aspect ratio is not cropped. + videoShell.classList.toggle("qcamera-framing", showingPreview) + if (message) setPlayerMessage(message) + + stagingVideo.pause() + stagingVideo.removeAttribute("src") + stagingVideo.classList.remove("active") + stagingVideo.classList.add("staging") + setNativeControls(stagingVideo, false) + + activeVideo.classList.remove("staging") + activeVideo.classList.add("active") + setNativeControls(activeVideo, true) + activeVideo.src = cameraVideoUrl(segmentUrl, camera, showingPreview ? "low" : undefined) + activeVideo.load() + if (autoplay) activeVideo.play().catch(() => {}) + } + + const playCurrentSegment = (autoplay = true) => { + if (!segments[current] || !selectedCamera) return + syncSegmentControls() + loadSegment(autoplay) + } + const goToSegment = index => { + if (!segments.length) return + const target = Math.min(Math.max(index, 0), segments.length - 1) + if (target === current) { + syncSegmentControls() + return + } + const keepPlaying = activeVideo.ended || (!activeVideo.paused && !activeVideo.error) + current = target + playCurrentSegment(keepPlaying) + } + overlay._cancelUpgrade = cancelUpgrade + + const closeOnEscape = event => { + if (document.querySelector(".route-logs-dialog")) return + if (event.key === "Escape") { + closeOverlay() + return + } + if (!event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) return + if (event.key === "ArrowLeft") { + event.preventDefault() + goToSegment(current - 1) + } else if (event.key === "ArrowRight") { + event.preventDefault() + goToSegment(current + 1) + } + } + + overlay.addEventListener("click", event => { if (event.target === overlay) closeOverlay() }) + document.addEventListener("keydown", closeOnEscape) + overlay._closeOnEscape = closeOnEscape + overlay.querySelector(".dashcam-player-close").onclick = closeOverlay + overlay.querySelector(".action-delete").onclick = () => deleteRoute(state.selectedRoute || route) + overlay.querySelector(".action-rename").onclick = () => renameRoute(state.selectedRoute || route) + + logsButton.onclick = () => openLogsDialog(route, logsButton, () => logsData, value => { logsData = value }) downloadButton.onclick = () => { - const link = document.createElement("a"); - const videoPath = `/video/${route.name}/combined?camera=${selectedCamera}`; - link.href = videoPath; - link.download = `${route.timestamp}-${selectedCamera}.mp4`; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - }; + if (!selectedCamera) return + const link = document.createElement("a") + link.href = `/video/${route.name}/combined?camera=${encodeURIComponent(selectedCamera)}` + link.download = `${routeLabel(state.selectedRoute || route)}-${selectedCamera}.mp4` + document.body.appendChild(link) + link.click() + link.remove() + } - (async () => { - try { - const response = await fetch(`/api/routes/${route.name}`); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - const data = await response.json(); - segments = data.segment_urls; - - if (!segments || segments.length === 0) { - segments = [`/video/${route.name}--0`]; - } - vid.src = `${segments[0]}?camera=forward`; - vid.load(); - vid.play(); - } catch (error) { - showSnackbar("Error: Could not load combined route video.", "error"); + const handleLoadedMetadata = event => { + if (event.target !== activeVideo) return + if (!showingPreview) return + if (shouldUpgradeFromHeight(activeVideo.videoHeight)) { + scheduleUpgrade(segments[current], selectedCamera) + } else { + showingPreview = false } - })(); - - vid.addEventListener("ended", () => { - current++; - if (current < segments.length) { - const videoPath = segments[current].includes("?") ? `${segments[current]}&camera=${selectedCamera}` : `${segments[current]}?camera=${selectedCamera}` - vid.src = videoPath; - vid.load(); - vid.play(); + } + const handleLoadedData = event => { + if (event.target === activeVideo) setPlayerMessage("") + } + const handlePlaying = event => { + if (event.target === activeVideo) setPlayerMessage("") + } + const handleWaiting = () => {} + const handleError = event => { + if (event.target !== activeVideo) return + // A dead preview drops through to the real stream rather than showing an error. + if (showingPreview) { + showingPreview = false + cancelUpgrade() + loadSegment(wantsPlayback, { preview: false }) + return } - }); + setPlayerMessage("This segment could not be played.", true) + } + const handleEnded = event => { + if (event.target === activeVideo) goToSegment(current + 1) + } + const handleSeeking = event => { + if (event.target !== activeVideo) return + if (isUpgrading && stagingVideo.readyState >= 1) { + try { + stagingVideo.currentTime = activeVideo.currentTime + } catch (_) {} + } + } + const handlePause = event => { + if (event.target !== activeVideo) return + if (isUpgrading && !stagingVideo.paused) stagingVideo.pause() + } + const handlePlay = event => { + if (event.target !== activeVideo) return + if (isUpgrading && stagingVideo.paused && stagingVideo.readyState >= 3) { + stagingVideo.play().catch(() => {}) + } + } - overlay.querySelectorAll(".camera-button").forEach(button => { - button.addEventListener("click", e => { - overlay.querySelectorAll(".camera-button").forEach(btn => btn.classList.remove("active")); - e.target.classList.add("active"); - selectedCamera = e.target.dataset.camera; - vid.src = segments[current].includes("?") ? `${segments[current]}&camera=${selectedCamera}` : `${segments[current]}?camera=${selectedCamera}`; - vid.load(); - vid.play(); - }); - }); + const bindVideoEvents = el => { + el.addEventListener("loadedmetadata", handleLoadedMetadata) + el.addEventListener("loadeddata", handleLoadedData) + el.addEventListener("playing", handlePlaying) + el.addEventListener("waiting", handleWaiting) + el.addEventListener("error", handleError) + el.addEventListener("ended", handleEnded) + el.addEventListener("seeking", handleSeeking) + el.addEventListener("pause", handlePause) + el.addEventListener("play", handlePlay) + } + bindVideoEvents(videoA) + bindVideoEvents(videoB) + + prevSegmentButton.onclick = () => goToSegment(current - 1) + nextSegmentButton.onclick = () => goToSegment(current + 1) + segmentSelect.onchange = () => goToSegment(Number(segmentSelect.value)) + + for (const button of cameraButtons) { + button.addEventListener("click", () => { + if (button.disabled || button.dataset.camera === selectedCamera || !segments[current]) return + selectedCamera = button.dataset.camera + cameraButtons.forEach(candidate => candidate.classList.toggle("active", candidate === button)) + const playbackTime = Number.isFinite(activeVideo.currentTime) ? activeVideo.currentTime : 0 + const shouldResume = !activeVideo.paused && !activeVideo.ended + activeVideo.addEventListener("loadedmetadata", () => { + if (playbackTime > 0) { + try { + activeVideo.currentTime = Math.min(playbackTime, Number.isFinite(activeVideo.duration) ? activeVideo.duration : playbackTime) + } catch (_) {} + } + }, { once: true }) + loadSegment(shouldResume, { message: "Switching camera…" }) + }) + } + + try { + const response = await fetch(`/api/routes/${route.name}`) + const data = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(routeMetadataErrorMessage(response.status, data?.error)) + segments = Array.isArray(data.segment_urls) ? data.segment_urls.filter(url => typeof url === "string") : [] + const availableCameras = ["forward", "wide", "driver"].filter(camera => data.available_cameras?.includes(camera)) + if (!segments.length) throw new Error("No video segments are stored for this route") + if (!availableCameras.length) throw new Error("No camera video is stored for this route") + + selectedCamera = availableCameras.includes("forward") ? "forward" : availableCameras[0] + for (const button of cameraButtons) { + const available = availableCameras.includes(button.dataset.camera) + button.hidden = !available + button.disabled = !available + button.classList.toggle("active", button.dataset.camera === selectedCamera) + } + downloadButton.disabled = false + buildSegmentPicker() + playCurrentSegment() + } catch (error) { + cameraButtons.forEach(button => { button.disabled = true }) + setPlayerMessage(error.message || "Could not load this route.", true) + } } function closeOverlay() { if (!overlay) return + overlay._cancelUpgrade?.() + document.removeEventListener("keydown", overlay._closeOnEscape) + const videos = overlay.querySelectorAll("video") + videos.forEach(v => { + v._dashcamControlsCleanup?.() + v.pause() + v.removeAttribute("src") + v.load() + }) overlay.remove() overlay = null state.selectedRoute = null } -async function togglePreserved(route, e) { - e.stopPropagation() - const newPreservedState = !route.is_preserved - const method = newPreservedState ? "POST" : "DELETE" +async function togglePreserved(route, event) { + event?.stopPropagation?.() + const isPreserved = !route.is_preserved try { - const response = await fetch(`/api/routes/${route.name}/preserve`, { method }) - if (response.ok) { - route.is_preserved = newPreservedState - } else { + const response = await fetch(`/api/routes/${route.name}/preserve`, { method: isPreserved ? "POST" : "DELETE" }) + if (!response.ok) { const errorData = await response.json() showSnackbar(errorData.error || "Failed to update preserved state...", "error") + return } + replaceRoute({ ...route, is_preserved: isPreserved }) } catch (_) { showSnackbar("An error occurred...", "error") } } -async function deleteAllRoutes() { - state.showDeleteAllModal = false +async function deleteAllRoutes(includePreserved) { + state.deleteMode = null state.isDeletingAll = true try { - const res = await fetch("/api/routes/delete_all", { method: "DELETE" }) - if (!res.ok) throw new Error() + const response = await fetch(`/api/routes/delete_all?include_preserved=${includePreserved}`, { method: "DELETE" }) + const payload = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(payload.error || "Route deletion failed") await refresh() - showSnackbar("All routes deleted!") - } catch { - showSnackbar("An error occurred while deleting all routes...", "error") + showSnackbar(payload.message || "Routes deleted!") + } catch (error) { + showSnackbar(error?.message || "An error occurred while deleting routes...", "error") } finally { state.isDeletingAll = false } } +function thumbnailFailed(event) { + event.currentTarget.hidden = true + event.currentTarget.parentElement?.classList.add("thumbnail-failed") +} + export function RouteRecordings() { if (isGalaxyTunnel()) { return html` @@ -415,97 +822,191 @@ export function RouteRecordings() {
🛰️

Dashcam Routes Unavailable via Galaxy

Loading dashcam routes requires a direct connection.
Connect to your device's local network to use this feature.

-
- `; +
` } - if (state.selectedRoute && !overlay) openOverlay(state.selectedRoute); + if (state.selectedRoute && !overlay) openOverlay(state.selectedRoute) return html` -
-
-
Dashcam Routes
- +
+
+
+
+

Local recordings

+

Dashcam Routes

+ ${() => { + const stats = computeRouteStats(state.routes) + return html`
${stats.count} ${stats.count === 1 ? "drive" : "drives"} ${stats.formattedDuration} total${stats.preservedCount > 0 ? html` ${stats.preservedCount} preserved` : ""}
` + }} +
+
+ +
+
+ +
+ +
+ + +
+ +
+ + +
+
${() => { - const routesToShow = state.routes.filter(r => !state.showPreservedOnly || r.is_preserved); + const view = buildRouteView(state.routes, { preservedOnly: state.showPreservedOnly, searchQuery: state.searchQuery, sortOrder: state.sortOrder }) + const groups = groupRoutesForView(view.visible, state.sortOrder) + const renderKey = routeViewRenderKey(view.visible, state.sortOrder, state.viewMode) + const hasActiveSearch = Boolean(state.searchQuery.trim()) - if (routesToShow.length === 0) { - if (state.loading && state.total > 0) { - return html`

Processing Routes: ${state.progress} of ${state.total}

`; - } - if (state.loading && !state.isDeletingAll) { - return html`

Loading...

`; - } - if (state.isDeletingAll) { - return html`

Deleting routes...

`; - } - if (state.showPreservedOnly) { - return html`

No preserved routes...

`; - } - if (state.error) { - return html`

${state.error}

`; - } - return html`

No routes found...

`; - } - - return html` -
- ${routesToShow.map( - route => html` -
-
- ${() => html``} -
-
- -
-

${route.timestamp}

+ return html` + ${state.loading || hasActiveSearch ? html` +
+ ${hasActiveSearch ? html`${view.matching.length} matching drive${view.matching.length === 1 ? "" : "s"}` : ""} + ${state.loading ? html`Loading routes` : ""} +
+ ` : ""} + ${state.error ? html`

${state.error}

` : ""} + ${state.isDeletingAll ? html`

Deleting routes…

` : ""} + ${!view.visible.length && state.loading ? html`

Finding local routes…

` : ""} + ${!view.visible.length && !state.loading && !state.isDeletingAll ? html`

${state.routes.length ? "No routes match these filters." : "No routes found."}

` : ""} +
+ ${groups.map(group => html` +
+
+

${group.label}

+ ${group.routes.length} ${group.routes.length === 1 ? "drive" : "drives"}
- ` - )} + ${state.viewMode === "grid" ? html`
+ ${group.routes.map(route => html` +
+
+ + ${route.name.split("--").slice(1).join("--") || route.name} +
+
+ Preview unavailable + + +
+
+
+

${() => route.displayName}

+ ${() => route.isCustomName ? html`` : ""} +
+ ${() => route.isCustomName ? html`

${route.displayDate}

` : ""} +
+ ${formatApproxDuration(route.approxDurationSeconds)} + ${route.segmentCount} seg +
+
+ + + + +
+
+
`.key(route.name))} +
` : html`
+ ${group.routes.map(route => html` +
+
+ + + +
+
+
+

${() => route.displayName}

+ ${() => route.isCustomName ? html` Custom` : ""} +
+ ${() => route.isCustomName ? html`

${route.displayDate}

` : ""} +
+ ${formatApproxDuration(route.approxDurationSeconds)} + ${route.segmentCount} segment${route.segmentCount === 1 ? "" : "s"} + ${() => route.is_preserved ? html` Preserved` : ""} + ${route.name.split("--").slice(1).join("--") || route.name} +
+
+
+ + + + + +
+
`.key(route.name))} +
`} +
`.key(group.key))}
- `; - }} - ${() => state.truncated ? html` -

Showing first ${MAX_RENDERED_ROUTES} routes to keep the UI responsive.

- ` : ""} - ${() => { - if (state.routes.length > 0) { - return html` - - `; - } - return ""; - }} -
- ${() => state.showDeleteAllModal ? Modal({ - title: "Confirm Delete All", - message: "Are you sure you want to delete all routes? This action cannot be undone...", - onConfirm: deleteAllRoutes, - onCancel: () => { state.showDeleteAllModal = false; }, - confirmText: "Delete All" - }) : ""} -
- `; + ${view.truncated ? html`

Showing the first ${MAX_RENDERED_ROUTES} of ${view.matching.length} matching routes.

` : ""}` + }} + + ${() => state.routes.length ? html` +
+
Delete local routesKeep preserved routes, or remove everything.
+
+ + +
+
` : ""} +
+ ${() => state.deleteMode ? Modal({ + title: state.deleteMode === "all" ? "Delete All Routes, Including Preserved?" : "Delete All Non-Preserved Routes?", + message: state.deleteMode === "all" + ? "This permanently deletes every local route, including preserved routes. This action cannot be undone." + : "This permanently deletes every non-preserved local route. Preserved routes will be kept.", + onConfirm: () => deleteAllRoutes(state.deleteMode === "all"), + onCancel: () => { state.deleteMode = null }, + confirmText: state.deleteMode === "all" ? "Delete Everything" : "Delete Non-Preserved", + }) : ""} +
` } diff --git a/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js new file mode 100644 index 000000000..3ea1a613e --- /dev/null +++ b/starpilot/system/the_galaxy/assets/components/recordings/dashcam_routes_helpers.js @@ -0,0 +1,284 @@ +export const MAX_RENDERED_ROUTES = 250 +const SEARCH_MONTHS = ["january", "february", "march", "april", "may", "june", "july", "august", "september", "october", "november", "december"] + +function validDate(value) { + if (!value) return null + const date = value instanceof Date ? new Date(value.getTime()) : new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +export function normalizeRouteSearchText(value) { + return String(value || "") + .normalize("NFKD") + .replace(/\p{M}/gu, "") + .toLocaleLowerCase() + .replace(/(\d+)(?:st|nd|rd|th)\b/g, "$1") + .replace(/[^\p{L}\p{N}]+/gu, " ") + .trim() +} + +export function formatRouteDate(value, locale) { + const date = validDate(value) + if (!date) return "Unknown date" + return new Intl.DateTimeFormat(locale, { + dateStyle: "long", + timeStyle: "short", + }).format(date) +} + +export function normalizeRoute(route, locale) { + const timestamp = route?.timestamp == null ? null : String(route.timestamp) + const startedAtDate = validDate(route?.startedAt) + const timestampDate = validDate(timestamp) + const routeDate = startedAtDate || timestampDate + const displayDate = formatRouteDate(routeDate, locale) + const isCustomName = Boolean(route?.isCustomName) || Boolean(timestamp && !timestampDate) + const displayName = isCustomName ? timestamp : displayDate + const dateAliases = routeDate ? [ + new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(routeDate), + new Intl.DateTimeFormat("en-US", { month: "long", day: "numeric", year: "numeric" }).format(routeDate), + new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric" }).format(routeDate), + `${routeDate.getMonth() + 1}/${routeDate.getDate()}/${routeDate.getFullYear()}`, + `${routeDate.getFullYear()}-${routeDate.getMonth() + 1}-${routeDate.getDate()}`, + ] : [] + const timeAliases = routeDate ? [ + new Intl.DateTimeFormat(locale, { hour: "numeric", minute: "2-digit" }).format(routeDate), + new Intl.DateTimeFormat("en-US", { hour: "numeric", minute: "2-digit", hour12: true }).format(routeDate), + `${String(routeDate.getHours()).padStart(2, "0")}:${String(routeDate.getMinutes()).padStart(2, "0")}`, + ] : [] + + return { + ...route, + name: String(route?.name || ""), + timestamp, + startedAt: route?.startedAt || null, + isCustomName, + displayDate, + displayName, + _startedAtMs: startedAtDate?.getTime() ?? timestampDate?.getTime() ?? null, + _searchIndex: { + ids: [normalizeRouteSearchText(route?.name)].filter(Boolean), + titles: [normalizeRouteSearchText(isCustomName ? displayName : "")].filter(Boolean), + dates: dateAliases.map(normalizeRouteSearchText).filter(Boolean), + times: timeAliases.map(normalizeRouteSearchText).filter(Boolean), + }, + } +} + +export function formatTotalDuration(seconds) { + const totalMinutes = Math.max(0, Math.round(Number(seconds) / 60) || 0) + if (totalMinutes < 1) return "0 min" + if (totalMinutes < 60) return `${totalMinutes} min` + const hours = Math.floor(totalMinutes / 60) + const remaining = totalMinutes % 60 + return remaining > 0 ? `${hours}h ${remaining}m` : `${hours}h` +} + +export function computeRouteStats(routes = []) { + const list = Array.isArray(routes) ? routes : [] + let totalDurationSeconds = 0 + let preservedCount = 0 + let totalSegments = 0 + + for (const route of list) { + if (route) { + if (Number.isFinite(route.approxDurationSeconds)) { + totalDurationSeconds += Math.max(0, route.approxDurationSeconds) + } + if (route.is_preserved) { + preservedCount += 1 + } + if (Number.isFinite(route.segmentCount)) { + totalSegments += Math.max(0, route.segmentCount) + } + } + } + + return { + count: list.length, + totalDurationSeconds, + formattedDuration: formatTotalDuration(totalDurationSeconds), + preservedCount, + totalSegments, + } +} + +export function sortRoutes(routes, sortOrder = "newest") { + if (sortOrder === "longest" || sortOrder === "shortest") { + const direction = sortOrder === "longest" ? -1 : 1 + return [...routes].sort((left, right) => { + const leftDur = Number.isFinite(left?.approxDurationSeconds) ? left.approxDurationSeconds : -1 + const rightDur = Number.isFinite(right?.approxDurationSeconds) ? right.approxDurationSeconds : -1 + if (leftDur !== rightDur) return (leftDur - rightDur) * direction + return String(left?.name || "").localeCompare(String(right?.name || "")) + }) + } + + const direction = sortOrder === "oldest" ? 1 : -1 + return [...routes].sort((left, right) => { + const leftTime = left?._startedAtMs + const rightTime = right?._startedAtMs + if (leftTime == null && rightTime == null) return String(left?.name || "").localeCompare(String(right?.name || "")) + if (leftTime == null) return 1 + if (rightTime == null) return -1 + if (leftTime !== rightTime) return (leftTime - rightTime) * direction + return String(left?.name || "").localeCompare(String(right?.name || "")) * -direction + }) +} + +export function routeMatchesSearch(route, searchQuery) { + const rawQuery = String(searchQuery || "").trim() + const normalizedQuery = normalizeRouteSearchText(searchQuery) + const queryTokens = normalizedQuery.split(" ").filter(Boolean) + if (!queryTokens.length) return true + + const fallbackIndex = { + ids: [route?.name], + titles: [route?.timestamp, route?.displayName], + dates: [route?.displayDate], + times: [route?.displayDate], + } + const searchIndex = route?._searchIndex || Object.fromEntries( + Object.entries(fallbackIndex).map(([key, values]) => [key, values.map(normalizeRouteSearchText).filter(Boolean)]), + ) + + const hasMonth = queryTokens.some(token => token.length >= 3 && SEARCH_MONTHS.some(month => month.startsWith(token))) + const isDateQuery = hasMonth || /\d\s*[/-]\s*\d/.test(rawQuery) || /\d+(?:st|nd|rd|th)\b/i.test(rawQuery) || /^\d{4}$/.test(normalizedQuery) + const isTimeQuery = /^\d{1,2}$/.test(normalizedQuery) || /\d\s*:\s*\d/.test(rawQuery) || queryTokens.some(token => token === "am" || token === "pm") + const compactQuery = normalizedQuery.replaceAll(" ", "") + const isHexQuery = /^[0-9a-f]+$/.test(compactQuery) + const isIdQuery = rawQuery.includes("--") || (isHexQuery && ( + compactQuery.length >= 8 || (compactQuery.length >= 4 && /\d/.test(compactQuery) && /[a-f]/.test(compactQuery)) + )) + const valuesFor = key => Array.isArray(searchIndex[key]) ? searchIndex[key] : [] + const matchesValue = (value, dateValue = false) => { + if (value.includes(normalizedQuery)) return true + const searchTokens = value.split(" ").filter(Boolean) + return queryTokens.every(queryToken => searchTokens.some(searchToken => { + // In a date query, "20" is a day prefix, not a match for the year "2026". + if (dateValue && /^\d{1,2}$/.test(queryToken) && /^\d{4}$/.test(searchToken)) return false + return searchToken.startsWith(queryToken) + })) + } + + return valuesFor("titles").some(value => matchesValue(value)) + || (isDateQuery && valuesFor("dates").some(value => matchesValue(value, true))) + || (isTimeQuery && valuesFor("times").some(value => matchesValue(value))) + || (isIdQuery && valuesFor("ids").some(value => matchesValue(value))) +} + +export function buildRouteView(routes, options = {}) { + const matching = sortRoutes( + routes.filter(route => (!options.preservedOnly || route.is_preserved) && routeMatchesSearch(route, options.searchQuery)), + options.sortOrder, + ) + return { + matching, + visible: matching.slice(0, MAX_RENDERED_ROUTES), + truncated: matching.length > MAX_RENDERED_ROUTES, + } +} + +export function routeViewRenderKey(routes, sortOrder = "newest", viewMode = "list") { + const routeNames = Array.isArray(routes) ? routes.map(route => String(route?.name || "")).join(",") : "" + return `${viewMode}:${sortOrder}:${routeNames}` +} + +function localDayKey(date) { + return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}` +} + +export function groupRoutesByDate(routes, now = new Date(), locale) { + const today = validDate(now) || new Date() + today.setHours(0, 0, 0, 0) + const yesterday = new Date(today) + yesterday.setDate(yesterday.getDate() - 1) + const groups = [] + const byKey = new Map() + + for (const route of routes) { + const routeDate = route?._startedAtMs == null ? null : new Date(route._startedAtMs) + const key = routeDate ? localDayKey(routeDate) : "unknown" + let group = byKey.get(key) + if (!group) { + let label = "Unknown date" + if (routeDate) { + if (key === localDayKey(today)) label = "Today" + else if (key === localDayKey(yesterday)) label = "Yesterday" + else label = new Intl.DateTimeFormat(locale, { dateStyle: "long" }).format(routeDate) + } + group = { key, label, routes: [] } + byKey.set(key, group) + groups.push(group) + } + group.routes.push(route) + } + return groups +} + +export function groupRoutesForView(routes, sortOrder = "newest", now, locale) { + // Date headers would scramble a duration sort, so show one flat group instead. + if (sortOrder === "longest" || sortOrder === "shortest") { + const list = Array.isArray(routes) ? [...routes] : [] + if (!list.length) return [] + return [{ key: sortOrder, label: sortOrder === "longest" ? "Longest first" : "Shortest first", routes: list }] + } + return groupRoutesByDate(routes, now, locale) +} + +export function formatApproxDuration(seconds) { + const minutes = Math.max(0, Math.round(Number(seconds) / 60) || 0) + if (minutes < 1) return "Less than 1 min" + if (minutes < 60) return `About ${minutes} min` + const hours = Math.floor(minutes / 60) + const remaining = minutes % 60 + return `About ${hours} hr${remaining ? ` ${remaining} min` : ""}` +} + +export function parseStoredSegmentNumber(segmentUrl) { + const cleanPath = String(segmentUrl || "").split(/[?#]/, 1)[0] + const match = cleanPath.match(/--(\d+)\/?$/) + if (!match) return null + const value = Number(match[1]) + return Number.isSafeInteger(value) ? value : null +} + +export function getSegmentStatus(segmentUrls, playbackIndex) { + if (!Array.isArray(segmentUrls) || !Number.isInteger(playbackIndex) || playbackIndex < 0 || playbackIndex >= segmentUrls.length) return "" + const segmentNumber = parseStoredSegmentNumber(segmentUrls[playbackIndex]) + if (segmentNumber == null) return "" + return `Segment ${segmentNumber}` +} + +export function getSegmentOptions(segmentUrls) { + if (!Array.isArray(segmentUrls)) return [] + return segmentUrls.map((_, index) => ({ + index, + label: getSegmentStatus(segmentUrls, index) || `Clip ${index + 1}`, + })) +} + +export function cameraVideoUrl(segmentUrl, camera, quality) { + const separator = String(segmentUrl).includes("?") ? "&" : "?" + const url = `${segmentUrl}${separator}camera=${encodeURIComponent(camera)}` + return quality ? `${url}&quality=${encodeURIComponent(quality)}` : url +} + +export function routeMetadataErrorMessage(status, serverError) { + if (status === 404) { + return "This route is no longer available on this device. Its local video segments may have been deleted or moved." + } + const detail = String(serverError || "").trim() + return detail || `Could not load route details (${status}).` +} + +// loggerd only writes qcamera.ts alongside the road camera. +export function supportsLowQuality(camera) { + return camera === "forward" +} + +// qcamera is 526x330. Only a positively taller frame proves the real stream is already +// playing; an unknown height upgrades rather than stranding the viewer on the preview. +export function shouldUpgradeFromHeight(height) { + return !(Number.isFinite(height) && height > 400) +} diff --git a/starpilot/system/the_galaxy/assets/js/utils.js b/starpilot/system/the_galaxy/assets/js/utils.js index b0303bee3..b00d24aac 100644 --- a/starpilot/system/the_galaxy/assets/js/utils.js +++ b/starpilot/system/the_galaxy/assets/js/utils.js @@ -37,6 +37,20 @@ export function parseErrorLogToDate(filename) { return new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}`) } +/** + * Escape a value for interpolation into an HTML string + * @param {unknown} value + * @returns {string} + */ +export function escapeHtml(value) { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'") +} + /** * Capitalize the first character of a string * @param {string} str diff --git a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py index 95a94a738..4f5d21c3d 100644 --- a/starpilot/system/the_galaxy/tests/test_dashboard_stats.py +++ b/starpilot/system/the_galaxy/tests/test_dashboard_stats.py @@ -59,6 +59,8 @@ sys.modules.setdefault("openpilot.starpilot.assets.theme_manager", theme_manager import utilities +_REAL_COMMON_PARAMS_MODULE = sys.modules.get("openpilot.common.params") + for _module_name, _module in _INITIAL_MODULES.items(): if _module is None: sys.modules.pop(_module_name, None) @@ -86,6 +88,8 @@ def _simple_module(name, **attrs): def _install_server_import_stubs(): + if _REAL_COMMON_PARAMS_MODULE is not None: + sys.modules["openpilot.common.params"] = _REAL_COMMON_PARAMS_MODULE sys.modules["openpilot.system.loggerd.config"] = loggerd_config sys.modules["openpilot.system.loggerd.deleter"] = loggerd_deleter sys.modules["openpilot.system.loggerd.uploader"] = loggerd_uploader @@ -130,6 +134,14 @@ def _install_server_import_stubs(): ) sys.modules["openpilot.common.realtime"] = _simple_module("openpilot.common.realtime", DT_HW=0.01) + sys.modules["openpilot.common.swaglog"] = _simple_module( + "openpilot.common.swaglog", + cloudlog=SimpleNamespace( + error=lambda *args, **kwargs: None, + exception=lambda *args, **kwargs: None, + info=lambda *args, **kwargs: None, + ), + ) sys.modules["openpilot.common.time_helpers"] = _simple_module("openpilot.common.time_helpers", system_time_valid=lambda: True) sys.modules["openpilot.system.hardware"] = _simple_module( "openpilot.system.hardware", @@ -149,6 +161,15 @@ def _install_server_import_stubs(): get_longitudinal_maneuver_support=lambda *args, **kwargs: {}, ) sys.modules["panda"] = _simple_module("panda", Panda=lambda *args, **kwargs: SimpleNamespace(can_send=lambda *send_args, **send_kwargs: None)) + msgq_module = _simple_module("msgq") + msgq_visionipc = _simple_module( + "msgq.visionipc", + VisionIpcClient=lambda *args, **kwargs: SimpleNamespace(connect=lambda *connect_args: False), + VisionStreamType=SimpleNamespace(VISION_STREAM_DRIVER=0), + ) + msgq_module.visionipc = msgq_visionipc + sys.modules["msgq"] = msgq_module + sys.modules["msgq.visionipc"] = msgq_visionipc model_manager.is_builtin_model_key = lambda value: False model_manager.model_key_aliases = lambda value: [value] @@ -337,17 +358,16 @@ class FakeDashboardAnalyzerProcess: def test_route_inventory_counts_segments_without_video_probing(monkeypatch): - segments = [ - SimpleNamespace(route_name=SimpleNamespace(time_str="route-new")), - SimpleNamespace(route_name=SimpleNamespace(time_str="route-new")), - SimpleNamespace(route_name=SimpleNamespace(time_str="route-new")), - SimpleNamespace(route_name=SimpleNamespace(time_str="route-old")), - ] + def segment(time_str, segment_num): + return SimpleNamespace(route_name=SimpleNamespace(time_str=time_str), segment_num=segment_num) + + # route-new has aged out of its first two segments, so it no longer starts at --0. + segments = [segment("route-new", 4), segment("route-new", 2), segment("route-new", 3), segment("route-old", 0)] monkeypatch.setattr(utilities, "get_all_segment_names", lambda _path: segments) - assert utilities.get_routes_with_segment_counts("/tmp/routes") == [ - ("route-old", 1), - ("route-new", 3), + assert utilities.get_routes_with_segment_details("/tmp/routes") == [ + ("route-old", {"segmentCount": 1, "firstSegmentNum": 0}), + ("route-new", {"segmentCount": 3, "firstSegmentNum": 2}), ] @@ -1071,6 +1091,28 @@ def test_clear_dashboard_route_history_keeps_durable_records(tmp_path, monkeypat assert stats["modelUsage"]["orion"]["drives"] == 3 +def test_clear_dashboard_route_history_can_retain_preserved_routes(tmp_path, monkeypatch): + monkeypatch.setattr(utilities, "DASHBOARD_PARAMS_DIR", tmp_path) + params = FakeParams({ + utilities.DASHBOARD_PERSISTENT_STATS_PARAM: { + "routes": { + "0000006a--9f0a7bdf9c": {"date": "2026-06-15T08:00:00"}, + "0000006b--9f0a7bdf9d": {"date": "2026-06-16T08:00:00"}, + }, + "ignoredRoutes": ["0000006a--9f0a7bdf9c", "0000006b--9f0a7bdf9d"], + "personalRecords": {"cleanDriveStreak": {"drives": 4}}, + }, + }) + + removed = utilities.clear_dashboard_route_history(params, retained_route_names={"0000006a--9f0a7bdf9c"}) + + assert removed == 1 + stats = utilities._load_dashboard_persistent_stats(params) + assert list(stats["routes"]) == ["0000006a--9f0a7bdf9c"] + assert stats["ignoredRoutes"] == ["0000006a--9f0a7bdf9c"] + assert stats["personalRecords"]["cleanDriveStreak"]["drives"] == 4 + + def test_lightweight_routes_surface_recent_drives_without_log_analysis(monkeypatch): utilities._invalidate_dashboard_cache() now = utilities.datetime.now().replace(hour=12, minute=0, second=0, microsecond=0) diff --git a/starpilot/system/the_galaxy/tests/test_dashcam_routes.py b/starpilot/system/the_galaxy/tests/test_dashcam_routes.py new file mode 100644 index 000000000..13864037e --- /dev/null +++ b/starpilot/system/the_galaxy/tests/test_dashcam_routes.py @@ -0,0 +1,854 @@ +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone +import io +import os +from pathlib import Path +import subprocess +import threading +import time + +import pytest + +from test_dashboard_stats import FakeParams, MODULE_DIR, _install_server_import_stubs + + +def _load_server_module(): + import importlib.util + import sys + + _install_server_import_stubs() + spec = importlib.util.spec_from_file_location("dashcam_routes_server", MODULE_DIR / "the_galaxy.py") + module = importlib.util.module_from_spec(spec) + sys.modules["dashcam_routes_server"] = module + spec.loader.exec_module(module) + return module + + +the_galaxy = _load_server_module() +utilities = the_galaxy.utilities +ROUTE_NAME = "0000006a--9f0a7bdf9c" + + +def _make_segment(root, route_name=ROUTE_NAME, segment_num=0): + segment = root / f"{route_name}--{segment_num}" + segment.mkdir(parents=True) + return segment + + +def _make_client(monkeypatch, root): + assert the_galaxy._import_galaxy_web_symbols() + monkeypatch.setattr(the_galaxy, "FOOTAGE_PATHS", [str(root) + "/"]) + monkeypatch.setattr(the_galaxy, "params", FakeParams()) + app = the_galaxy.Flask( + f"dashcam_routes_{time.monotonic_ns()}", + template_folder=str(MODULE_DIR / "templates"), + static_folder=str(MODULE_DIR / "assets"), + ) + the_galaxy.setup(app) + return app.test_client() + + +def test_process_route_is_metadata_only_and_retains_fields(monkeypatch, tmp_path): + segment = _make_segment(tmp_path, segment_num=3) + (segment / "qlog.zst").write_bytes(b"log") + (segment / "Morning school run").touch() + started_at = datetime(2026, 8, 26, 15, 30, tzinfo=timezone.utc) + monkeypatch.setattr(utilities, "get_route_start_time", lambda path: started_at) + monkeypatch.setattr(utilities, "has_preserve_attr", lambda path: True) + monkeypatch.setattr(utilities, "video_to_png", lambda *args: (_ for _ in ()).throw(AssertionError("preview generation must stay lazy"))) + + result = utilities.process_route(str(tmp_path), ROUTE_NAME, segment_count=4, first_segment_num=3) + + assert result == { + "name": ROUTE_NAME, + "png": f"/thumbnails/{ROUTE_NAME}--3/preview.png", + "timestamp": "Morning school run", + "startedAt": "2026-08-26T15:30:00Z", + "isCustomName": True, + "is_preserved": True, + "segmentCount": 4, + "approxDurationSeconds": 240, + } + + +def test_process_route_uses_display_timestamp_without_losing_started_at(monkeypatch, tmp_path): + _make_segment(tmp_path) + started_at = datetime(2026, 8, 26, 15, 30, tzinfo=timezone.utc) + monkeypatch.setattr(utilities, "get_route_start_time", lambda path: started_at) + + result = utilities.process_route(str(tmp_path), ROUTE_NAME, segment_count=1) + + assert result["timestamp"] == started_at.isoformat() + assert result["startedAt"] == "2026-08-26T15:30:00Z" + assert result["isCustomName"] is False + + +def test_route_scan_deduplicates_using_footage_root_priority(monkeypatch): + first = "/priority/" + second = "/fallback/" + details = { + first: [(ROUTE_NAME, {"segmentCount": 2, "firstSegmentNum": 1})], + second: [ + (ROUTE_NAME, {"segmentCount": 8, "firstSegmentNum": 0}), + ("0000006b--9f0a7bdf9d", {"segmentCount": 1, "firstSegmentNum": 4}), + ], + } + monkeypatch.setattr(utilities, "get_routes_with_segment_details", lambda path: details[path]) + + entries = the_galaxy._route_scan_entries([first, second]) + + assert entries == [ + (first, ROUTE_NAME, 2, 1), + (second, "0000006b--9f0a7bdf9d", 1, 4), + ] + + +def test_route_metadata_stream_batches_eight_with_progress_and_retained_fields(): + entries = [ + ("/routes/", f"{index:08x}--{index:010x}", index + 1, index % 3) + for index in range(18) + ] + + def process(path, name, segment_count, first_segment_num): + return { + "name": name, + "png": f"/thumbnails/{name}--{first_segment_num}/preview.png", + "timestamp": f"Route {segment_count}", + "startedAt": "2026-08-26T15:30:00Z", + "isCustomName": True, + "is_preserved": False, + "segmentCount": segment_count, + "approxDurationSeconds": segment_count * 60, + } + + events = list(the_galaxy._route_metadata_events(entries, "dongle", process)) + + assert events[0] == {"routes": [], "progress": 0, "total": 18, "connectDongleId": "dongle"} + assert [len(event["routes"]) for event in events[1:]] == [8, 8, 2] + assert [event["progress"] for event in events[1:]] == [8, 16, 18] + assert all(event["total"] == 18 for event in events) + results = [route for event in events[1:] for route in event["routes"]] + assert len(results) == 18 + assert all({ + "name", "png", "timestamp", "startedAt", "isCustomName", + "is_preserved", "segmentCount", "approxDurationSeconds", + } <= result.keys() for result in results) + + +def test_route_metadata_stream_cancels_queued_work_when_closed(): + entries = [("/routes/", f"{index:08x}--{index:010x}", 1, 0) for index in range(40)] + release = threading.Event() + started = [] + lock = threading.Lock() + + def process(path, name, segment_count, first_segment_num): + index = int(name.split("--", 1)[0], 16) + with lock: + started.append(index) + if index >= 8: + release.wait(timeout=2) + return {"name": name} + + stream = the_galaxy._route_metadata_events(entries, process_route=process) + next(stream) + batch = next(stream) + assert len(batch["routes"]) == 8 + stream.close() + release.set() + time.sleep(0.1) + + # At most four already-running workers continue; the remaining queue is cancelled. + assert len(started) <= 12 + + +def test_thumbnail_path_validation_is_strict(tmp_path): + _make_segment(tmp_path) + valid = f"{ROUTE_NAME}--0/preview.png" + + assert the_galaxy._resolve_route_thumbnail(valid, [tmp_path]) == tmp_path / f"{ROUTE_NAME}--0" / "preview.png" + for invalid in ( + "../preview.png", + f"{ROUTE_NAME}--0/qcamera.ts", + f"{ROUTE_NAME}--0/subdir/preview.png", + f"{ROUTE_NAME}--nope/preview.png", + f"/{ROUTE_NAME}--0/preview.png", + f"{ROUTE_NAME}--0\\preview.png", + ): + assert the_galaxy._resolve_route_thumbnail(invalid, [tmp_path]) is None + + +def test_thumbnail_path_validation_rejects_symlinks_outside_the_footage_root(tmp_path): + footage_root = tmp_path / "footage" + outside_segment = tmp_path / "outside" + footage_root.mkdir() + outside_segment.mkdir() + (footage_root / f"{ROUTE_NAME}--0").symlink_to(outside_segment, target_is_directory=True) + + assert the_galaxy._resolve_route_thumbnail(f"{ROUTE_NAME}--0/preview.png", [footage_root]) is None + + +def test_thumbnail_generation_is_lazy_and_reuses_completed_preview(monkeypatch, tmp_path): + segment = _make_segment(tmp_path) + (segment / "qcamera.ts").write_bytes(b"video") + calls = [] + + def generate(source, output): + calls.append((Path(source), Path(output))) + Path(output).write_bytes(b"png") + return True + + monkeypatch.setattr(utilities, "video_to_png", generate) + relative_path = f"{ROUTE_NAME}--0/preview.png" + + first = the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path]) + second = the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path]) + + assert first == second == segment / "preview.png" + assert len(calls) == 1 + assert calls[0][0] == segment / "qcamera.ts" + + +def test_thumbnail_failure_returns_none_and_does_not_cache_partial_file(monkeypatch, tmp_path): + segment = _make_segment(tmp_path) + (segment / "qcamera.ts").write_bytes(b"video") + monkeypatch.setattr(utilities, "video_to_png", lambda source, output: False) + + result = the_galaxy._get_or_create_route_thumbnail(f"{ROUTE_NAME}--0/preview.png", [tmp_path]) + + assert result is None + assert not (segment / "preview.png").exists() + + +def test_duplicate_thumbnail_requests_share_one_generation_job(monkeypatch, tmp_path): + segment = _make_segment(tmp_path) + (segment / "qcamera.ts").write_bytes(b"video") + release = threading.Event() + started = threading.Event() + calls = [] + + def generate(preview_path): + calls.append(preview_path) + started.set() + release.wait(timeout=2) + preview_path.write_bytes(b"png") + return preview_path + + monkeypatch.setattr(the_galaxy, "_generate_route_thumbnail", generate) + relative_path = f"{ROUTE_NAME}--0/preview.png" + with ThreadPoolExecutor(max_workers=2) as callers: + first = callers.submit(the_galaxy._get_or_create_route_thumbnail, relative_path, [tmp_path]) + assert started.wait(timeout=1) + second = callers.submit(the_galaxy._get_or_create_route_thumbnail, relative_path, [tmp_path]) + time.sleep(0.05) + release.set() + assert first.result(timeout=1) == segment / "preview.png" + assert second.result(timeout=1) == segment / "preview.png" + + assert len(calls) == 1 + assert the_galaxy._ROUTE_THUMBNAIL_EXECUTOR._max_workers == 2 + + +def test_timed_out_thumbnail_job_stays_deduplicated_until_completion(monkeypatch, tmp_path): + segment = _make_segment(tmp_path) + (segment / "qcamera.ts").write_bytes(b"video") + release = threading.Event() + started = threading.Event() + calls = [] + + def generate(preview_path): + calls.append(preview_path) + started.set() + release.wait(timeout=2) + preview_path.write_bytes(b"png") + return preview_path + + monkeypatch.setattr(the_galaxy, "_generate_route_thumbnail", generate) + monkeypatch.setattr(the_galaxy, "ROUTE_THUMBNAIL_WAIT_SECONDS", 0.01) + relative_path = f"{ROUTE_NAME}--0/preview.png" + preview_key = str((tmp_path / f"{ROUTE_NAME}--0" / "preview.png").resolve()) + + assert the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path]) is None + assert started.is_set() + assert preview_key in the_galaxy._ROUTE_THUMBNAIL_FUTURES + + # A retry while the original job is still running must reuse that job. + assert the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path]) is None + assert len(calls) == 1 + + release.set() + for _ in range(100): + if preview_key not in the_galaxy._ROUTE_THUMBNAIL_FUTURES: + break + time.sleep(0.01) + + assert preview_key not in the_galaxy._ROUTE_THUMBNAIL_FUTURES + assert the_galaxy._get_or_create_route_thumbnail(relative_path, [tmp_path]) == segment / "preview.png" + assert len(calls) == 1 + + +def test_routes_endpoint_uses_sse_no_buffering_headers(monkeypatch, tmp_path): + segment = _make_segment(tmp_path) + (segment / "qlog.zst").write_bytes(b"log") + monkeypatch.setattr(utilities, "get_route_start_time", lambda path: datetime(2026, 8, 26, tzinfo=timezone.utc)) + client = _make_client(monkeypatch, tmp_path) + + response = client.get("/api/routes") + + assert response.status_code == 200 + assert response.mimetype == "text/event-stream" + assert response.headers["X-Accel-Buffering"] == "no" + assert "no-cache" in response.headers["Cache-Control"] + assert b'"progress": 1' in response.data + assert b'"startedAt": "2026-08-26T00:00:00Z"' in response.data + + +def test_thumbnail_endpoint_sets_cache_headers(monkeypatch, tmp_path): + segment = _make_segment(tmp_path) + preview = segment / "preview.png" + preview.write_bytes(b"not-a-real-png-but-send-file-does-not-mind") + client = _make_client(monkeypatch, tmp_path) + + with client.get(f"/thumbnails/{ROUTE_NAME}--0/preview.png") as response: + assert response.status_code == 200 + assert response.mimetype == "image/png" + assert response.headers["Cache-Control"] == f"public, max-age={the_galaxy.ROUTE_THUMBNAIL_CACHE_SECONDS}" + assert response.data == preview.read_bytes() + + +def test_rename_and_reset_keep_logs_and_use_both_reset_urls(monkeypatch, tmp_path): + segments = [_make_segment(tmp_path, segment_num=number) for number in (0, 3)] + for segment in segments: + (segment / "qlog.zst").write_bytes(b"log") + (segment / "Old_name").touch() + monkeypatch.setattr(utilities, "get_route_start_time", lambda path: datetime(2026, 8, 26, tzinfo=timezone.utc)) + client = _make_client(monkeypatch, tmp_path) + + renamed = client.post("/api/routes/rename", json={"old": ROUTE_NAME, "new": "New name"}) + assert renamed.status_code == 200 + assert renamed.get_json()["name"] == "New_name" + assert all((segment / "New_name").exists() for segment in segments) + assert all((segment / "qlog.zst").read_bytes() == b"log" for segment in segments) + + reset = client.post("/api/routes/reset_name", json={"name": ROUTE_NAME}) + assert reset.status_code == 200 + assert reset.get_json()["timestamp"].startswith("2026-08-26") + assert all(not (segment / "New_name").exists() for segment in segments) + assert all((segment / "qlog.zst").exists() for segment in segments) + + # The legacy URL remains available for older clients. + for segment in segments: + (segment / "Another_name").touch() + assert client.post("/api/routes/clear_name", json={"name": ROUTE_NAME}).status_code == 200 + + +def test_preserve_unpreserve_and_delete_route_endpoints(monkeypatch, tmp_path): + segment = _make_segment(tmp_path) + client = _make_client(monkeypatch, tmp_path) + attributes = set() + deleted = [] + monkeypatch.setattr(the_galaxy, "PRESERVE_COUNT", 10) + monkeypatch.setattr(the_galaxy.os, "listxattr", lambda path: list(attributes), raising=False) + monkeypatch.setattr(the_galaxy.os, "getxattr", lambda path, name: the_galaxy.PRESERVE_ATTR_VALUE, raising=False) + monkeypatch.setattr(the_galaxy.os, "setxattr", lambda path, name, value: attributes.add(name), raising=False) + monkeypatch.setattr(the_galaxy.os, "removexattr", lambda path, name: attributes.discard(name), raising=False) + monkeypatch.setattr(the_galaxy, "delete_file", deleted.append) + + assert client.post(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200 + assert the_galaxy.PRESERVE_ATTR_NAME in attributes + assert client.delete(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200 + assert the_galaxy.PRESERVE_ATTR_NAME not in attributes + assert client.delete(f"/api/routes/{ROUTE_NAME}").status_code == 200 + assert deleted == [str(segment)] + + +def test_preserve_follows_the_first_surviving_segment(monkeypatch, tmp_path): + segment = _make_segment(tmp_path, segment_num=3) # --0 and --1 already aged out + client = _make_client(monkeypatch, tmp_path) + attributes = {} + monkeypatch.setattr(the_galaxy, "PRESERVE_COUNT", 10) + monkeypatch.setattr(the_galaxy.os, "listxattr", lambda path: list(attributes.get(str(path), ())), raising=False) + monkeypatch.setattr(the_galaxy.os, "getxattr", lambda path, name: the_galaxy.PRESERVE_ATTR_VALUE, raising=False) + monkeypatch.setattr(the_galaxy.os, "setxattr", lambda path, name, value: attributes.setdefault(str(path), set()).add(name), raising=False) + monkeypatch.setattr(the_galaxy.os, "removexattr", lambda path, name: attributes[str(path)].discard(name), raising=False) + + assert client.post(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200 + assert attributes == {str(segment): {the_galaxy.PRESERVE_ATTR_NAME}} + assert utilities.process_route(str(tmp_path) + "/", ROUTE_NAME, 1, 3)["is_preserved"] is True + + assert client.delete(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200 + assert attributes[str(segment)] == set() + + +def test_preserve_limit_counts_routes_not_segments(monkeypatch, tmp_path): + for segment_num in (5, 6, 7): + _make_segment(tmp_path, segment_num=segment_num) + client = _make_client(monkeypatch, tmp_path) + monkeypatch.setattr(the_galaxy, "PRESERVE_COUNT", 1) + monkeypatch.setattr(the_galaxy.os, "listxattr", lambda path: [the_galaxy.PRESERVE_ATTR_NAME], raising=False) + monkeypatch.setattr(the_galaxy.os, "getxattr", lambda path, name: the_galaxy.PRESERVE_ATTR_VALUE, raising=False) + monkeypatch.setattr(the_galaxy.os, "setxattr", lambda path, name, value: None, raising=False) + + # Three preserved segments belong to one route, so the cap of 1 is not already spent on it. + assert client.post(f"/api/routes/{ROUTE_NAME}/preserve").status_code == 200 + assert client.post("/api/routes/00000099--9f0a7bdf9c/preserve").status_code == 400 + + +def test_delete_all_non_preserved_keeps_entire_preserved_route_across_roots(monkeypatch, tmp_path): + standard = tmp_path / "standard" + high_resolution = tmp_path / "high_resolution" + preserved_route = ROUTE_NAME + ordinary_route = "0000006b--9f0a7bdf9d" + preserved_marker = _make_segment(standard, preserved_route, 3) + preserved_other_root = _make_segment(high_resolution, preserved_route, 4) + ordinary_standard = _make_segment(standard, ordinary_route, 0) + ordinary_other_root = _make_segment(high_resolution, ordinary_route, 1) + unrelated = high_resolution / "video_cache" + unrelated.mkdir() + + client = _make_client(monkeypatch, standard) + monkeypatch.setattr(the_galaxy, "FOOTAGE_PATHS", [str(standard), str(high_resolution)]) + monkeypatch.setattr(utilities, "has_preserve_attr", lambda path: path == str(preserved_marker)) + monkeypatch.setattr(utilities, "stop_dashboard_background_analysis", lambda: None) + monkeypatch.setattr(the_galaxy, "delete_file", lambda path: Path(path).rmdir()) + history_calls = [] + monkeypatch.setattr(utilities, "clear_dashboard_route_history", lambda params, retained_route_names=None: history_calls.append(retained_route_names) or 1) + factory_delete_calls = [] + monkeypatch.setattr(the_galaxy, "_run_factory_reset_delete", factory_delete_calls.append) + + response = client.delete("/api/routes/delete_all?include_preserved=false") + + assert response.status_code == 200 + assert response.get_json()["deletedRoutes"] == 1 + assert response.get_json()["preservedRoutes"] == 1 + assert preserved_marker.is_dir() + assert preserved_other_root.is_dir() + assert not ordinary_standard.exists() + assert not ordinary_other_root.exists() + assert unrelated.is_dir() + assert history_calls == [{preserved_route}] + assert factory_delete_calls == [] + + +def test_delete_all_including_preserved_keeps_existing_full_wipe_behavior(monkeypatch, tmp_path): + first_root = tmp_path / "standard" + second_root = tmp_path / "high_resolution" + _make_segment(first_root) + _make_segment(second_root) + client = _make_client(monkeypatch, first_root) + monkeypatch.setattr(the_galaxy, "FOOTAGE_PATHS", [str(first_root) + "/", str(second_root), str(first_root)]) + monkeypatch.setattr(utilities, "stop_dashboard_background_analysis", lambda: None) + history_calls = [] + monkeypatch.setattr(utilities, "clear_dashboard_route_history", lambda params, retained_route_names=None: history_calls.append(retained_route_names) or 2) + factory_delete_calls = [] + monkeypatch.setattr(the_galaxy, "_run_factory_reset_delete", factory_delete_calls.append) + + response = client.delete("/api/routes/delete_all?include_preserved=true") + + assert response.status_code == 200 + assert factory_delete_calls == [str(first_root), str(second_root)] + assert history_calls == [None] + assert "including preserved routes" in response.get_json()["message"] + + +def test_video_cache_evicts_oldest_instead_of_wiping_everything(monkeypatch, tmp_path): + """A tight disk used to delete every cached mp4, so playback re-muxed on every request.""" + cache = tmp_path / "video_cache" + cache.mkdir() + monkeypatch.setattr(utilities, "VIDEO_CACHE_PATH", cache) + monkeypatch.setattr(utilities, "VIDEO_CACHE_MAX_BYTES", 300) + + for index in range(4): + entry = cache / f"{index}.mp4" + entry.write_bytes(b"x" * 100) + os.utime(entry, (1000 + index, 1000 + index)) + + utilities._prune_video_cache() + + survivors = sorted(path.name for path in cache.glob("*.mp4")) + # Budget is 300 bytes of 400 used, so only the oldest goes. + assert survivors == ["1.mp4", "2.mp4", "3.mp4"] + + +def test_video_cache_never_evicts_the_entry_being_written(monkeypatch, tmp_path): + cache = tmp_path / "video_cache" + cache.mkdir() + monkeypatch.setattr(utilities, "VIDEO_CACHE_PATH", cache) + monkeypatch.setattr(utilities, "VIDEO_CACHE_MAX_BYTES", 50) + + for index in range(3): + entry = cache / f"{index}.mp4" + entry.write_bytes(b"x" * 100) + os.utime(entry, (1000 + index, 1000 + index)) + + keep = cache / "0.mp4" + utilities._prune_video_cache(keep_path=keep) + + assert keep.exists() + + +def test_combined_video_streams_fragmented_mp4_without_a_full_cache_file(monkeypatch, tmp_path): + cache = tmp_path / "video_cache" + first = tmp_path / "first.hevc" + second = tmp_path / "second.hevc" + first.write_bytes(b"first") + second.write_bytes(b"second") + monkeypatch.setattr(utilities, "VIDEO_CACHE_PATH", cache) + captured = {} + + class FakeProcess: + def __init__(self): + self.stdout = io.BytesIO(b"streamed-video") + self.returncode = None + + def wait(self, timeout=None): + self.returncode = 0 + return 0 + + def poll(self): + return self.returncode + + def popen(command, **kwargs): + captured["command"] = command + list_path = Path(command[command.index("-i") + 1]) + captured["list_path"] = list_path + captured["list_contents"] = list_path.read_text() + return FakeProcess() + + monkeypatch.setattr(utilities.subprocess, "Popen", popen) + + payload = b"".join(utilities.ffmpeg_stream_concatenated_mp4([first, second], chunk_size=4)) + + assert payload == b"streamed-video" + assert "frag_keyframe+empty_moov+default_base_moof" in captured["command"] + assert captured["command"][-1] == "pipe:1" + assert captured["list_contents"] == f"file '{first}'\nfile '{second}'\n" + assert not captured["list_path"].exists() + assert not list(cache.glob("*.mp4")) + + +def test_combined_video_stream_has_a_hard_timeout(monkeypatch, tmp_path): + cache = tmp_path / "video_cache" + source = tmp_path / "first.hevc" + source.write_bytes(b"first") + monkeypatch.setattr(utilities, "VIDEO_CACHE_PATH", cache) + monkeypatch.setattr(utilities, "VIDEO_STREAM_TIMEOUT_SECONDS", 0.01) + + class HangingStdout: + def read(self, _): + time.sleep(10) + return b"" + + def close(self): + pass + + class HangingProcess: + def __init__(self): + self.stdout = HangingStdout() + self.returncode = None + self.terminated = False + + def poll(self): + return self.returncode + + def terminate(self): + self.terminated = True + self.returncode = -15 + + def wait(self, timeout=None): + del timeout + return self.returncode + + process = HangingProcess() + monkeypatch.setattr(utilities.subprocess, "Popen", lambda *args, **kwargs: process) + + with pytest.raises(TimeoutError, match="Timed out streaming"): + b"".join(utilities.ffmpeg_stream_concatenated_mp4([source], chunk_size=4)) + + assert process.terminated + assert not list(cache.glob("route-download-*.txt")) + + +def test_route_endpoints_reject_invalid_names(monkeypatch, tmp_path): + client = _make_client(monkeypatch, tmp_path) + + for method, path in ( + (client.delete, "/api/routes/not-a-route"), + (client.post, "/api/routes/not-a-route/preserve"), + (client.delete, "/api/routes/not-a-route/preserve"), + (client.get, "/api/routes/not-a-route"), + (client.get, "/video/not-a-route/combined"), + ): + assert method(path).status_code == 400 + + +def _stub_remux(monkeypatch, tmp_path, payload=b"wrapped-video"): + """Stand in for the ffmpeg remux, returning a real file so send_file can stream it.""" + wrapped = tmp_path / "wrapped.mp4" + wrapped.write_bytes(payload) + monkeypatch.setattr(utilities, "ffmpeg_mp4_wrap_to_path", lambda path: wrapped) + return wrapped + + +def test_sparse_route_metadata_and_video_downloads(monkeypatch, tmp_path): + segments = [_make_segment(tmp_path, segment_num=number) for number in (0, 3, 11)] + for segment in segments: + (segment / "fcamera.hevc").write_bytes(b"hevc") + monkeypatch.setattr(utilities, "get_route_start_time", lambda path: datetime(2026, 8, 26, tzinfo=timezone.utc)) + _stub_remux(monkeypatch, tmp_path) + monkeypatch.setattr(utilities, "ffmpeg_stream_concatenated_mp4", lambda paths: iter((b"combined-", b"video"))) + client = _make_client(monkeypatch, tmp_path) + + metadata = client.get(f"/api/routes/{ROUTE_NAME}") + assert metadata.status_code == 200 + assert metadata.get_json()["segment_urls"] == [f"/video/{ROUTE_NAME}--{number}" for number in (0, 3, 11)] + # One minute per segment, without probing each one with ffprobe. + assert metadata.get_json()["total_duration"] == 180 + + with client.get(f"/video/{ROUTE_NAME}--3?camera=forward") as segment_video: + assert segment_video.status_code == 200 + assert segment_video.mimetype == "video/mp4" + assert segment_video.data == b"wrapped-video" + + with client.get(f"/video/{ROUTE_NAME}/combined?camera=forward") as combined_video: + assert combined_video.status_code == 200 + assert combined_video.mimetype == "video/mp4" + assert combined_video.data == b"combined-video" + assert combined_video.headers["X-Accel-Buffering"] == "no" + + +def test_route_metadata_never_probes_segments_with_ffprobe(monkeypatch, tmp_path): + """Probing each segment put one subprocess per segment in front of playback.""" + for number in (0, 1, 2): + segment = _make_segment(tmp_path, segment_num=number) + (segment / "fcamera.hevc").write_bytes(b"hevc") + + def explode(path): + raise AssertionError(f"ffprobe must stay off the route metadata path: {path}") + + monkeypatch.setattr(utilities, "get_video_duration", explode) + monkeypatch.setattr(utilities, "get_route_start_time", lambda path: datetime(2026, 8, 26, tzinfo=timezone.utc)) + client = _make_client(monkeypatch, tmp_path) + + metadata = client.get(f"/api/routes/{ROUTE_NAME}") + assert metadata.status_code == 200 + assert metadata.get_json()["total_duration"] == 180 + + +def test_low_quality_serves_the_wrapped_qcamera_preview(monkeypatch, tmp_path): + """qcamera.ts is tiny, but it still needs the mp4 wrap - MPEG-TS will not play in a