import "./index.css"; import { useCallback, useEffect, useRef, useState } from "react"; import type { Frame } from "../../src/electron/backend/schema.d.ts"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import localizedFormat from "dayjs/plugin/localizedFormat"; import updateLocale from "dayjs/plugin/updateLocale"; import { useAtomValue } from "jotai"; import { apiInfoAtom } from "src/renderer/state/apiInfo.ts"; dayjs.extend(relativeTime); dayjs.extend(localizedFormat); dayjs.extend(updateLocale); dayjs.updateLocale("en", { relativeTime: { future: "in %s", past: "%s ago", s: "%d seconds", m: "1 minute", mm: "%d minutes", h: "1 hour", hh: "%d hours", d: "1 day", dd: "%d days", M: "1 month", MM: "%d months", y: "1 year", yy: "%d years" } }); function Image({ src }: { src: string }) { return ( ); } // TODO: Memory optimization export default function RewindPage() { const { port, apiKey } = useAtomValue(apiInfoAtom); const [timeline, setTimeline] = useState([]); const [currentFrameId, setCurrentFrameId] = useState(null); const [images, setImages] = useState>({}); const [isLoadingMore, setIsLoadingMore] = useState(false); const containerRef = useRef(null); const lastAvaliableFrameId = useRef(null); const timeoutRef = useRef(null); const updatedTimes = useRef(0); const loadingQueue = useRef([]); const isProcessingQueue = useRef(false); const processQueue = useCallback(async () => { if (!port || isProcessingQueue.current || loadingQueue.current.length === 0) return; isProcessingQueue.current = true; const frameId = loadingQueue.current.shift()!; try { const startUpdateTimes = updatedTimes.current; const response = await fetch(`http://localhost:${port}/frame/${frameId}`, { headers: { "x-api-key": apiKey } }); const blob = await response.blob(); const url = URL.createObjectURL(blob); setImages((prev) => { const newImages = { ...prev, [frameId]: url }; if (updatedTimes.current <= startUpdateTimes) { lastAvaliableFrameId.current = frameId; updatedTimes.current++; } return newImages; }); } catch (error) { console.error(error); } finally { isProcessingQueue.current = false; setTimeout(() => { processQueue(); }, 500); } }, [apiKey, port]); const loadImage = useCallback( (frameId: number) => { if (!port || images[frameId]) return; // Add to queue if not already in it if (!loadingQueue.current.includes(frameId)) { loadingQueue.current.push(frameId); // preserve up to 5 tasks in the queue loadingQueue.current = loadingQueue.current.slice(-5); } // Start processing if not already running if (!isProcessingQueue.current) { processQueue(); } }, [images, port, processQueue] ); // Load current frame after 400ms of inactivity useEffect(() => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } if (currentFrameId) { timeoutRef.current = setTimeout(() => { loadImage(currentFrameId); }, 400); } return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, [currentFrameId, loadImage]); // Fetch timeline data const fetchTimeline = useCallback( async (untilID?: number) => { if (!port) return; try { const url = new URL(`http://localhost:${port}/timeline`); if (untilID) { url.searchParams.set("untilID", untilID.toString()); } const response = await fetch(url.toString(), { headers: { "x-api-key": apiKey } }); const data = await response.json(); setTimeline((prev) => (untilID ? [...prev, ...data] : data)); } catch (error) { console.error(error); } }, [port, apiKey] ); useEffect(() => { fetchTimeline(); }, [fetchTimeline]); // Load initial images useEffect(() => { if (timeline.length > 0 && !currentFrameId) { setCurrentFrameId(timeline[0].id); loadImage(timeline[0].id); if (timeline.length > 1) { loadImage(timeline[1].id); } } }, [timeline, currentFrameId, loadImage]); const lastScrollTime = useRef(Date.now()); const handleScroll = (e: React.WheelEvent) => { if (!containerRef.current || !currentFrameId) return; // Only allow scroll changes every 30ms const now = Date.now(); if (now - lastScrollTime.current < 30) return; lastScrollTime.current = now; const delta = Math.sign(e.deltaY); const currentIndex = timeline.findIndex((frame) => frame.id === currentFrameId); if (currentIndex === -1) return; const newIndex = Math.min(Math.max(currentIndex - delta, 0), timeline.length - 1); const newFrameId = timeline[newIndex].id; console.log(currentFrameId, lastAvaliableFrameId); if (newFrameId !== currentFrameId) { setCurrentFrameId(newFrameId); // Preload adjacent images if (newIndex > 0) loadImage(timeline[newIndex - 1].id); if (newIndex < timeline.length - 1) loadImage(timeline[newIndex + 1].id); // Load more timeline data when we're near the end if (newIndex > timeline.length - 10 && !isLoadingMore) { setIsLoadingMore(true); const lastID = timeline[timeline.length - 1].id; fetchTimeline(lastID).finally(() => setIsLoadingMore(false)); } } }; function displayTime(time: number) { // if diff < 1h, fromNow() // else use localized format const diff = dayjs().diff(dayjs.unix(time), "second"); if (diff < 3600) { return dayjs.unix(time).fromNow(); } else { return dayjs.unix(time).format("llll"); } } return ( {/* Current image */} {/* Time capsule */} {currentFrameId ? displayTime( timeline.find((frame) => frame.id === currentFrameId)!.createdAt ) || "Loading..." : "Loading..."} ); }