diff --git a/src/app/(core)/v/[slug]/loading.tsx b/src/app/(core)/v/[slug]/loading.tsx
new file mode 100644
index 0000000..de4c766
--- /dev/null
+++ b/src/app/(core)/v/[slug]/loading.tsx
@@ -0,0 +1,11 @@
+// Static placeholder in the same layout as the player page (no animation)
+export default function Loading() {
+ return (
+
+
+
+ Loading markers
+
+
+ );
+}
diff --git a/src/app/(core)/v/[slug]/page.tsx b/src/app/(core)/v/[slug]/page.tsx
index a5c2bfd..8cc659e 100644
--- a/src/app/(core)/v/[slug]/page.tsx
+++ b/src/app/(core)/v/[slug]/page.tsx
@@ -1,34 +1,82 @@
import { auth } from "@clerk/nextjs/server";
-import {
- getTwitchTokenFromClerk,
- getVodWithMarkers,
-} from "~/utils/twitch-server";
+import type { Metadata } from "next";
+import { ButtonLink } from "~/app/_components/common/button";
+import { SignInButton } from "~/app/_components/signin";
+import { EXPORT_BUFFER_SECONDS, MAX_BUFFER_SECONDS } from "~/utils/markers";
+import { getVodWithMarkers } from "~/utils/twitch-server";
import { VodPlayer } from "./player";
-import Script from "next/script";
-export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "VOD markers | MarkerThing" };
-// I do the revalidate 0 here because "force-dynamic" doesn't actually work
-// See: https://github.com/vercel/next.js/issues/47273
-export const revalidate = 60;
+const Notice = (props: { title: string; children: React.ReactNode }) => (
+
+ Go home
+
+ );
+ }
+
+ if (result.status === "creator-not-connected") {
+ return (
+
+
+ {result.rejected
+ ? `${result.creator} needs to sign in to MarkerThing again.`
+ : `${result.creator} has not signed in to MarkerThing yet.`}
+
+ Go home
+
+ );
+ }
+
+ // A repeated param (?offset=1&offset=2) arrives as an array. Use the first.
+ const param = (key: string) => [query[key]].flat()[0];
+ const buffer = Number(param("buffer"));
return (
- <>
-
-
- >
+ = 0 &&
+ buffer <= MAX_BUFFER_SECONDS
+ ? buffer
+ : EXPORT_BUFFER_SECONDS,
+ numbered: param("numbered") === "1",
+ }}
+ />
);
}
diff --git a/src/app/(core)/v/[slug]/player.tsx b/src/app/(core)/v/[slug]/player.tsx
index 23dc719..d37282b 100644
--- a/src/app/(core)/v/[slug]/player.tsx
+++ b/src/app/(core)/v/[slug]/player.tsx
@@ -1,223 +1,478 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
+import Link from "next/link";
+import Script from "next/script";
+import { useEffect, useMemo, useRef, useState } from "react";
import toast, { Toaster } from "react-hot-toast";
-import { Button } from "~/app/_components/common/button";
-import { Card } from "~/app/_components/common/card";
-import { TextInput } from "~/app/_components/common/text-input";
import {
buildSegments,
- EXPORT_BUFFER_SECONDS,
+ findMarkedOffset,
+ formatClipNumber,
formatSeconds,
+ MAX_BUFFER_SECONDS,
+ numberLabels,
parseOffsetValue,
parseTwitchDuration,
toCsv,
toYouTubeChapters,
+ type Segment,
} from "~/utils/markers";
import type { VOD } from "~/utils/twitch-server";
-import { Player } from "~/utils/types/twitch-player";
-
-const initializePlayer = (
- id: string,
- callback: (player: Player) => void
-): (() => void) => {
- // cleanup for previous player for effect bs
- const clearCurrent = () => {
- const playerElement = document.getElementById("vod-player");
- if (playerElement) {
- playerElement.innerHTML = "";
- }
- };
+import type { Player } from "~/utils/types/twitch-player";
- if (!(window as any).Twitch) {
- const timeout = setTimeout(() => initializePlayer(id, callback), 100);
- return () => {
- clearTimeout(timeout);
- clearCurrent();
- };
- }
+// Settings that live in the URL, so a refresh or a shared link keeps them
+export type VodSettings = { offset: string; buffer: number; numbered: boolean };
- const options = {
- width: "100%",
- height: "100%",
- video: id,
- autoplay: false,
- };
- const player = new (window as any).Twitch!.Player(
- "vod-player",
- options
- ) as Player;
+function setUrlParam(key: string, value: string | undefined) {
+ const url = new URL(window.location.href);
+ if (value) url.searchParams.set(key, value);
+ else url.searchParams.delete(key);
+ window.history.replaceState(null, "", url);
+}
- callback(player);
+// "1:05:12" or "5:12"
+const formatClock = (totalSeconds: number) => {
+ const [h, m, s] = formatSeconds(totalSeconds).split(":").map(Number);
+ const ss = String(s).padStart(2, "0");
+ return h ? `${h}:${String(m).padStart(2, "0")}:${ss}` : `${m}:${ss}`;
+};
- return clearCurrent;
+// "1h 20m", "10m 8s" or "45s"
+const formatLength = (totalSeconds: number) => {
+ const [h, m, s] = formatSeconds(totalSeconds).split(":").map(Number);
+ if (h) return `${h}h ${m}m`;
+ if (m) return s ? `${m}m ${s}s` : `${m}m`;
+ return `${s}s`;
};
-export const VodPlayer = (props: { id: string; vod: VOD }) => {
- const [player, setPlayer] = useState(null);
+// Small square checkbox: outlined when off, solid white when on
+const checkboxClasses =
+ "h-3 w-3 shrink-0 appearance-none border border-zinc-600 checked:border-zinc-400 checked:bg-zinc-400";
- useEffect(() => {
- const cleanup = initializePlayer(props.id, setPlayer);
+// Plain text actions. Disabled ones fade out and cannot be clicked.
+const actionClasses =
+ "whitespace-nowrap text-zinc-400 hover:text-white disabled:pointer-events-none disabled:text-zinc-700 aria-disabled:pointer-events-none aria-disabled:text-zinc-700";
- return cleanup;
- }, [props.id]);
+const PlayIcon = () => (
+
+);
- const [offset, setOffset] = useState<{
- presentational: string;
- totalSeconds: number;
- }>({
- presentational: "0",
- totalSeconds: 0,
- });
+// Copy chapters and download CSV for one export target
+const ExportRow = (props: {
+ title: string;
+ chapters: string;
+ csv: string;
+ fileName: string;
+ disabled?: boolean;
+ // Small color key that matches the timeline strip
+ marker?: React.ReactNode;
+ children?: React.ReactNode;
+}) => (
+
+
+ {props.marker}
+ {props.title}
+ {props.children}
+
+
+ {/* No href while disabled, so the link cannot be clicked or keyboard activated */}
+
+ Download CSV
+
+
+);
- const segments = useMemo(
+export const VodPlayer = (props: { vod: VOD; initial: VodSettings }) => {
+ const { vod } = props;
+ const videoSeconds = parseTwitchDuration(vod.duration);
+
+ const playerRef = useRef(null);
+ const [scriptReady, setScriptReady] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+
+ const [buffer, setBuffer] = useState(props.initial.buffer);
+ const [numbered, setNumbered] = useState(props.initial.numbered);
+ const [excluded, setExcluded] = useState>(new Set());
+ const [renamed, setRenamed] = useState>({});
+ const [editingId, setEditingId] = useState();
+
+ // Times in the list always match the Twitch VOD
+ const vodSegments = useMemo(
() =>
buildSegments({
- markers: props.vod.markers,
- videoSeconds: parseTwitchDuration(props.vod.duration),
- offsetSeconds: offset.totalSeconds,
+ markers: vod.markers,
+ videoSeconds,
+ bufferSeconds: buffer,
}),
- [props.vod, offset.totalSeconds]
+ [vod.markers, videoSeconds, buffer]
);
+ // The clips you marked. The fake intro only labels chapters, so it is not
+ // listed or exported. toYouTubeChapters adds its own Intro chapter.
+ const clips = vodSegments.filter((s) => s.type === "start" && !s.isIntro);
+
+ // An OFFSET marker fills in the camera start, unless the URL has one
+ const markedOffset = findMarkedOffset(vodSegments);
+ const [cameraInput, setCameraInput] = useState(
+ props.initial.offset ||
+ (markedOffset === undefined ? "" : formatClock(markedOffset))
+ );
+ const cameraSeconds =
+ cameraInput.trim() === "" ? undefined : parseOffsetValue(cameraInput);
+ const cameraInvalid =
+ cameraInput.trim() !== "" && cameraSeconds === undefined;
+
+ const cameraSegments = useMemo(
+ () =>
+ cameraSeconds === undefined
+ ? []
+ : buildSegments({
+ markers: vod.markers,
+ videoSeconds,
+ offsetSeconds: cameraSeconds,
+ bufferSeconds: buffer,
+ }),
+ [vod.markers, videoSeconds, cameraSeconds, buffer]
+ );
+
+ // A cleared rename falls back to the marker's own label
+ const labelOf = (clip: Segment) => renamed[clip.id]?.trim() || clip.label;
+
+ // Export data only changes with these inputs, not with the 1s playhead poll
+ const exports = useMemo(() => {
+ // The checked clips, with renames and optional "01 " numbers
+ const pick = (segments: Segment[]) =>
+ segments
+ .filter((s) => s.type === "start" && !s.isIntro && !excluded.has(s.id))
+ .map((s) => ({ ...s, label: renamed[s.id]?.trim() || s.label }));
+ const vodClips = pick(vodSegments);
+ const cameraClips = pick(cameraSegments);
+ const order = vodClips.map((clip) => clip.id);
+ const toFileCsv = (picked: Segment[]) =>
+ toCsv(numbered ? numberLabels(picked, order) : picked);
+
+ return {
+ count: vodClips.length,
+ numbers: new Map(
+ order.map((id, i) => [id, formatClipNumber(i + 1, order.length)])
+ ),
+ vod: { chapters: toYouTubeChapters(vodClips), csv: toFileCsv(vodClips) },
+ camera: {
+ chapters: toYouTubeChapters(cameraClips),
+ csv: toFileCsv(cameraClips),
+ },
+ };
+ }, [vodSegments, cameraSegments, excluded, renamed, numbered]);
+
+ // A clip is playing from its padded start until the next clip takes over
+ const activeId = clips.findLast(
+ (clip) =>
+ Math.max(clip.vodStart - buffer, 0) <= currentTime &&
+ currentTime < clip.vodEnd
+ )?.id;
+
+ useEffect(() => {
+ if (!scriptReady || !window.Twitch) return;
+
+ const player = new window.Twitch.Player("vod-player", {
+ width: "100%",
+ height: "100%",
+ video: vod.id,
+ autoplay: false,
+ });
+ playerRef.current = player;
+
+ // The embed has no time update event, so poll to track the playhead
+ const interval = setInterval(
+ () => setCurrentTime(Math.floor(player.getCurrentTime())),
+ 1000
+ );
+
+ return () => {
+ clearInterval(interval);
+ playerRef.current = null;
+ document.getElementById("vod-player")?.replaceChildren();
+ };
+ }, [scriptReady, vod.id]);
+
+ const seek = (clip: Segment) => {
+ const time = Math.max(clip.vodStart - buffer, 0);
+ playerRef.current?.seek(time);
+ setCurrentTime(time);
+ };
+
+ const updateCamera = (value: string) => {
+ setCameraInput(value);
+ const trimmed = value.trim();
+ if (trimmed === "" || parseOffsetValue(trimmed) !== undefined) {
+ setUrlParam("offset", trimmed || undefined);
+ }
+ };
+
+ const toggleClip = (id: string) =>
+ setExcluded((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
+
+ const percent = (seconds: number) =>
+ `${(Math.min(seconds, videoSeconds) / (videoSeconds || 1)) * 100}%`;
+ const fileDate = vod.created_at.replaceAll(":", "-");
+
+ // Only shown when it helps: a bad value, or no value yet
+ const cameraNote = cameraInvalid
+ ? "Use H:MM:SS, MM:SS or seconds"
+ : cameraSeconds === undefined
+ ? "Seek to where your camera starts, or add /marker OFFSET when you start recording"
+ : markedOffset !== undefined && cameraSeconds === markedOffset
+ ? "From your OFFSET marker"
+ : undefined;
return (
-