Files
SearchInterface/SearchFrontend/search_ui/src/App.jsx
2026-03-07 11:46:24 -05:00

913 lines
36 KiB
JavaScript

"use client";
import React, { useState, useEffect, useRef, useCallback } from "react";
import Login from "./components/Login";
import EmbedTimeline from "./components/EmbedTimeline";
import VideoPlayer from "./components/VideoPlayer";
import CustomDateRangePicker from "./components/DateRangePicker";
import "./App.css";
import StatusesDisplayHUD from "./components/StatusDisplay";
function App() {
const original_data = useRef(null);
const chartRef = useRef(null);
const [dataResults, setDataResults] = useState(null);
const [statusMessages, setStatusMessages] = useState([]);
const [markerTime, setMarkerTime] = useState(0);
const playerRef = useRef(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const playerInstanceRef = useRef(null);
const [selectedCamera, setSelectedCamera] = useState("Leopards 1");
const [selectedHighRes, setSelectedHighRes] = useState(false);
const [videoPlaying, setVideoPlaying] = useState(true);
// State for the values
window.chartRef = chartRef;
window.playerRef = playerRef;
window.playerInstanceRef = playerInstanceRef;
// Slider states
const inputRef = useRef(null);
const [sliderMin, setSliderMin] = useState(0.0);
const [sliderMax, setSliderMax] = useState(1.0);
// Date range states
//
const [startRange, setStartRange] = useState(
new Date(new Date().getTime() - 1 * 24 * 60 * 60 * 1000),
);
const [endRange, setEndRange] = useState(new Date());
// const [endRange, setEndRange] = useState(new Date(new Date().getTime() - 6 * 24 * 60 * 60 * 1000));
const [queryText, setQueryText] = useState("Two clouded leopards being aggressive");
const [sliderValue, setSliderValue] = useState(0);
// State to track last submitted values
const [lastSubmitted, setLastSubmitted] = useState({
startRange,
endRange,
queryText,
selectedCamera,
});
// // Check if any value has changed
// const queryChanged =
// startRange !== lastSubmitted.startRange ||
// endRange !== lastSubmitted.endRange ||
// queryText !== lastSubmitted.queryText;
// Check if queryText is different from lastSubmitted.queryText
const textChanged = queryText !== lastSubmitted.queryText;
const startChanged =
startRange.getTime() !== new Date(lastSubmitted.startRange).getTime();
const endChanged =
endRange.getTime() !== new Date(lastSubmitted.endRange).getTime();
const cameraChanged = selectedCamera !== lastSubmitted.selectedCamera;
const queryChanged =
textChanged || startChanged || endChanged || cameraChanged;
const streamComputeStatus = () => {
fetch("api/return_status")
.then((response) => {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = ""; // Accumulate partial text
function read() {
reader.read().then(({ done, value }) => {
if (done) {
if (buffer) {
}
setStatusMessages([]);
return;
}
// Decode only the new chunk
buffer += decoder.decode(value, { stream: true });
// If your server sends lines, split and log only complete lines:
let lines = buffer.split("\n");
buffer = lines.pop(); // Save incomplete line for next chunk
for (const line of lines) {
if (line.trim()) {
let c_line = JSON.parse(line);
if (c_line["task"] !== "DONE_QUIT") {
setStatusMessages((msgs) => [
...msgs,
c_line,
]);
}
}
}
read();
});
}
read();
})
.catch((error) => {
console.error("Error while streaming status:", error);
});
};
// Function to resubmit fetch
const handleResubmit = (doTestMode = false) => {
setVideoPlaying(false);
let startRangeUse;
let endRangeUse;
setDrawerOpen(false);
if (doTestMode == true) {
startRangeUse = new Date(
new Date().getTime() - 2 * 24 * 60 * 60 * 1000,
);
endRangeUse = new Date(
new Date().getTime() - 1 * 24 * 60 * 60 * 1000,
);
} else {
startRangeUse = startRange;
endRangeUse = endRange;
}
setStartRange(startRangeUse);
setEndRange(endRangeUse);
const params = new URLSearchParams();
params.append("startRange", startRangeUse.toISOString());
params.append("endRange", endRangeUse.toISOString());
params.append("threshold", 0.0);
params.append("query", queryText);
params.append("camera", selectedCamera);
setDataResults({ videos: [], breaks: [] });
authenticatedFetch("api/videos.json?" + params.toString())
.then((res) => res.json())
.then((data) => {
streamComputeStatus();
// Don't setDataResults here, since it's just {"status": ...}
pollForResult(); // Start polling for the real result
});
setLastSubmitted({
startRange: startRangeUse,
endRange: endRangeUse,
queryText,
selectedCamera,
});
};
function selectHighResFunc(selectedHighRes, setSelectedHighRes, toggleCheckbox = true) {
console.log(selectedHighRes);
const params = new URLSearchParams();
params.append("do_high_res", !selectedHighRes);
authenticatedFetch("api/set_parameter?" + params.toString())
.then((res) => res.json())
.then((data) => {
if (toggleCheckbox) {
setSelectedHighRes(!selectedHighRes);
}
});
}
function updateDataAndValue(newValue) {
const floatValue = parseFloat(newValue);
setSliderValue(floatValue);
var newData = JSON.parse(JSON.stringify(original_data.current));
newData["videos"] = newData["videos"].filter(
(vid) => vid["embed_scores"]["score"][1] >= floatValue,
);
newData["threshold"] = floatValue;
setDataResults(newData);
}
function setMarkerValueNonReactive(inputValue) {
let chart = chartRef.current.getEchartsInstance();
let options = chart.getOption();
let mappers = options["mappers"];
let vv = {
xAxis: mappers["real_to_virtual"](new Date(inputValue)),
lineStyle: { type: "solid", color: "#FF0000", width: 2 },
label: {
show: false,
formatter: "Break",
position: "bottom",
color: "#888",
fontSize: 10,
},
};
let markLine = {
symbol: ["none", "none"],
data: [vv],
lineStyle: { type: "dashed", color: "#FF0000", width: 2 },
silent: true,
animation: false,
};
// if ("markLine" in options["series"][1]) {
if (false) {
let vv_new = {
xAxis: mappers["real_to_virtual"](new Date(inputValue)),
};
let markLine_new = {
data: [vv_new],
};
chart.setOption(
{
series: [{}, { markLine: { data: [vv_new] } }],
},
false,
["series.markLine"],
);
} else {
chart.setOption(
{
series: [{}, { markLine: markLine }],
},
false,
["series.markLine"],
);
}
}
const handleTimelineClick = useCallback((path, timeoffset) => {
if (playerRef.current && playerInstanceRef.current) {
const player = playerInstanceRef.current;
const token = localStorage.getItem("access_token");
const authenticatedUrl = `api/${path}?token=${token}`;
player.reset();
// Configure player for range request support
const videoElement = player.el().querySelector("video");
if (videoElement) {
videoElement.crossOrigin = "anonymous";
videoElement.preload = "metadata";
}
player.src({
src: authenticatedUrl,
type: "video/mp4",
withCredentials: false,
});
setVideoPlaying(true);
// Ensure range headers are sent for seeking
player.ready(() => {
const tech = player.tech();
if (tech && tech.el_) {
tech.el_.addEventListener("loadstart", () => {
// Force range request capability
if (
tech.el_.seekable &&
tech.el_.seekable.length === 0
) {
console.log(
"Video doesn't support seeking - range headers may be needed",
);
}
});
}
});
player.load();
// Add multiple event listeners for debugging
player.one("loadedmetadata", () => {
console.log("Video metadata loaded");
console.log("Video duration:", player.duration());
console.log("Time offset:", timeoffset);
});
// Wait for the video to be ready for seeking
player.one("canplay", () => {
console.log("Video can start playing - setting time");
const duration = player.duration();
// Ensure timeoffset is valid
const seekTime = Math.max(
0,
Math.min(timeoffset, duration - 0.1),
);
console.log("Seeking to:", seekTime, "of", duration);
player.currentTime(seekTime);
// Wait a bit before playing to ensure seek completed
setTimeout(() => {
const playPromise = player.play();
if (playPromise !== undefined) {
playPromise
.then(() => {
console.log(
"Video started playing at time:",
player.currentTime(),
);
})
.catch((error) => {
console.error("Error playing video:", error);
});
}
}, 100);
});
player.one("error", (e) => {
console.error("Video error:", e);
console.error("Player error:", player.error());
});
player.one("loadstart", () => {
console.log("Load started");
});
player.one("canplay", () => {
console.log("Video can start playing");
});
} else {
console.error("Player ref not available");
}
}, []);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
selectHighResFunc(
selectedHighRes,
setSelectedHighRes,
false
)
handleResubmit(params.get("test_mode") === "true");
}, []);
// useEffect(() => {
// if (startRange && endRange) {
// handleResubmit();
// }
// }, [startRange, endRange]);
function pollForResult() {
const poll = () => {
authenticatedFetch("api/videos_result.json")
.then((res) => res.json())
.then((data) => {
if (data.status === "processing") {
setTimeout(poll, 250); // Try again in 1 second
} else {
const max_value = Math.max(
...data["videos"].map(
(vid) => vid["embed_scores"]["score"][1],
),
);
setSliderMax(max_value);
original_data.current = data;
setDataResults(data);
updateDataAndValue(sliderValue)
}
});
};
poll();
}
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [checkingAuth, setCheckingAuth] = useState(true);
// Check authentication on load
useEffect(() => {
const token = localStorage.getItem("access_token");
if (token) {
// Verify token is still valid
fetch("/api/videos_result.json", {
headers: {
Authorization: `Bearer ${token}`,
},
})
.then((response) => {
if (response.ok) {
setIsAuthenticated(true);
} else {
localStorage.removeItem("access_token");
setIsAuthenticated(false);
}
})
.catch(() => {
localStorage.removeItem("access_token");
setIsAuthenticated(false);
})
.finally(() => {
setCheckingAuth(false);
});
} else {
setCheckingAuth(false);
}
}, []);
// Add authentication header to all API calls
const authenticatedFetch = useCallback((url, options = {}) => {
const token = localStorage.getItem("access_token");
const headers = {
...options.headers,
Authorization: `Bearer ${token}`,
};
return fetch(url, { ...options, headers });
}, []);
const handleLoginSuccess = () => {
setIsAuthenticated(true);
handleResubmit(params.get("test_mode") === "true");
};
const handleLogout = () => {
localStorage.removeItem("access_token");
setIsAuthenticated(false);
};
if (checkingAuth) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
background: "#181a20",
color: "#fff",
}}
>
Loading...
</div>
);
}
if (!isAuthenticated) {
return <Login onLoginSuccess={handleLoginSuccess} />;
}
return (
<div className="app-container">
{/* {drawerOpen && (
<div
className="drawer-backdrop"
onClick={() => setDrawerOpen(false)}
style={{
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100vh",
background: "rgba(0, 0, 0, 0.5)",
zIndex: 1400,
opacity: drawerOpen ? 1 : 0,
transition: "opacity 0.3s ease",
}}
/>
)} */}
{drawerOpen && (
<div
className={`controls-section ${
drawerOpen ? "drawer-open" : ""
}`}
>
<div
className="control-group"
style={{
display: "flex",
alignItems: "center",
gap: "20px",
justifyContent: "flex-start",
}}
>
<button
onClick={handleLogout}
style={{
flexShrink: 0,
width: "auto",
minWidth: "80px",
marginLeft: "20px",
}}
>
Logout
</button>
<div
className="radio-group"
style={{
display: "flex",
flexDirection: "column",
gap: "8px",
flexShrink: 0,
width: "auto",
border: "2px solid #4a5568",
borderRadius: "8px",
padding: "12px",
backgroundColor: "#2d3748",
boxShadow: "0 2px 4px rgba(0,0,0,0.3)",
}}
>
<label
style={{
display: "flex",
alignItems: "center",
gap: "6px",
color: "white",
justifyContent: "flex-start",
cursor: "pointer",
}}
>
<input
type="radio"
name="camera_choice"
value="Leopards 1"
checked={selectedCamera === "Leopards 1"}
onChange={(e) =>
setSelectedCamera(e.target.value)
}
/>
Leopards 1
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: "6px",
color: "white",
justifyContent: "flex-start",
cursor: "pointer",
}}
>
<input
type="radio"
name="camera_choice"
value="Leopards 2"
checked={selectedCamera === "Leopards 2"}
onChange={(e) =>
setSelectedCamera(e.target.value)
}
/>
Leopards 2
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: "6px",
color: "white",
justifyContent: "flex-start",
cursor: "pointer",
}}
>
<input
type="radio"
name="camera_choice"
value="Leopards 3"
checked={selectedCamera === "Leopards 3"}
onChange={(e) =>
setSelectedCamera(e.target.value)
}
/>
Leopards 3
</label>
<label
style={{
display: "flex",
alignItems: "center",
gap: "6px",
color: "white",
justifyContent: "flex-start",
cursor: "pointer",
}}
>
<input
type="radio"
name="camera_choice"
value="Leopards 4"
checked={selectedCamera === "Leopards 4"}
onChange={(e) =>
setSelectedCamera(e.target.value)
}
/>
Leopards 4
</label>
</div>
</div>
</div>
)}
<div className="controls-section" style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "12px",
flexWrap: "nowrap",
padding: "12px 16px",
background: "linear-gradient(135deg, #2a2d3a 0%, #1f2129 100%)",
borderRadius: "12px",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
border: "1px solid #3a3f4f",
overflowX: "auto"
}}>
<div className="control-group" style={{ margin: 0, flexShrink: 0 }}>
<button
className="drawer-toggle"
onClick={() => setDrawerOpen(!drawerOpen)}
style={{
padding: "8px 12px",
fontSize: "0.85em",
minWidth: "70px",
background: "linear-gradient(135deg, #4a90e2 0%, #357abd 100%)",
color: "white",
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontWeight: "500",
boxShadow: "0 2px 4px rgba(74, 144, 226, 0.3)",
transition: "all 0.2s ease"
}}
onMouseEnter={(e) => {
e.target.style.transform = "translateY(-1px)";
e.target.style.boxShadow = "0 4px 8px rgba(74, 144, 226, 0.4)";
}}
onMouseLeave={(e) => {
e.target.style.transform = "translateY(0)";
e.target.style.boxShadow = "0 2px 4px rgba(74, 144, 226, 0.3)";
}}
>
{drawerOpen ? "✕" : "Options"}
</button>
</div>
<div className="control-group" style={{
margin: 0,
flexShrink: 0,
background: "rgba(255, 255, 255, 0.05)",
borderRadius: "6px",
border: "1px solid rgba(255, 255, 255, 0.1)"
}}>
<CustomDateRangePicker
startDate={startRange}
endDate={endRange}
setStartRange={setStartRange}
setEndRange={setEndRange}
/>
</div>
<div className="control-group" style={{ margin: 0, flex: "1 1 180px", minWidth: "180px", maxWidth: "300px" }}>
<input
type="text"
placeholder="Enter search query..."
value={queryText}
onChange={(e) => setQueryText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleResubmit();
}}
style={{
padding: "10px 12px",
borderRadius: "6px",
border: "2px solid #3a3f4f",
color: "#e1e5e9",
backgroundColor: "rgba(26, 29, 35, 0.8)",
width: "100%",
boxSizing: "border-box",
fontSize: "0.9em",
fontWeight: "400",
outline: "none",
transition: "all 0.2s ease",
boxShadow: "inset 0 2px 4px rgba(0, 0, 0, 0.1)"
}}
onFocus={(e) => {
e.target.style.borderColor = "#4a90e2";
e.target.style.boxShadow = "inset 0 2px 4px rgba(0, 0, 0, 0.1), 0 0 0 3px rgba(74, 144, 226, 0.15)";
}}
onBlur={(e) => {
e.target.style.borderColor = "#3a3f4f";
e.target.style.boxShadow = "inset 0 2px 4px rgba(0, 0, 0, 0.1)";
}}
ref={inputRef}
/>
</div>
<div className="control-group" style={{
margin: 0,
display: "flex",
alignItems: "center",
gap: "6px",
color: "#e1e5e9",
fontSize: "0.85em",
background: "rgba(255, 255, 255, 0.05)",
padding: "6px 10px",
borderRadius: "6px",
border: "1px solid rgba(255, 255, 255, 0.1)",
flexShrink: 0
}}>
<input
type="checkbox"
name="do_high_res"
checked={selectedHighRes}
onChange={(e) =>
selectHighResFunc(
selectedHighRes,
setSelectedHighRes,
)
}
style={{
width: "14px",
height: "14px",
accentColor: "#4a90e2"
}}
/>
<span style={{ fontWeight: "500", whiteSpace: "nowrap" }}>HD</span>
</div>
<div style={{
display: "flex",
alignItems: "center",
gap: "8px",
visibility: queryChanged ? "hidden" : "visible",
background: "rgba(255, 255, 255, 0.05)",
padding: "6px 12px",
borderRadius: "6px",
border: "1px solid rgba(255, 255, 255, 0.1)",
flexShrink: 0
}}>
<label style={{
color: "#e1e5e9",
fontSize: "0.85em",
whiteSpace: "nowrap",
fontWeight: "500"
}}>
Threshold:
</label>
<input
type="range"
min={sliderMin}
max={sliderMax}
step={0.001}
value={sliderValue}
onChange={(e) => updateDataAndValue(e.target.value)}
style={{
width: "80px",
height: "6px",
background: "linear-gradient(to right, #3a3f4f 0%, #4a90e2 100%)",
borderRadius: "3px",
outline: "none",
appearance: "none"
}}
/>
<span style={{
color: "#4a90e2",
fontSize: "0.8em",
minWidth: "35px",
textAlign: "right",
fontWeight: "600",
fontFamily: "monospace"
}}>
{sliderValue.toFixed(2)}
</span>
</div>
<div className="control-group" style={{
margin: 0,
visibility: queryChanged ? "visible" : "hidden",
flexShrink: 0
}}>
<button
onClick={handleResubmit}
style={{
padding: "8px 14px",
fontSize: "0.85em",
background: "linear-gradient(135deg, #28a745 0%, #20a73a 100%)",
color: "white",
border: "none",
borderRadius: "6px",
cursor: "pointer",
fontWeight: "600",
boxShadow: "0 2px 4px rgba(40, 167, 69, 0.3)",
transition: "all 0.2s ease",
whiteSpace: "nowrap"
}}
onMouseEnter={(e) => {
e.target.style.transform = "translateY(-1px)";
e.target.style.boxShadow = "0 4px 8px rgba(40, 167, 69, 0.4)";
}}
onMouseLeave={(e) => {
e.target.style.transform = "translateY(0)";
e.target.style.boxShadow = "0 2px 4px rgba(40, 167, 69, 0.3)";
}}
>
🔄 Resubmit
</button>
</div>
<div className="control-group" style={{ margin: 0, flexShrink: 0 }}>
<button
onClick={videoPlaying ? () => window.open("api/media_download/low") : undefined}
disabled={!videoPlaying}
style={{
padding: "6px 10px",
fontSize: "0.75em",
background: videoPlaying
? "linear-gradient(135deg, #17c671 0%, #15b565 100%)"
: "linear-gradient(135deg, #6c757d 0%, #5a6169 100%)",
color: videoPlaying ? "white" : "#adb5bd",
border: "none",
borderRadius: "6px",
cursor: videoPlaying ? "pointer" : "not-allowed",
opacity: videoPlaying ? 1 : 0.6,
fontWeight: "500",
boxShadow: videoPlaying
? "0 2px 4px rgba(23, 198, 113, 0.3)"
: "0 1px 2px rgba(108, 117, 125, 0.2)",
transition: "all 0.2s ease",
whiteSpace: "nowrap"
}}
onMouseEnter={(e) => {
if (videoPlaying) {
e.target.style.transform = "translateY(-1px)";
e.target.style.boxShadow = "0 4px 8px rgba(23, 198, 113, 0.4)";
}
}}
onMouseLeave={(e) => {
if (videoPlaying) {
e.target.style.transform = "translateY(0)";
e.target.style.boxShadow = "0 2px 4px rgba(23, 198, 113, 0.3)";
}
}}
>
DL Low-Res Video
</button>
</div>
<div className="control-group" style={{ margin: 0, flexShrink: 0 }}>
<button
onClick={videoPlaying ? () => window.open("api/media_download/high") : undefined}
disabled={!videoPlaying}
style={{
padding: "6px 10px",
fontSize: "0.75em",
background: videoPlaying
? "linear-gradient(135deg, #007bff 0%, #0056b3 100%)"
: "linear-gradient(135deg, #6c757d 0%, #5a6169 100%)",
color: videoPlaying ? "white" : "#adb5bd",
border: "none",
borderRadius: "6px",
cursor: videoPlaying ? "pointer" : "not-allowed",
opacity: videoPlaying ? 1 : 0.6,
fontWeight: "500",
boxShadow: videoPlaying
? "0 2px 4px rgba(0, 123, 255, 0.3)"
: "0 1px 2px rgba(108, 117, 125, 0.2)",
transition: "all 0.2s ease",
whiteSpace: "nowrap"
}}
onMouseEnter={(e) => {
if (videoPlaying) {
e.target.style.transform = "translateY(-1px)";
e.target.style.boxShadow = "0 4px 8px rgba(0, 123, 255, 0.4)";
}
}}
onMouseLeave={(e) => {
if (videoPlaying) {
e.target.style.transform = "translateY(0)";
e.target.style.boxShadow = "0 2px 4px rgba(0, 123, 255, 0.3)";
}
}}
>
DL High-Res Video
</button>
</div>
</div>
<div className="status-section">
<StatusesDisplayHUD statusMessages={statusMessages} />
</div>
<div className="timeline-section">
<EmbedTimeline
chartRef={chartRef}
data_in={dataResults}
onTimelineClick={handleTimelineClick}
authenticatedFetch={authenticatedFetch}
/>
</div>
<div className="video-section vjs-16-9 vjs-fluid">
<VideoPlayer
videoRef={playerRef}
playerInstanceRef={playerInstanceRef}
setMarkerTimeFunc={setMarkerValueNonReactive}
authenticatedFetch={authenticatedFetch}
/>
</div>
</div>
);
}
export default App;