diff --git a/bun.lockb b/bun.lockb index 578b8cf..c2529d1 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 111e920..405a3c4 100644 --- a/package.json +++ b/package.json @@ -1,85 +1,87 @@ { - "name": "openrewind", - "version": "0.6.0", - "type": "module", - "description": "Your second brain, superpowered.", - "main": "dist/electron/index.js", - "scripts": { - "dev": "cross-env NODE_ENV=dev bun run dev:all", - "dev:all": "concurrently -n=react,electron -c='#ff3e00',blue \"bun run dev:react\" \"bun run dev:electron\"", - "dev:react": "vite dev", - "dev:electron": "bunx gulp build && electron dist/electron/index.js", - "build:react": "vite build", - "build:app": "bunx gulp build", - "build:electron": "electron-builder", - "format": "bunx prettier --write ." - }, - "keywords": [], - "author": "", - "license": "MIT", - "dependencies": { - "@alikia/random-key": "npm:@jsr/alikia__random-key", - "@electron/remote": "^2.1.2", - "@hono/node-server": "^1.13.7", - "@unly/universal-language-detector": "^2.0.3", - "better-sqlite3": "^11.6.0", - "detect-port": "^2.1.0", - "electron-context-menu": "^4.0.4", - "electron-reloader": "^1.2.3", - "electron-screencapture": "^1.1.0", - "electron-serve": "^2.1.1", - "electron-store": "^10.0.0", - "electron-window-state": "^5.0.3", - "execa": "^9.5.1", - "hono": "^4.6.15", - "i18next": "^24.0.2", - "i18next-browser-languagedetector": "^8.0.0", - "i18next-electron-fs-backend": "^3.0.2", - "i18next-fs-backend": "^2.6.0", - "i18next-icu": "^2.3.0", - "image-size": "^1.1.1", - "memory-cache": "^0.2.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "react-i18next": "^15.1.2", - "react-router": "^7.0.1", - "react-router-dom": "^7.0.1", - "screenshot-desktop": "^1.15.0", - "sqlite3": "^5.1.7", - "sqlstring": "^2.3.3", - "vite-tsconfig-paths": "^5.1.3" - }, - "devDependencies": { - "@electron/rebuild": "^3.7.1", - "@eslint/js": "^9.13.0", - "@iconify-icon/react": "^2.1.0", - "@types/better-sqlite3": "^7.6.12", - "@types/gulp": "^4.0.17", - "@types/memory-cache": "^0.2.6", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@types/screenshot-desktop": "^1.12.3", - "@types/sqlstring": "^2.3.2", - "@vitejs/plugin-react": "^4.3.3", - "autoprefixer": "^10.4.19", - "concurrently": "^9.0.1", - "cross-env": "^7.0.3", - "del": "^8.0.0", - "electron": "^33.2.0", - "electron-build": "^0.0.3", - "electron-builder": "^25.1.8", - "eslint": "^9.13.0", - "eslint-plugin-react-hooks": "^5.0.0", - "eslint-plugin-react-refresh": "^0.4.14", - "globals": "^15.11.0", - "gulp": "^5.0.0", - "gulp-clean": "^0.4.0", - "gulp-typescript": "6.0.0-alpha.1", - "postcss": "^8.4.38", - "tailwindcss": "^3.4.15", - "typescript": "~5.6.2", - "typescript-eslint": "^8.11.0", - "vite": "^5.4.10", - "vite-plugin-chunk-split": "^0.5.0" - } + "name": "openrewind", + "version": "0.6.0", + "type": "module", + "description": "Your second brain, superpowered.", + "main": "dist/electron/index.js", + "scripts": { + "dev": "cross-env NODE_ENV=dev bun run dev:all", + "dev:all": "concurrently -n=react,electron -c='#ff3e00',blue \"bun run dev:react\" \"bun run dev:electron\"", + "dev:react": "vite dev", + "dev:electron": "bunx gulp build && electron dist/electron/index.js", + "build:react": "vite build", + "build:app": "bunx gulp build", + "build:electron": "electron-builder", + "format": "bunx prettier --write ." + }, + "keywords": [], + "author": "", + "license": "MIT", + "dependencies": { + "@alikia/random-key": "npm:@jsr/alikia__random-key", + "@electron/remote": "^2.1.2", + "@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", + "electron-screencapture": "^1.1.0", + "electron-serve": "^2.1.1", + "electron-store": "^10.0.0", + "electron-window-state": "^5.0.3", + "execa": "^9.5.1", + "hono": "^4.6.15", + "i18next": "^24.0.2", + "i18next-browser-languagedetector": "^8.0.0", + "i18next-electron-fs-backend": "^3.0.2", + "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", + "react-i18next": "^15.1.2", + "react-router": "^7.0.1", + "react-router-dom": "^7.0.1", + "screenshot-desktop": "^1.15.0", + "sqlite3": "^5.1.7", + "sqlstring": "^2.3.3", + "vite-tsconfig-paths": "^5.1.3" + }, + "devDependencies": { + "@electron/rebuild": "^3.7.1", + "@eslint/js": "^9.13.0", + "@iconify-icon/react": "^2.1.0", + "@types/better-sqlite3": "^7.6.12", + "@types/gulp": "^4.0.17", + "@types/memory-cache": "^0.2.6", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/screenshot-desktop": "^1.12.3", + "@types/sqlstring": "^2.3.2", + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.19", + "concurrently": "^9.0.1", + "cross-env": "^7.0.3", + "del": "^8.0.0", + "electron": "^33.2.0", + "electron-build": "^0.0.3", + "electron-builder": "^25.1.8", + "eslint": "^9.13.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.14", + "globals": "^15.11.0", + "gulp": "^5.0.0", + "gulp-clean": "^0.4.0", + "gulp-typescript": "6.0.0-alpha.1", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.15", + "typescript": "~5.6.2", + "typescript-eslint": "^8.11.0", + "vite": "^5.4.10", + "vite-plugin-chunk-split": "^0.5.0" + } } diff --git a/pages/rewind/index.tsx b/pages/rewind/index.tsx index 3d35de0..30e6406 100644 --- a/pages/rewind/index.tsx +++ b/pages/rewind/index.tsx @@ -1,9 +1,216 @@ 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"; -export default function RewindPage() { +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 ( - <> -
- + Current frame + ); +} + +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 lastRequestTime = useRef(Date.now()); + const containerRef = useRef(null); + const lastAvaliableFrameId = useRef(null); + + useEffect(() => { + if (currentFrameId && images[currentFrameId]) { + lastAvaliableFrameId.current = currentFrameId; + } + }, [images, currentFrameId]); + + // 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]); + + const loadImage = useCallback( + (frameId: number) => { + if (!port) return; + // Rate limit to at most 1 request every 200ms + const now = Date.now(); + if (images[frameId]) return; + + lastRequestTime.current = now; + fetch(`http://localhost:${port}/frame/${frameId}`, { + headers: { + "x-api-key": apiKey + } + }) + .then((res) => res.blob()) + .then((blob) => { + const url = URL.createObjectURL(blob); + setImages((prev) => ({ ...prev, [frameId]: url })); + }) + .catch(console.error); + }, + [apiKey, images, port] + ); + + // 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; + + 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..."} +
+ + {/* Timeline */} +
+ {timeline + .filter((frame) => frame.id <= currentFrameId!) + .map((frame) => ( +
{ + setCurrentFrameId(frame.id); + loadImage(frame.id); + }} + > + #{frame.id} + + {dayjs().diff(dayjs.unix(frame.createdAt), "second")} sec ago + +
+ ))} +
+
); } diff --git a/src/electron/index.ts b/src/electron/index.ts index 57b9d37..b0bb404 100644 --- a/src/electron/index.ts +++ b/src/electron/index.ts @@ -99,6 +99,12 @@ 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}`); }); }); @@ -126,3 +132,10 @@ 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") + }; +}); diff --git a/src/electron/preload/rewind.cjs b/src/electron/preload/rewind.cjs index ea28cf5..52a5241 100644 --- a/src/electron/preload/rewind.cjs +++ b/src/electron/preload/rewind.cjs @@ -1,20 +1,5 @@ const { contextBridge, ipcRenderer } = require("electron"); -// 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)); - } - } +contextBridge.exposeInMainWorld("appGlobal", { + requestApiInfo: () => ipcRenderer.invoke("request-api-info") }); diff --git a/src/electron/preload/settings.cjs b/src/electron/preload/settings.cjs index 4c1cede..0004681 100644 --- a/src/electron/preload/settings.cjs +++ b/src/electron/preload/settings.cjs @@ -17,3 +17,7 @@ contextBridge.exposeInMainWorld("settingsWindow", { ipcRenderer.send("close-settings", {}); } }); + +contextBridge.exposeInMainWorld("appGlobal", { + requestApiInfo: () => ipcRenderer.invoke("request-api-info") +}); diff --git a/src/electron/server/index.ts b/src/electron/server/index.ts index 092c7dc..877dea8 100644 --- a/src/electron/server/index.ts +++ b/src/electron/server/index.ts @@ -1,4 +1,5 @@ import { Hono } from "hono"; +import { cors } from "hono/cors"; import cache from "memory-cache"; import { join } from "path"; import fs from "fs"; @@ -15,6 +16,8 @@ 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) { @@ -45,7 +48,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 ? ` diff --git a/src/electron/utils/video/index.ts b/src/electron/utils/video/index.ts index a48d9a8..c483d2e 100644 --- a/src/electron/utils/video/index.ts +++ b/src/electron/utils/video/index.ts @@ -36,7 +36,7 @@ export function immediatelyExtractFrameFromVideo( const outputFilename = `${bareVideoFilename}_${frameIndex.toString().padStart(4, "0")}.bmp`; const outputPathArg = join(outputPath, outputFilename); const args = [ - "-ss", + "-ss", `${formatTime(frameIndex / ENCODING_FRAME_RATE)}`, "-i", `${fullVideoPath}`, diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index c2bfc36..38dc59f 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -3,8 +3,38 @@ 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 (
diff --git a/src/renderer/state/apiInfo.ts b/src/renderer/state/apiInfo.ts new file mode 100644 index 0000000..066be24 --- /dev/null +++ b/src/renderer/state/apiInfo.ts @@ -0,0 +1,6 @@ +import { atom } from 'jotai' + +export const apiInfoAtom = atom({ + port: -1, + apiKey: '' +}) \ No newline at end of file