diff --git a/src/app/[slug]/page.tsx b/src/app/[slug]/page.tsx
index 4d00dee..ea78ec6 100644
--- a/src/app/[slug]/page.tsx
+++ b/src/app/[slug]/page.tsx
@@ -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 (
-
+
}>
-
+
);
diff --git a/src/app/_components/vods.tsx b/src/app/_components/vods.tsx
index 2877a85..22b97d8 100644
--- a/src/app/_components/vods.tsx
+++ b/src/app/_components/vods.tsx
@@ -41,7 +41,7 @@ function sendTwitchAPIRequest(path: string, title: string, creds: string) {
}
interface Pagination {
- cursor: string;
+ cursor?: string;
}
interface TwitchVodRequest {
@@ -103,12 +103,22 @@ 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 ;
+
+ // 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);
// fetch vods from twitch api
@@ -116,11 +126,7 @@ export const VODs = async (props: { username: string }) => {
PromiseSettledResult,
PromiseSettledResult
] = 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",
@@ -132,12 +138,15 @@ 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 : [];
const streamMap = new Map(
streamData.map((stream) => [stream.id, true])
@@ -145,11 +154,23 @@ export const VODs = async (props: { username: string }) => {
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 (