feature: a usable timeline
This commit is contained in:
parent
a43a563609
commit
f778c4f44b
168
package.json
168
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"
|
||||
}
|
||||
}
|
||||
|
@ -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 (
|
||||
<>
|
||||
<div className="w-screen h-screen relative dark:text-white"></div>
|
||||
</>
|
||||
<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 lastRequestTime = useRef(Date.now());
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const lastAvaliableFrameId = useRef<number | null>(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 (
|
||||
<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 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>
|
||||
{dayjs().diff(dayjs.unix(frame.createdAt), "second")} sec ago
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -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")
|
||||
};
|
||||
});
|
||||
|
@ -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")
|
||||
});
|
||||
|
@ -17,3 +17,7 @@ contextBridge.exposeInMainWorld("settingsWindow", {
|
||||
ipcRenderer.send("close-settings", {});
|
||||
}
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld("appGlobal", {
|
||||
requestApiInfo: () => ipcRenderer.invoke("request-api-info")
|
||||
});
|
||||
|
@ -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 ?
|
||||
`
|
||||
|
@ -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}`,
|
||||
|
@ -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 (
|
||||
<div className="w-screen h-screen">
|
||||
<HashRouter>
|
||||
|
6
src/renderer/state/apiInfo.ts
Normal file
6
src/renderer/state/apiInfo.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { atom } from 'jotai'
|
||||
|
||||
export const apiInfoAtom = atom({
|
||||
port: -1,
|
||||
apiKey: ''
|
||||
})
|
Loading…
Reference in New Issue
Block a user