Skip to content

Commit 1c02406

Browse files
committed
Support URL timestamp seek and copy-at-time
1 parent 77127a8 commit 1c02406

File tree

2 files changed

+120
-18
lines changed

2 files changed

+120
-18
lines changed

apps/web/app/s/[videoId]/Share.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import type { comments as commentsSchema } from "@cap/database/schema";
44
import type { ImageUpload, Video } from "@cap/web-domain";
55
import { useQuery } from "@tanstack/react-query";
6+
import { useSearchParams } from "next/navigation";
67
import {
78
startTransition,
89
use,
@@ -349,6 +350,9 @@ export const Share = ({
349350

350351
const aiLoading = shouldShowLoading();
351352

353+
const searchParams = useSearchParams();
354+
const initialSeekDone = useRef(false);
355+
352356
const handleSeek = useCallback((time: number) => {
353357
const v =
354358
playerRef.current ??
@@ -386,6 +390,41 @@ export const Share = ({
386390
}, 3000);
387391
}, []);
388392

393+
useEffect(() => {
394+
if (initialSeekDone.current) return;
395+
const tParam = searchParams.get("t");
396+
if (!tParam) return;
397+
const t = parseInt(tParam, 10);
398+
if (!Number.isFinite(t) || t < 0) return;
399+
400+
const v =
401+
playerRef.current ??
402+
(document.querySelector("video") as HTMLVideoElement | null);
403+
if (v) {
404+
initialSeekDone.current = true;
405+
handleSeek(t);
406+
return;
407+
}
408+
409+
const interval = setInterval(() => {
410+
const el =
411+
playerRef.current ??
412+
(document.querySelector("video") as HTMLVideoElement | null);
413+
if (el) {
414+
clearInterval(interval);
415+
initialSeekDone.current = true;
416+
handleSeek(t);
417+
}
418+
}, 200);
419+
420+
const timeout = setTimeout(() => clearInterval(interval), 10000);
421+
422+
return () => {
423+
clearInterval(interval);
424+
clearTimeout(timeout);
425+
};
426+
}, [searchParams, handleSeek]);
427+
389428
const handleOptimisticComment = useCallback(
390429
(comment: CommentType) => {
391430
startTransition(() => {

apps/web/app/s/[videoId]/_components/ShareHeader.tsx

Lines changed: 81 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ import {
88
faLock,
99
} from "@fortawesome/free-solid-svg-icons";
1010
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
11-
import { Check, Copy, Globe2 } from "lucide-react";
11+
import { Check, Clock, Copy, Globe2 } from "lucide-react";
1212
import moment from "moment";
1313
import { useRouter } from "next/navigation";
14-
import { useEffect, useState } from "react";
14+
import { useEffect, useRef, useState } from "react";
1515
import { toast } from "sonner";
1616
import { editTitle } from "@/actions/videos/edit-title";
1717
import { useDashboardContext } from "@/app/(org)/dashboard/Contexts";
@@ -58,6 +58,23 @@ export const ShareHeader = ({
5858
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
5959
const [isSharingDialogOpen, setIsSharingDialogOpen] = useState(false);
6060
const [linkCopied, setLinkCopied] = useState(false);
61+
const [showCopyOptions, setShowCopyOptions] = useState(false);
62+
const [capturedTime, setCapturedTime] = useState(0);
63+
const copyOptionsRef = useRef<HTMLDivElement>(null);
64+
65+
useEffect(() => {
66+
if (!showCopyOptions) return;
67+
const handler = (e: MouseEvent) => {
68+
if (
69+
copyOptionsRef.current &&
70+
!copyOptionsRef.current.contains(e.target as Node)
71+
) {
72+
setShowCopyOptions(false);
73+
}
74+
};
75+
document.addEventListener("mousedown", handler);
76+
return () => document.removeEventListener("mousedown", handler);
77+
}, [showCopyOptions]);
6178

6279
const contextData = useDashboardContext();
6380
const contextSharedSpaces = contextData?.sharedSpaces || null;
@@ -130,6 +147,39 @@ export const ShareHeader = ({
130147
}
131148
};
132149

150+
const formatTimestamp = (seconds: number): string => {
151+
const h = Math.floor(seconds / 3600);
152+
const m = Math.floor((seconds % 3600) / 60);
153+
const s = seconds % 60;
154+
if (h > 0)
155+
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
156+
return `${m}:${String(s).padStart(2, "0")}`;
157+
};
158+
159+
const handleCopyClick = () => {
160+
const video = document.querySelector("video");
161+
const currentTime = video ? Math.floor(video.currentTime) : 0;
162+
163+
if (currentTime > 3) {
164+
setCapturedTime(currentTime);
165+
setShowCopyOptions(true);
166+
} else {
167+
navigator.clipboard.writeText(getVideoLink());
168+
setLinkCopied(true);
169+
setTimeout(() => setLinkCopied(false), 2000);
170+
}
171+
};
172+
173+
const handleCopyLink = (withTimestamp: boolean) => {
174+
const link = withTimestamp
175+
? `${getVideoLink()}?t=${capturedTime}`
176+
: getVideoLink();
177+
navigator.clipboard.writeText(link);
178+
setShowCopyOptions(false);
179+
setLinkCopied(true);
180+
setTimeout(() => setLinkCopied(false), 2000);
181+
};
182+
133183
const handleSharingUpdated = () => {
134184
refresh();
135185
};
@@ -268,23 +318,36 @@ export const ShareHeader = ({
268318
icon={faLock}
269319
/>
270320
)}
271-
<Button
272-
variant="white"
273-
onClick={() => {
274-
navigator.clipboard.writeText(getVideoLink());
275-
setLinkCopied(true);
276-
setTimeout(() => {
277-
setLinkCopied(false);
278-
}, 2000);
279-
}}
280-
>
281-
{getDisplayLink()}
282-
{linkCopied ? (
283-
<Check className="ml-2 w-4 h-4 svgpathanimation" />
284-
) : (
285-
<Copy className="ml-2 w-4 h-4" />
321+
<div className="relative" ref={copyOptionsRef}>
322+
<Button variant="white" onClick={handleCopyClick}>
323+
{getDisplayLink()}
324+
{linkCopied ? (
325+
<Check className="ml-2 w-4 h-4 svgpathanimation" />
326+
) : (
327+
<Copy className="ml-2 w-4 h-4" />
328+
)}
329+
</Button>
330+
{showCopyOptions && (
331+
<div className="absolute right-0 top-full z-50 mt-1 min-w-full w-max overflow-hidden rounded-lg border border-gray-6 bg-white shadow-lg">
332+
<button
333+
type="button"
334+
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-12 transition-colors hover:bg-gray-3"
335+
onClick={() => handleCopyLink(false)}
336+
>
337+
<Copy className="w-3.5 h-3.5 shrink-0" />
338+
Copy link
339+
</button>
340+
<button
341+
type="button"
342+
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-12 transition-colors hover:bg-gray-3"
343+
onClick={() => handleCopyLink(true)}
344+
>
345+
<Clock className="w-3.5 h-3.5 shrink-0" />
346+
Copy link at {formatTimestamp(capturedTime)}
347+
</button>
348+
</div>
286349
)}
287-
</Button>
350+
</div>
288351
</div>
289352
{userIsOwnerAndNotPro && (
290353
<button

0 commit comments

Comments
 (0)