commit fb6dc5d7726a72a65ef466e132ec756c7b7de8cc
parent d5cb49794a3ebc589dbcc2bc70409bc0d0e9a7f3
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 22 Apr 2026 20:40:55 -0400
Add odysee player and p-limit for scripts
Diffstat:
7 files changed, 99 insertions(+), 10 deletions(-)
diff --git a/app/OdyseePlayer.tsx b/app/OdyseePlayer.tsx
@@ -0,0 +1,47 @@
+"use client";
+
+import { forwardRef, useImperativeHandle, useState } from "react";
+
+export type OdyseePlayerHandle = {
+ seekTo: (seconds: number, unit?: "seconds") => void;
+};
+
+type Props = {
+ videoId: string;
+ startSeconds?: number;
+};
+
+// Odysee's iframe embed — reloading with a new `t` query param is our only
+// way to seek from the outside (no JS API, no currentTime tracking).
+const OdyseePlayer = forwardRef<OdyseePlayerHandle, Props>(function OdyseePlayer(
+ { videoId, startSeconds = 0 },
+ ref,
+) {
+ const [start, setStart] = useState(() =>
+ Math.max(0, Math.floor(startSeconds)),
+ );
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ seekTo(seconds: number) {
+ setStart(Math.max(0, Math.floor(seconds)));
+ },
+ }),
+ [],
+ );
+
+ const src = `https://odysee.com/%24/embed/${encodeURIComponent(videoId)}?t=${start}`;
+
+ return (
+ <iframe
+ key={start}
+ src={src}
+ allow="autoplay; fullscreen; encrypted-media; picture-in-picture"
+ allowFullScreen
+ style={{ border: 0, width: "100%", height: "100%" }}
+ />
+ );
+});
+
+export default OdyseePlayer;
diff --git a/app/PlayerProvider.tsx b/app/PlayerProvider.tsx
@@ -16,6 +16,7 @@ import { useUrlParams, writeUrlParams } from "./urlState";
import { formatDate, formatDuration } from "@/lib/format";
import type { Platform } from "@/lib/transcripts";
import type { RumblePlayerHandle } from "./RumblePlayer";
+import type { OdyseePlayerHandle } from "./OdyseePlayer";
const ReactPlayer = dynamic(() => import("react-player/youtube"), {
ssr: false,
@@ -25,7 +26,14 @@ const RumblePlayer = dynamic(() => import("./RumblePlayer"), {
ssr: false,
});
-type PlayerHandle = Pick<ReactPlayerType, "seekTo"> | RumblePlayerHandle;
+const OdyseePlayer = dynamic(() => import("./OdyseePlayer"), {
+ ssr: false,
+});
+
+type PlayerHandle =
+ | Pick<ReactPlayerType, "seekTo">
+ | RumblePlayerHandle
+ | OdyseePlayerHandle;
export type Cue = { start: number; end: number; text: string };
@@ -228,7 +236,7 @@ export function PlayerProvider({
const copyShareUrl = useCallback(async (): Promise<boolean> => {
if (!data) return false;
- const t = data.platform === "rumble" ? (urlTime ?? 0) : currentTime;
+ const t = data.platform === "youtube" ? currentTime : (urlTime ?? 0);
const secs = Math.max(0, Math.floor(t));
const params = new URLSearchParams();
params.set("v", data.slug);
@@ -302,11 +310,12 @@ export function PlayerProvider({
setCurrentTime(urlTime);
}, [urlSlug, urlTime]);
- // Rumble has no onReady or progress events; seed currentTime from the URL so
- // clip-mark buttons and active-cue highlighting have a baseline, and mark
- // the slug ready so later ?t= changes can seek via the imperative handle.
+ // Rumble / Odysee have no onReady or progress events; seed currentTime from
+ // the URL so clip-mark buttons and active-cue highlighting have a baseline,
+ // and mark the slug ready so later ?t= changes can seek via the imperative
+ // handle.
useEffect(() => {
- if (detail?.platform === "rumble") {
+ if (detail?.platform === "rumble" || detail?.platform === "odysee") {
readyForSlugRef.current = detail.slug;
pendingSeekRef.current = null;
setCurrentTime(urlTime ?? 0);
@@ -390,6 +399,15 @@ export function PlayerProvider({
videoId={data.id}
startSeconds={urlTime ?? 0}
/>
+ ) : data.platform === "odysee" ? (
+ <OdyseePlayer
+ key={data.id}
+ ref={(p: OdyseePlayerHandle | null) => {
+ playerRef.current = p;
+ }}
+ videoId={data.id}
+ startSeconds={urlTime ?? 0}
+ />
) : (
<ReactPlayer
ref={(p: ReactPlayerType | null) => {
diff --git a/lib/transcripts-server.ts b/lib/transcripts-server.ts
@@ -16,16 +16,19 @@ export type RawMetadata = {
extractor?: string;
extractor_key?: string;
webpage_url?: string;
+ webpage_url_basename?: string;
};
function detectPlatform(meta: RawMetadata): Platform {
const key = meta.extractor_key ?? meta.extractor ?? "";
if (/^rumble/i.test(key)) return "rumble";
+ if (/^lbry/i.test(key)) return "odysee";
return "youtube";
}
function defaultWebpageUrl(platform: Platform, id: string): string {
if (platform === "rumble") return `https://rumble.com/${id}`;
+ if (platform === "odysee") return `https://odysee.com/${id}`;
return `https://www.youtube.com/watch?v=${id}`;
}
@@ -42,9 +45,12 @@ export function summarize(
liveStatus === "was_live" ||
liveStatus === "is_live" ||
liveStatus === "is_upcoming";
- const id = meta.id ?? videoDir;
- const dateFromDir = videoDir.match(/^(\d{8})(?:_|$)/)?.[1];
const platform = detectPlatform(meta);
+ const id =
+ platform === "odysee"
+ ? (meta.webpage_url_basename ?? meta.id ?? videoDir)
+ : (meta.id ?? videoDir);
+ const dateFromDir = videoDir.match(/^(\d{8})(?:_|$)/)?.[1];
return {
slug: `${channelSlug}/${id}`,
id,
diff --git a/lib/transcripts.ts b/lib/transcripts.ts
@@ -9,7 +9,7 @@ const MANIFEST_PATH = path.join(
"manifest.json",
);
-export type Platform = "youtube" | "rumble";
+export type Platform = "youtube" | "rumble" | "odysee";
export type TranscriptSummary = {
slug: string;
diff --git a/package.json b/package.json
@@ -17,6 +17,7 @@
"execa": "^9.6.1",
"lmdb": "^3.5.4",
"next": "16.2.3",
+ "p-limit": "^7.3.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-player": "^2.16.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
@@ -23,6 +23,9 @@ importers:
next:
specifier: 16.2.3
version: 16.2.3(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+ p-limit:
+ specifier: ^7.3.0
+ version: 7.3.0
react:
specifier: 19.2.4
version: 19.2.4
@@ -2146,6 +2149,10 @@ packages:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
+ p-limit@7.3.0:
+ resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==}
+ engines: {node: '>=20'}
+
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
@@ -2604,6 +2611,10 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
+ yocto-queue@1.2.2:
+ resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
+ engines: {node: '>=12.20'}
+
yoctocolors@2.1.2:
resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==}
engines: {node: '>=18'}
@@ -4692,6 +4703,10 @@ snapshots:
dependencies:
yocto-queue: 0.1.0
+ p-limit@7.3.0:
+ dependencies:
+ yocto-queue: 1.2.2
+
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
@@ -5278,6 +5293,8 @@ snapshots:
yocto-queue@0.1.0: {}
+ yocto-queue@1.2.2: {}
+
yoctocolors@2.1.2: {}
zod-validation-error@4.0.2(zod@4.3.6):
diff --git a/scripts/build-index.ts b/scripts/build-index.ts
@@ -57,7 +57,7 @@ const SUMMARIES_DIR = path.join(PUBLIC_DIR, "summaries");
const TRANSCRIPTS_DIR = path.join(PUBLIC_DIR, "transcripts");
const MANIFEST_PATH = path.join(SUMMARIES_DIR, "manifest.json");
-const SCHEMA_VERSION = 4;
+const SCHEMA_VERSION = 5;
type Handling = "youtube" | "transcribe";
type ChannelConfig = { handling: Handling; name?: string };