Skip to content

Commit 828570b

Browse files
Merge pull request #354 from CapSoftware/share-link-features
feat: Improved video loading/playblack on mobile + thumbnail on seek
2 parents 857782b + 7c38240 commit 828570b

File tree

8 files changed

+1019
-192
lines changed

8 files changed

+1019
-192
lines changed
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { db } from "@cap/database";
2+
import { videos, sharedVideos, spaces } from "@cap/database/schema";
3+
import { eq, and } from "drizzle-orm";
4+
import { NextRequest } from "next/server";
5+
6+
export async function GET(request: NextRequest) {
7+
const { searchParams } = request.nextUrl;
8+
const videoId = searchParams.get("videoId");
9+
10+
if (!videoId) {
11+
return Response.json({ error: "Video ID is required" }, { status: 400 });
12+
}
13+
14+
try {
15+
// First, get the video to find the owner or shared space
16+
const video = await db
17+
.select({
18+
id: videos.id,
19+
ownerId: videos.ownerId,
20+
})
21+
.from(videos)
22+
.where(eq(videos.id, videoId))
23+
.limit(1);
24+
25+
if (video.length === 0) {
26+
return Response.json({ error: "Video not found" }, { status: 404 });
27+
}
28+
29+
const videoData = video[0];
30+
if (!videoData || !videoData.ownerId) {
31+
return Response.json({ error: "Invalid video data" }, { status: 500 });
32+
}
33+
34+
// Check if the video is shared with a space
35+
const sharedVideo = await db
36+
.select({
37+
spaceId: sharedVideos.spaceId,
38+
})
39+
.from(sharedVideos)
40+
.where(eq(sharedVideos.videoId, videoId))
41+
.limit(1);
42+
43+
let spaceId = null;
44+
if (sharedVideo.length > 0 && sharedVideo[0] && sharedVideo[0].spaceId) {
45+
spaceId = sharedVideo[0].spaceId;
46+
}
47+
48+
// If we have a space ID, get the space's custom domain
49+
if (spaceId) {
50+
const space = await db
51+
.select({
52+
customDomain: spaces.customDomain,
53+
domainVerified: spaces.domainVerified,
54+
})
55+
.from(spaces)
56+
.where(eq(spaces.id, spaceId))
57+
.limit(1);
58+
59+
if (space.length > 0 && space[0] && space[0].customDomain) {
60+
return Response.json({
61+
customDomain: space[0].customDomain,
62+
domainVerified: space[0].domainVerified || false,
63+
});
64+
}
65+
}
66+
67+
// If no shared space or no custom domain, check the owner's space
68+
const ownerSpaces = await db
69+
.select({
70+
customDomain: spaces.customDomain,
71+
domainVerified: spaces.domainVerified,
72+
})
73+
.from(spaces)
74+
.where(eq(spaces.ownerId, videoData.ownerId))
75+
.limit(1);
76+
77+
if (ownerSpaces.length > 0 && ownerSpaces[0] && ownerSpaces[0].customDomain) {
78+
return Response.json({
79+
customDomain: ownerSpaces[0].customDomain,
80+
domainVerified: ownerSpaces[0].domainVerified || false,
81+
});
82+
}
83+
84+
// No custom domain found
85+
return Response.json({
86+
customDomain: null,
87+
domainVerified: false,
88+
});
89+
} catch (error) {
90+
console.error("Error fetching domain info:", error);
91+
return Response.json({ error: "Internal server error" }, { status: 500 });
92+
}
93+
}

apps/web/app/globals.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,3 +442,11 @@ footer a {
442442
.animate-slideUp {
443443
animation: slideUp 0.3s ease-out forwards;
444444
}
445+
446+
/* Safari-specific styles using CSS hacks */
447+
@media screen and (-webkit-min-device-pixel-ratio:0) {
448+
_::-webkit-full-page-media, _:future, :root #video-container {
449+
height: calc(100% - 1.70rem) !important;
450+
}
451+
}
452+

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

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
"use client";
22

