Compare commits

..

No commits in common. "d4f14b97b0696a00445c7425a26324b15a6fd453" and "a43a563609591819da10509d5ebfee18c1c26e8e" have entirely different histories.

10 changed files with 105 additions and 406 deletions

BIN
bun.lockb

Binary file not shown.

View File

@ -23,7 +23,6 @@
"@hono/node-server": "^1.13.7",
"@unly/universal-language-detector": "^2.0.3",
"better-sqlite3": "^11.6.0",
"dayjs": "^1.11.13",
"detect-port": "^2.1.0",
"electron-context-menu": "^4.0.4",
"electron-reloader": "^1.2.3",
@ -39,7 +38,6 @@
"i18next-fs-backend": "^2.6.0",
"i18next-icu": "^2.3.0",
"image-size": "^1.1.1",
"jotai": "^2.11.0",
"memory-cache": "^0.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",

View File

@ -1,264 +1,9 @@
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 (
<img
src={src}
alt="Current frame"
className="w-full h-full object-cover absolute inset-0"
/>
);
}
export default function RewindPage() {
const { port, apiKey } = useAtomValue(apiInfoAtom);
const [timeline, setTimeline] = useState<Frame[]>([]);
const [currentFrameId, setCurrentFrameId] = useState<number | null>(null);
const [images, setImages] = useState<Record<number, string>>({});
const [isLoadingMore, setIsLoadingMore] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const lastAvaliableFrameId = useRef<number | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const updatedTimes = useRef<number>(0);
const loadingQueue = useRef<number[]>([]);
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 80ms
const now = Date.now();
if (now - lastScrollTime.current < 80) 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 (
<div
ref={containerRef}
className="w-screen h-screen relative dark:text-white overflow-hidden"
onWheel={handleScroll}
>
{/* Current image */}
<Image
src={
currentFrameId
? images[currentFrameId] ||
(lastAvaliableFrameId.current ? images[lastAvaliableFrameId.current] : "")
: ""
}
/>
{/* Time capsule */}
<div
className="absolute bottom-8 left-8 bg-zinc-800 text-white bg-opacity-80 backdrop-blur-lg
rounded-full px-4 py-2 text-xl"
>
{currentFrameId
? displayTime(
timeline.find((frame) => frame.id === currentFrameId)!.createdAt
) || "Loading..."
: "Loading..."}
</div>
{/* Timeline */}
<div
className="absolute top-8 right-8 h-5/6 overflow-hidden bg-zinc-800 bg-opacity-80 backdrop-blur-lg
px-4 py-2 text-xl"
>
{timeline
.filter((frame) => frame.id <= currentFrameId!)
.map((frame) => (
<div
key={frame.id}
className="flex items-center gap-2"
onClick={() => {
setCurrentFrameId(frame.id);
loadImage(frame.id);
}}
>
<span className="mt-2 text-base text-zinc-400">#{frame.id}</span>
<span className="text-white">
{dayjs().diff(dayjs.unix(frame.createdAt), "second")} sec ago
</span>
</div>
))}
</div>
</div>
<>
<div className="w-screen h-screen relative dark:text-white"></div>
</>
);
}

View File

@ -99,12 +99,6 @@ app.on("ready", () => {
cache.put("server:APIKey", key);
}
serve({ fetch: honoApp.fetch, port: port });
// Send API info to renderer
settingsWindow?.webContents.send("api-info", {
port,
apiKey: key
});
console.log(`App server running on port ${port}`);
});
});
@ -132,10 +126,3 @@ app.on("will-quit", () => {
ipcMain.on("close-settings", () => {
settingsWindow?.hide();
});
ipcMain.handle("request-api-info", () => {
return {
port: cache.get("server:port"),
apiKey: cache.get("server:APIKey")
};
});

View File

@ -1,5 +1,20 @@
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("appGlobal", {
requestApiInfo: () => ipcRenderer.invoke("request-api-info")
// Expose protected methods that allow the renderer process to use
// the ipcRenderer without exposing the entire object
contextBridge.exposeInMainWorld("api", {
send: (channel, data) => {
// whitelist channels
let validChannels = ["toMain"];
if (validChannels.includes(channel)) {
ipcRenderer.send(channel, data);
}
},
receive: (channel, func) => {
let validChannels = ["fromMain"];
if (validChannels.includes(channel)) {
// Deliberately strip event as it includes `sender`
ipcRenderer.on(channel, (event, ...args) => func(...args));
}
}
});

View File

@ -17,7 +17,3 @@ contextBridge.exposeInMainWorld("settingsWindow", {
ipcRenderer.send("close-settings", {});
}
});
contextBridge.exposeInMainWorld("appGlobal", {
requestApiInfo: () => ipcRenderer.invoke("request-api-info")
});

View File

@ -1,5 +1,4 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import cache from "memory-cache";
import { join } from "path";
import fs from "fs";
@ -16,8 +15,6 @@ import { existsSync } from "fs";
const app = new Hono();
app.use("*", cors());
app.use(async (c, next) => {
const key = cache.get("server:APIKey");
if (key && c.req.header("x-api-key") !== key) {
@ -48,7 +45,7 @@ function getFramesUntilID(db: Database, untilID: number, limit = 50): Frame[] {
`
SELECT id, createdAt, imgFilename, videoPath, videoFrameIndex
FROM frame
WHERE id < ?
WHERE id <= ?
ORDER BY createdAt DESC
LIMIT ?
`
@ -120,9 +117,6 @@ app.get("/frame/:id", async (c) => {
if (existsSync(decodedPath)) {
const imageBuffer = fs.readFileSync(decodedPath);
setTimeout(() => {
fs.unlinkSync(decodedPath);
}, 1000);
return new Response(imageBuffer, {
status: 200,
headers: {

View File

@ -3,38 +3,8 @@ import SettingsPage from "pages/settings";
import "./i18n.ts";
import RewindPage from "pages/rewind";
import "./app.css";
import { useEffect } from "react";
import { useAtom } from "jotai";
import { apiInfoAtom } from "./state/apiInfo.ts";
declare global {
interface Window {
appGlobal: {
requestApiInfo: () => Promise<{ port: number; apiKey: string }>;
};
}
}
export function App() {
const [apiInfo, setApiInfo] = useAtom(apiInfoAtom);
useEffect(() => {
const fetchApiInfo = async () => {
try {
const info = await window.appGlobal.requestApiInfo();
setApiInfo(info);
} catch (error) {
console.error("Failed to fetch API info:", error);
}
};
fetchApiInfo();
}, [setApiInfo]);
if (!apiInfo) {
return null;
}
return (
<div className="w-screen h-screen">
<HashRouter>

View File

@ -1,6 +0,0 @@
import { atom } from 'jotai'
export const apiInfoAtom = atom({
port: -1,
apiKey: ''
})