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 (
- <>
-
- >
+
+ );
+}
+
+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