Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions src/app/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,22 @@ export const revalidate = 60;

export default async function Home({
params,
searchParams,
}: {
params: Promise<{ slug: string }>;
searchParams: Promise<{ after?: string | string[] }>;
}) {
const { slug } = await params;
const [{ slug }, { after }] = await Promise.all([params, searchParams]);
const cursor = typeof after === "string" ? after : undefined;
// Next keeps this div when only ?after changes. The key makes each VOD page
// a new div, so the scroll goes back to the top and the loading state shows.
return (
<div className="flex min-h-0 flex-1 items-center justify-center overflow-y-auto">
<div
key={cursor}
className="flex min-h-0 flex-1 items-center justify-center overflow-y-auto"
>
<Suspense fallback={<LoadingPage />}>
<VODs username={slug} />
<VODs username={slug} after={cursor} />
</Suspense>
</div>
);
Expand Down
54 changes: 44 additions & 10 deletions src/app/_components/vods.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ function sendTwitchAPIRequest(path: string, title: string, creds: string) {
}

interface Pagination {
cursor: string;
cursor?: string;
}

interface TwitchVodRequest {
Expand Down Expand Up @@ -103,24 +103,30 @@ const VodEmptyState = () => {
);
};

export const VODs = async (props: { username: string }) => {
export const VODs = async (props: { username: string; after?: string }) => {
const self = await auth();
if (!self) throw new Error("you shouldn't be here");

const creds = await getTwitchClientCredentials();
const twitchUserId = await getTwitchUserId(props.username, creds);
if (!twitchUserId) return <VodEmptyState />;

// Twitch keeps past broadcasts for 60 days at most, so the max page size of
// 100 fits almost every channel on one page. The page links cover the rest.
const videoParams = new URLSearchParams({
user_id: twitchUserId,
type: "archive",
first: "100",
});
if (props.after) videoParams.set("after", props.after);
Comment thread
t3dotgg marked this conversation as resolved.
Comment thread
t3dotgg marked this conversation as resolved.

// fetch vods from twitch api

const [vodResult, streamResult]: [
PromiseSettledResult<TwitchVodRequest>,
PromiseSettledResult<TwitchStreamRequest>
] = await Promise.allSettled([
sendTwitchAPIRequest(
`/helix/videos?user_id=${twitchUserId}`,
"Videos",
creds
),
sendTwitchAPIRequest(`/helix/videos?${videoParams}`, "Videos", creds),
sendTwitchAPIRequest(
`/helix/streams?user_id=${twitchUserId}&type=live`,
"Streams",
Expand All @@ -132,24 +138,39 @@ export const VODs = async (props: { username: string }) => {
throw new Error(vodResult.reason.message);
}

// A failed live check only means the live VOD is not filtered out, so log
// it and still show the list.
if (streamResult.status === "rejected") {
throw new Error(streamResult.reason.message);
console.error("Live stream check failed:", streamResult.reason);
}

const vodData = vodResult.value.data;
const streamData = streamResult.value.data;
const streamData =
streamResult.status === "fulfilled" ? streamResult.value.data : [];
Comment thread
t3dotgg marked this conversation as resolved.

const streamMap = new Map<string, boolean>(
streamData.map((stream) => [stream.id, true])
);
const filteredVodData = vodData.filter(
({ stream_id }) => !streamMap.has(stream_id)
);
// Twitch can return an empty page near the end of the list. Treat an empty
// page as the end, and do not link past it.
const nextCursor =
filteredVodData.length > 0 ? vodResult.value.pagination?.cursor : undefined;
const channelPath = `/${encodeURIComponent(props.username)}`;
const olderVodsPath = nextCursor
? `${channelPath}?${new URLSearchParams({ after: nextCursor })}`
: null;

return (
<div className="my-auto flex max-w-7xl flex-wrap items-center justify-center gap-4 overflow-y-auto p-4">
{filteredVodData.length === 0 ? (
<VodEmptyState />
props.after ? (
<p className="text-sm text-gray-500">No older VODs.</p>
) : (
<VodEmptyState />
)
) : (
filteredVodData.map((vod) => (
<Link key={vod.id} href={`/v/${vod.id}`}>
Expand Down Expand Up @@ -182,6 +203,19 @@ export const VODs = async (props: { username: string }) => {
</Link>
))
)}
{(olderVodsPath || props.after) && (
<nav
className="flex w-full justify-center gap-3"
aria-label="VOD pages"
>
{props.after && (
<ButtonLink href={channelPath}>Latest VODs</ButtonLink>
)}
{olderVodsPath && (
<ButtonLink href={olderVodsPath}>Older VODs</ButtonLink>
)}
</nav>
)}
</div>
);
};
Loading