3-
import { ShareHeader } from "./_components/ShareHeader";
4-
import { ShareVideo } from "./_components/ShareVideo";
5-
import { comments as commentsSchema, videos } from "@cap/database/schema";
63
import { userSelectProps } from "@cap/database/auth/session";
7-
import { Toolbar } from "./_components/Toolbar";
4+
import { comments as commentsSchema, videos } from "@cap/database/schema";
5+
import { clientEnv } from "@cap/env";
86
import { Logo } from "@cap/ui";
7+
import { useEffect, useRef, useState } from "react";
8+
import { ShareHeader } from "./_components/ShareHeader";
9+
import { ShareVideo } from "./_components/ShareVideo";
910
import { Sidebar } from "./_components/Sidebar";
10-
import { useEffect, useState, useRef } from "react";
11-
import { clientEnv } from "@cap/env";
11+
import { Toolbar } from "./_components/Toolbar";
1212

1313
type CommentWithAuthor = typeof commentsSchema.$inferSelect & {
1414
authorName: string | null;
@@ -38,6 +38,8 @@ interface ShareProps {
3838
comments: number;
3939
reactions: number;
4040
};
41+
customDomain: string | null;
42+
domainVerified: boolean;
4143
}
4244

4345
export const Share: React.FC<ShareProps> = ({
@@ -46,6 +48,8 @@ export const Share: React.FC<ShareProps> = ({
4648
comments,
4749
individualFiles,
4850
initialAnalytics,
51+
customDomain,
52+
domainVerified,
4953
}) => {
5054
const [analytics, setAnalytics] = useState(initialAnalytics);
5155

@@ -89,17 +93,19 @@ export const Share: React.FC<ShareProps> = ({
8993

9094
return (
9195
<div className="min-h-screen flex flex-col bg-[#F7F8FA]">
92-
<div className="flex-1 container mx-auto px-4 py-4">
96+
<div className="container flex-1 px-4 py-4 mx-auto">
9397
<ShareHeader
9498
data={data}
9599
user={user}
96100
individualFiles={individualFiles}
101+
customDomain={customDomain}
102+
domainVerified={domainVerified}
97103
/>
98104

99105
<div className="mt-4">
100-
<div className="flex flex-col lg:flex-row gap-4">
106+
<div className="flex flex-col gap-4 lg:flex-row">
101107
<div className="flex-1">
102-
<div className="relative aspect-video new-card-style p-3 overflow-hidden">
108+
<div className="overflow-hidden relative p-3 aspect-video new-card-style">
103109
<ShareVideo
104110
data={data}
105111
user={user}
@@ -112,7 +118,7 @@ export const Share: React.FC<ShareProps> = ({
112118
</div>
113119
</div>
114120

115-
<div className="lg:w-80 flex flex-col">
121+
<div className="flex flex-col lg:w-80">
116122
<Sidebar
117123
data={data}
118124
user={user}
@@ -124,17 +130,17 @@ export const Share: React.FC<ShareProps> = ({
124130
</div>
125131
</div>
126132

127-
<div className="hidden lg:block mt-4">
133+
<div className="hidden mt-4 lg:block">
128134
<Toolbar data={data} user={user} />
129135
</div>
130136
</div>
131137
</div>
132138

133-
<div className="mt-auto py-4">
139+
<div className="py-4 mt-auto">
134140
<a
135141
target="_blank"
136142
href={`${clientEnv.NEXT_PUBLIC_WEB_URL}?ref=video_${data.id}`}
137-
className="flex items-center justify-center space-x-2 py-2 px-4 bg-gray-100 new-card-style rounded-full mx-auto w-fit"
143+
className="flex justify-center items-center px-4 py-2 mx-auto space-x-2 bg-gray-100 rounded-full new-card-style w-fit"
138144
>
139145
<span className="text-sm">Recorded with</span>
140146
<Logo className="w-14 h-auto" />

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

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import {
2-
memo,
32
forwardRef,
4-
useRef,
5-
useImperativeHandle,
3+
memo,
4+
useCallback,
65
useEffect,
6+
useImperativeHandle,
7+
useRef,
78
useState,
8-
useCallback,
99
} from "react";
1010

1111
interface MP4VideoPlayerProps {
@@ -57,6 +57,10 @@ export const MP4VideoPlayer = memo(
5757
const video = videoRef.current;
5858
if (!video) return;
5959

60+
// Store the current position before reloading
61+
const currentPosition = video.currentTime;
62+
const wasPlaying = !video.paused;
63+
6064
// Get a fresh URL from the API
6165
const newUrl = await fetchNewUrl();
6266

@@ -69,6 +73,20 @@ export const MP4VideoPlayer = memo(
6973
// Reset video and reload with new source
7074
video.load();
7175

76+
// Restore position and play state after loading
77+
if (currentPosition > 0) {
78+
const restorePosition = () => {
79+
video.currentTime = currentPosition;
80+
if (wasPlaying) {
81+
video
82+
.play()
83+
.catch((err) => console.error("Error resuming playback:", err));
84+
}
85+
video.removeEventListener("canplay", restorePosition);
86+
};
87+
video.addEventListener("canplay", restorePosition);
88+
}
89+
7290
// Update the last attempt time
7391
lastAttemptTime.current = Date.now();
7492
}, [fetchNewUrl]);
@@ -135,6 +153,10 @@ export const MP4VideoPlayer = memo(
135153
const handleLoadedData = () => {
136154
console.log("Video loaded successfully");
137155
setIsLoaded(true);
156+
// Dispatch canplay event to notify parent component
157+
if (videoRef.current) {
158+
videoRef.current.dispatchEvent(new Event("canplay"));
159+
}
138160
// Clear any retry timeouts if video is loaded
139161
if (retryTimeout.current) {
140162
clearTimeout(retryTimeout.current);
@@ -143,8 +165,8 @@ export const MP4VideoPlayer = memo(
143165
};
144166

145167
const handleLoadedMetadata = () => {
146-
// Trigger a canplay event after metadata is loaded
147-
video.dispatchEvent(new Event("canplay"));
168+
// We'll let the loadeddata event handle dispatching canplay
169+
// This ensures we don't trigger the event too early
148170
};
149171

150172
const handleError = (e: ErrorEvent) => {
@@ -202,7 +224,7 @@ export const MP4VideoPlayer = memo(
202224
<video
203225
id="video-player"
204226
ref={videoRef}
205-
className="w-full h-full object-contain"
227+
className="object-contain w-full h-full"
206228
preload="auto"
207229
playsInline
208230
controls={false}

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

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,17 @@ export const ShareHeader = ({
1414
data,
1515
user,
1616
individualFiles,
17+
customDomain,
18+
domainVerified,
1719
}: {
1820
data: typeof videos.$inferSelect;
1921
user: typeof userSelectProps | null;
2022
individualFiles?: {
2123
fileName: string;
2224
url: string;
2325
}[];
26+
customDomain: string | null;
27+
domainVerified: boolean;
2428
}) => {
2529
const { push, refresh } = useRouter();
2630
const [isEditing, setIsEditing] = useState(false);
@@ -82,6 +86,22 @@ export const ShareHeader = ({
8286
}
8387
};
8488

89+
const getVideoLink = () => {
90+
return customDomain && domainVerified
91+
? `https://${customDomain}/s/${data.id}`
92+
: clientEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production"
93+
? `https://cap.link/${data.id}`
94+
: `${clientEnv.NEXT_PUBLIC_WEB_URL}/s/${data.id}`;
95+
};
96+
97+
const getDisplayLink = () => {
98+
return customDomain && domainVerified
99+
? `${customDomain}/s/${data.id}`
100+
: clientEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production"
101+
? `cap.link/${data.id}`
102+
: `${clientEnv.NEXT_PUBLIC_WEB_URL}/s/${data.id}`;
103+
};
104+
85105
return (
86106
<>
87107
<div>
@@ -144,24 +164,11 @@ export const ShareHeader = ({
144164
variant="gray"
145165
className="hover:bg-gray-300"
146166
onClick={() => {
147-
if (
148-
clientEnv.NEXT_PUBLIC_IS_CAP &&
149-
NODE_ENV === "production"
150-
) {
151-
navigator.clipboard.writeText(
152-
`https://cap.link/${data.id}`
153-
);
154-
} else {
155-
navigator.clipboard.writeText(
156-
`${clientEnv.NEXT_PUBLIC_WEB_URL}/s/${data.id}`
157-
);
158-
}
167+
navigator.clipboard.writeText(getVideoLink());
159168
toast.success("Link copied to clipboard!");
160169
}}
161170
>
162-
{clientEnv.NEXT_PUBLIC_IS_CAP && NODE_ENV === "production"
163-
? `cap.link/${data.id}`
164-
: `${clientEnv.NEXT_PUBLIC_WEB_URL}/s/${data.id}`}
171+
{getDisplayLink()}
165172
<Copy className="ml-2 h-4 w-4" />
166173
</Button>
167174
{user !== null && (

0 commit comments

Comments
 (0)