Compare commits
No commits in common. "d4f14b97b0696a00445c7425a26324b15a6fd453" and "a43a563609591819da10509d5ebfee18c1c26e8e" have entirely different histories.
d4f14b97b0
...
a43a563609
@ -23,7 +23,6 @@
|
|||||||
"@hono/node-server": "^1.13.7",
|
"@hono/node-server": "^1.13.7",
|
||||||
"@unly/universal-language-detector": "^2.0.3",
|
"@unly/universal-language-detector": "^2.0.3",
|
||||||
"better-sqlite3": "^11.6.0",
|
"better-sqlite3": "^11.6.0",
|
||||||
"dayjs": "^1.11.13",
|
|
||||||
"detect-port": "^2.1.0",
|
"detect-port": "^2.1.0",
|
||||||
"electron-context-menu": "^4.0.4",
|
"electron-context-menu": "^4.0.4",
|
||||||
"electron-reloader": "^1.2.3",
|
"electron-reloader": "^1.2.3",
|
||||||
@ -39,7 +38,6 @@
|
|||||||
"i18next-fs-backend": "^2.6.0",
|
"i18next-fs-backend": "^2.6.0",
|
||||||
"i18next-icu": "^2.3.0",
|
"i18next-icu": "^2.3.0",
|
||||||
"image-size": "^1.1.1",
|
"image-size": "^1.1.1",
|
||||||
"jotai": "^2.11.0",
|
|
||||||
"memory-cache": "^0.2.0",
|
"memory-cache": "^0.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
@ -1,264 +1,9 @@
|
|||||||
import "./index.css";
|
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() {
|
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 (
|
return (
|
||||||
<div
|
<>
|
||||||
ref={containerRef}
|
<div className="w-screen h-screen relative dark:text-white"></div>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -99,12 +99,6 @@ app.on("ready", () => {
|
|||||||
cache.put("server:APIKey", key);
|
cache.put("server:APIKey", key);
|
||||||
}
|
}
|
||||||
serve({ fetch: honoApp.fetch, port: port });
|
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}`);
|
console.log(`App server running on port ${port}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -132,10 +126,3 @@ app.on("will-quit", () => {
|
|||||||
ipcMain.on("close-settings", () => {
|
ipcMain.on("close-settings", () => {
|
||||||
settingsWindow?.hide();
|
settingsWindow?.hide();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle("request-api-info", () => {
|
|
||||||
return {
|
|
||||||
port: cache.get("server:port"),
|
|
||||||
apiKey: cache.get("server:APIKey")
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
@ -1,5 +1,20 @@
|
|||||||
const { contextBridge, ipcRenderer } = require("electron");
|
const { contextBridge, ipcRenderer } = require("electron");
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("appGlobal", {
|
// Expose protected methods that allow the renderer process to use
|
||||||
requestApiInfo: () => ipcRenderer.invoke("request-api-info")
|
// 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
@ -17,7 +17,3 @@ contextBridge.exposeInMainWorld("settingsWindow", {
|
|||||||
ipcRenderer.send("close-settings", {});
|
ipcRenderer.send("close-settings", {});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("appGlobal", {
|
|
||||||
requestApiInfo: () => ipcRenderer.invoke("request-api-info")
|
|
||||||
});
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { cors } from "hono/cors";
|
|
||||||
import cache from "memory-cache";
|
import cache from "memory-cache";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@ -16,8 +15,6 @@ import { existsSync } from "fs";
|
|||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
|
|
||||||
app.use("*", cors());
|
|
||||||
|
|
||||||
app.use(async (c, next) => {
|
app.use(async (c, next) => {
|
||||||
const key = cache.get("server:APIKey");
|
const key = cache.get("server:APIKey");
|
||||||
if (key && c.req.header("x-api-key") !== key) {
|
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
|
SELECT id, createdAt, imgFilename, videoPath, videoFrameIndex
|
||||||
FROM frame
|
FROM frame
|
||||||
WHERE id < ?
|
WHERE id <= ?
|
||||||
ORDER BY createdAt DESC
|
ORDER BY createdAt DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`
|
`
|
||||||
@ -120,9 +117,6 @@ app.get("/frame/:id", async (c) => {
|
|||||||
|
|
||||||
if (existsSync(decodedPath)) {
|
if (existsSync(decodedPath)) {
|
||||||
const imageBuffer = fs.readFileSync(decodedPath);
|
const imageBuffer = fs.readFileSync(decodedPath);
|
||||||
setTimeout(() => {
|
|
||||||
fs.unlinkSync(decodedPath);
|
|
||||||
}, 1000);
|
|
||||||
return new Response(imageBuffer, {
|
return new Response(imageBuffer, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -3,38 +3,8 @@ import SettingsPage from "pages/settings";
|
|||||||
import "./i18n.ts";
|
import "./i18n.ts";
|
||||||
import RewindPage from "pages/rewind";
|
import RewindPage from "pages/rewind";
|
||||||
import "./app.css";
|
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() {
|
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 (
|
return (
|
||||||
<div className="w-screen h-screen">
|
<div className="w-screen h-screen">
|
||||||
<HashRouter>
|
<HashRouter>
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
import { atom } from 'jotai'
|
|
||||||
|
|
||||||
export const apiInfoAtom = atom({
|
|
||||||
port: -1,
|
|
||||||
apiKey: ''
|
|
||||||
})
|
|
Loading…
Reference in New Issue
Block a user